From 4374ba0b350f6d2b2b59abc2228a500bcd7bfa06 Mon Sep 17 00:00:00 2001 From: Sandro Ducceschi Date: Fri, 2 May 2025 01:57:33 +0200 Subject: [PATCH] major refactor - added data store for banners - better css variable handling - more modularity - reduced redundancy - better performance --- esbuild.config.mjs | 28 +- package.json | 2 +- src/data/store.ts | 33 ++ src/feature/banner.ts | 50 +++ src/feature/icon.ts | 51 ++++ src/main.scss | 6 + src/main.ts | 569 +++++++++------------------------- src/scss/animations.scss | 15 + src/scss/banner.scss | 66 ++++ src/scss/frontmatter.scss | 143 +++++++++ src/scss/icon.scss | 91 ++++++ src/scss/settings.scss | 65 ++++ src/scss/variables.scss | 39 +++ src/settings.ts | 625 -------------------------------------- src/settings/core.ts | 465 ++++++++++++++++++++++++++++ src/settings/migrator.ts | 60 ++++ src/styles.scss | 378 ----------------------- src/types/enums.ts | 39 +++ src/types/interfaces.ts | 87 ++++++ src/utils/domutils.ts | 37 +++ src/utils/parse.ts | 161 ++++++++++ 21 files changed, 1572 insertions(+), 1438 deletions(-) create mode 100644 src/data/store.ts create mode 100644 src/feature/banner.ts create mode 100644 src/feature/icon.ts create mode 100644 src/main.scss create mode 100644 src/scss/animations.scss create mode 100644 src/scss/banner.scss create mode 100644 src/scss/frontmatter.scss create mode 100644 src/scss/icon.scss create mode 100644 src/scss/settings.scss create mode 100644 src/scss/variables.scss delete mode 100644 src/settings.ts create mode 100644 src/settings/core.ts create mode 100644 src/settings/migrator.ts delete mode 100644 src/styles.scss create mode 100644 src/types/enums.ts create mode 100644 src/types/interfaces.ts create mode 100644 src/utils/domutils.ts create mode 100644 src/utils/parse.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 7b903d1..ac7af1d 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -2,16 +2,17 @@ import esbuild from 'esbuild'; import process from 'process'; import builtins from 'builtin-modules'; import { sassPlugin } from 'esbuild-sass-plugin'; -import { cp } from 'node:fs/promises'; +import { cp, readFile } from 'node:fs/promises'; import 'dotenv/config'; -const banner = -`/* -THIS IS A GENERATED/BUNDLED FILE BY ESBUILD -if you want to view the source, please visit the github repository of this plugin -*/ -`; - +const getBanner = async () => { + const manifest = JSON.parse(await readFile('./manifest.json', 'utf-8')); + return `/** + * Simple Banner v${manifest.version} + * @author ${manifest.author} + * @url ${manifest.authorUrl} + */`; +}; const prod = (process.argv[2] === 'production'); const vaultCopy = { name: 'vault-copy', @@ -21,9 +22,9 @@ const vaultCopy = { if (vaultPluginPath) { try { await Promise.all([ - cp('dist/main.js', `${vaultPluginPath}/main.js`, { overwrite: true }), cp('manifest.json', `${vaultPluginPath}/manifest.json`, { overwrite: true }), - cp('dist/styles.css', `${vaultPluginPath}/styles.css`, { overwrite: true }), + cp('dist/main.js', `${vaultPluginPath}/main.js`, { overwrite: true }), + cp('dist/main.css', `${vaultPluginPath}/styles.css`, { overwrite: true }), ]); } catch (copyError) { console.error('Error copying files:', copyError); @@ -35,9 +36,12 @@ const vaultCopy = { const context = await esbuild.context({ banner: { - js: banner, + js: await getBanner(), }, - entryPoints: ['src/main.ts', 'src/styles.scss'], + entryPoints: [ + 'src/main.ts', + 'src/main.scss', + ], bundle: true, external: [ 'obsidian', diff --git a/package.json b/package.json index 413edb5..e0fe97a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "simple-banner", "version": "0.2.0", "description": "Simply add a banner image and an icon", - "main": "src/main.js", + "main": "dist/main.js", "scripts": { "dev": "node esbuild.config.mjs", "test": "tsc -noEmit -skipLibCheck", diff --git a/src/data/store.ts b/src/data/store.ts new file mode 100644 index 0000000..7f7aab6 --- /dev/null +++ b/src/data/store.ts @@ -0,0 +1,33 @@ +import { BannerData, Datastore } from '../types/interfaces'; + + + +const storage = {} as Datastore; + +export default class Store { + static get(id: string): BannerData | null { + return storage[id] || null; + } + + static set(id: string, data: BannerData) { + storage[id] = data; + } + + static delete(id: string | null = null) { + if (id) { + delete storage[id]; + } + } + + static exists(id: string): boolean { + return storage[id] !== undefined; + } + + static getAll(): Datastore { + return storage; + } + + static getIds(): string[] { + return Object.keys(storage); + } +} diff --git a/src/feature/banner.ts b/src/feature/banner.ts new file mode 100644 index 0000000..24376ec --- /dev/null +++ b/src/feature/banner.ts @@ -0,0 +1,50 @@ +import { BannerData, ImageOptions } from '../types/interfaces'; +import { CSSClasses, CSSValue } from '../types/enums'; +import DomUtils from '../utils/domutils'; + +const MAIN_SELECTOR = `.${CSSClasses.Main}`; + +export default class Banner { + static update(data: BannerData, imgOptions: ImageOptions, containers: NodeListOf): HTMLElement[] { + const { isImageChange, isImagePropsUpdate } = data; + const banners: HTMLElement[] = []; + + containers.forEach(container => { + let element = (container.querySelector(MAIN_SELECTOR) || document.createElement('div')) as HTMLElement; + element.classList.add(CSSClasses.Main); + banners.push(element); + + if (isImageChange || isImagePropsUpdate) { + if (isImageChange) { + element.classList.remove(CSSClasses.Static); + } + + DomUtils.setCSSVariables({ + 'url': `url(${imgOptions.url})`, + 'img-x': `${imgOptions.x}px`, + 'img-y': `${imgOptions.y}px`, + 'size': imgOptions.repeatable ? CSSValue.Auto : CSSValue.RevertLayer, + 'repeat': imgOptions.repeatable ? CSSValue.Repeat : CSSValue.RevertLayer, + }, container); + } + }); + return banners; + } + + static insert(banners: HTMLElement[], containers: NodeListOf): void { + containers.forEach((container, index) => { + const banner: HTMLElement = banners[index]; + container.prepend(banner); + banner.onanimationend = () => { + const instances = document.querySelectorAll(MAIN_SELECTOR); + instances.forEach(i => i.classList.add(CSSClasses.Static)); + } + }); + } + + static replace(banners: HTMLElement[]): void { + banners.forEach((banner) => { + banner.classList.add(CSSClasses.Static); + }) + } +} diff --git a/src/feature/icon.ts b/src/feature/icon.ts new file mode 100644 index 0000000..6881f72 --- /dev/null +++ b/src/feature/icon.ts @@ -0,0 +1,51 @@ +import { Platform } from 'obsidian'; +import { BannerData, DeviceSettings } from '../types/interfaces'; +import { CSSClasses, IconType } from '../types/enums'; +import DomUtils from '../utils/domutils'; +import Parse from '../utils/parse'; + +export default class Icon { + static update(data: BannerData, banners: HTMLElement[], settings: DeviceSettings) { + let calculatedFontSize: string | null = null; + banners.forEach((banner) => { + const { icon, view } = data; + let iconContainer = banner.querySelector(`.${CSSClasses.Icon}`) || null; + const hadIconContainer = iconContainer !== null; + if (hadIconContainer) { + iconContainer?.classList.add(CSSClasses.Static); + } + if (settings.iconEnabled && icon) { + if (!hadIconContainer) { + iconContainer = document.createElement('div'); + iconContainer.classList.add(CSSClasses.Icon); + if (Platform.isWin) { + iconContainer.classList.add(CSSClasses.IsWindows); + } + const div = document.createElement('div'); + iconContainer.appendChild(div); + banner.prepend(iconContainer); + } + + if (iconContainer) { + const iconelement = iconContainer.querySelector('div') as HTMLElement; + + let { value, type } = Parse.icon(icon, view); + value = value?.replace(/([#.:[\\]"])/g, '\\$1') || ''; + iconelement.dataset.type = type; + + const iconVars = {} as any; + iconVars['icon-value'] = type === IconType.Link ? `url(${value})` : `"${value}"`; + + if (type === IconType.Text) { + calculatedFontSize = calculatedFontSize ? calculatedFontSize : DomUtils.calculateFontsize(value); + iconVars['icon-fontsize'] = calculatedFontSize; + } + DomUtils.setCSSVariables(iconVars, iconelement); + } + } else if (iconContainer) { + data.icon = null; + iconContainer.remove(); + } + }); + } +} diff --git a/src/main.scss b/src/main.scss new file mode 100644 index 0000000..b753027 --- /dev/null +++ b/src/main.scss @@ -0,0 +1,6 @@ +@use 'scss/variables'; +@use 'scss/animations'; +@use 'scss/banner'; +@use 'scss/icon'; +@use 'scss/frontmatter'; +@use 'scss/settings'; diff --git a/src/main.ts b/src/main.ts index bbc37e7..bbf3375 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,62 +1,13 @@ -import { - MarkdownView, - Platform, - Plugin, - TFile, - Workspace, - WorkspaceLeaf, -} from 'obsidian'; -import { - Settings, - SettingsMigrator, - SimpleBannerSettings, - PropertySettings, - DeviceSettings, -} from "./settings"; - -//---------------------------------- -// Interfaces -//---------------------------------- -interface BannerData { - filepath: string | null; - image: string | null; - icon: string | null, - viewMode: ViewMode | null; - lastViewMode: ViewMode | null; - isImageChange: boolean; - isImagePropsUpdate: boolean; - isIconChange: boolean; - needsUpdate: boolean; - container: HTMLElement | null; -} - -interface ImageProperties { - x: number; - y: number; - repeatable: boolean; -} - -interface IconData { - value: string | null, - type: IconType, -} - -enum IconType { - Link = 'link', - Text = 'text', -} - -enum ViewMode { - Source = 'source', - Reading = 'preview', -} - -const RegExpression = { - Wikilink: /^!?\[\[([^\]]+?)(\|([^\]]+?))?\]\]$/, - Markdown: /^!?\[([^\]]*)\]\(([^)]+?)\)$/, - MarkdownBare: /^!?<([^>]+)>$/, - Weblink: /^https?:\/\//i, -}; +import { MarkdownView, Plugin, TFile, WorkspaceLeaf, } from 'obsidian'; +import { BannerData, DeviceSettings, PropertySettings, SimpleBannerSettings } from './types/interfaces'; +import { CSSClasses, CSSValue, ViewMode } from './types/enums'; +import Settings from './settings/core'; +import SettingsMigrator from './settings/migrator'; +import Store from './data/store'; +import DomUtils from './utils/domutils'; +import Parse from './utils/parse'; +import Banner from './feature/banner'; +import Icon from './feature/icon'; export default class SimpleBanner extends Plugin { //--------------------------------------------------- @@ -64,27 +15,29 @@ export default class SimpleBanner extends Plugin { // Variables // //--------------------------------------------------- - workspace: Workspace; settings: SimpleBannerSettings; deviceSettings: DeviceSettings; settingProperties: PropertySettings; - //--------------------------------------------------- // // Plugin Lifecycle // //--------------------------------------------------- async onload() { - this.workspace = this.app.workspace; + const app = this.app; + const workspace = app.workspace; + + Parse.init(this); + DomUtils.init(this); await this.loadSettings(); - this.addSettingTab(new Settings(this.app, this)); + this.addSettingTab(new Settings(app, this)); - this.app.workspace.onLayoutReady(() => { - this.registerEvent(this.workspace.on('layout-change', this.process.bind(this))); - this.registerEvent(this.workspace.on('file-open', this.handleFileEvents.bind(this))); - this.registerEvent(this.app.metadataCache.on('changed', this.handleMetaEvents.bind(this))); + workspace.onLayoutReady(() => { + this.registerEvent(workspace.on('layout-change', this.handleLayoutChange.bind(this))); + this.registerEvent(workspace.on('file-open', this.handleFileOpen.bind(this))); + this.registerEvent(app.metadataCache.on('changed', this.handleMetaChange.bind(this))); this.applySettings(); }); } @@ -97,53 +50,93 @@ export default class SimpleBanner extends Plugin { // Methods // //--------------------------------------------------- - processAll() { this.app.workspace.iterateRootLeaves((leaf: WorkspaceLeaf) => { const view = leaf.view as MarkdownView; if (view) { if (this.deviceSettings.bannerEnabled) { const file = view?.file || null; - const options = this.computeBannerData(file, view); - this.process(options || null); + this.process(file, view); } else { - this.removeBanner(); + this.remove(); } } }); } - process(options?: BannerData | null) { - const opts = options || this.computeBannerData(); - if (!opts) { + process(file?: TFile | null, view?: MarkdownView) { + const data = this.compute(file, view); + if (!data) { return; } - if (!opts.image) { - this.removeBanner(); + if (!data.image) { + this.remove(data); return; } - if (!opts.icon) { - opts.needsUpdate = true; + if (!data.icon) { + data.needsUpdate = true; } if (this.deviceSettings.bannerEnabled) { - this.updateBanner(opts); + this.render(data); } } - updateBanner(options: BannerData) { - const { - image, - icon, - viewMode, - lastViewMode, - container, - needsUpdate, - isImageChange, - isImagePropsUpdate, - isIconChange, - } = options; + compute(file?: TFile | null, targetView?: MarkdownView): BannerData | null { + const view = targetView || this.getActiveView(); + if (file && view instanceof MarkdownView) { + const defaultData = this.createDefaultBannerData(); + // @ts-ignore + const olddata: BannerData | null = Store.get(view?.leaf.id) || defaultData; + const newdata = this.createDefaultBannerData(view, olddata.viewMode); + const cachedMetadata = this.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + + if (frontmatter) { + const settingProps = this.settingProperties; + const imageProp = settingProps.image; + const iconProp = settingProps.icon; + + // parse for image property + if (frontmatter[imageProp]) { + newdata.image = frontmatter[imageProp]; + newdata.filepath = file.path; + if (olddata.filepath !== newdata.filepath) { + newdata.needsUpdate = true; + newdata.isImageChange = true; + } else if (olddata.image !== newdata.image) { + newdata.needsUpdate = true; + newdata.isImageChange = true; + if (Parse.isImagePropertiesUpdate(olddata.image, newdata.image, view)) { + newdata.isImagePropsUpdate = true; + newdata.isImageChange = false; + } + } + } + + // parse for icon property if enabled + if (this.deviceSettings.iconEnabled) { + if (frontmatter[iconProp]) { + newdata.icon = frontmatter[iconProp]; + newdata.filepath = file.path; + if (olddata.icon !== newdata.icon) { + newdata.needsUpdate = true; + } + } else if (olddata.icon) { + newdata.icon = null; + newdata.needsUpdate = true; + } + } + } + return newdata; + } + return null; + } + + render(data: BannerData) { + const { image, viewMode, lastViewMode, view, needsUpdate, isImageChange } = data; const propsContainer = document.querySelector('.metadata-container'); + if (propsContainer) { const parent = propsContainer.parentNode; if (parent && parent.firstChild !== propsContainer) { @@ -151,153 +144,45 @@ export default class SimpleBanner extends Plugin { } } + const container = view?.containerEl; if (container && (lastViewMode !== viewMode || needsUpdate)) { - const view = this.getActiveView(); - let calculatedFontSize: string | null = null; - const { url, repeatable, x, y } = this.parseLinkString(image || '', view); - const containers = container.querySelectorAll('.markdown-reading-view > .markdown-preview-view, .cm-scroller'); - containers.forEach((c) => { - let element = (c.querySelector('.simple-banner') || document.createElement('div')) as HTMLElement; - element.classList.add('simple-banner'); + const containers = container.querySelectorAll('.cm-scroller, .markdown-reading-view > .markdown-preview-view') as NodeListOf; + const imageOptions = Parse.link(image || '', view); + const settings = this.deviceSettings; - const STATIC_CSS = 'static'; + // process features + const banners = Banner.update(data, imageOptions, containers); + Icon.update(data, banners, settings); - if (isImageChange || isImagePropsUpdate) { - if (isImageChange) { - element.classList.remove(STATIC_CSS); - } + // apply changes + if (!isImageChange) { + Banner.replace(banners); + } else { + Banner.insert(banners, containers); + } - this.setCSSVariables({ - 'url': `url(${url})`, - 'img-x': `${x}px`, - 'img-y': `${y}px`, - 'size': repeatable ? 'auto' : 'cover', - 'repeat': repeatable ? 'repeat' : 'no-repeat', - }, container); - } - - let iconContainer = element.querySelector('.icon'); - const hadIconContainer = iconContainer !== null; - if (hadIconContainer) { - iconContainer?.classList.add(STATIC_CSS); - } - if (this.deviceSettings.iconEnabled && icon) { - if (!hadIconContainer) { - iconContainer = document.createElement('div'); - iconContainer.classList.add('icon'); - if (Platform.isWin) { - iconContainer.classList.add('is-windows'); - } - const div = document.createElement('div'); - iconContainer.appendChild(div); - element.prepend(iconContainer); - } - - if (iconContainer) { - const iconelement = iconContainer.querySelector('div') as HTMLElement; - - let { value, type } = this.getIconData(icon, view); - value = value?.replace(/([#.:[\\]"])/g, '\\$1') || ''; - iconelement.dataset.type = type; - - const iconVars = {} as any; - iconVars['icon'] = type === IconType.Link ? `url(${value})` : `"${value}"`; - - if (type === IconType.Text) { - calculatedFontSize = calculatedFontSize !== null ? calculatedFontSize : this.getFontsize(value); - iconVars['icon-fontsize'] = calculatedFontSize; - } - this.setCSSVariables(iconVars, iconelement); - } - } else if (iconContainer) { - options.icon = null; - iconContainer.remove(); - } - - if (!isImageChange) { - element.classList.add(STATIC_CSS); - } else { - c.prepend(element); - element.onanimationend = () => { - const banners = document.querySelectorAll('.simple-banner'); - banners.forEach(b => b.classList.add(STATIC_CSS)); - } - } - }); - - options.lastViewMode = viewMode; - container.dataset.sb = JSON.stringify(options); + // Store new state + data.lastViewMode = viewMode; + container.dataset.sb = ''; + // @ts-ignore + Store.set(view?.leaf.id, data); } } - removeBanner() { - const options = this.computeBannerData(); - const container = options?.container; - if (options && container) { - const targets = container.querySelectorAll('.simple-banner'); - targets.forEach((t) => { t.remove() }); - delete container.dataset.sb; - } - } - - computeBannerData(newfile?: TFile | null, targetView?: MarkdownView): BannerData | null { - const view = targetView || this.getActiveView(); + remove(data?: BannerData) { + const view = data?.view || this.getActiveView(); if (view instanceof MarkdownView) { - const defaultOptions = this.createDefaultBannerData(); - let oldopt: BannerData | null = defaultOptions; - const container = view.containerEl; - - if (newfile && newfile.path !== view?.file?.path) { - oldopt = defaultOptions; - } else if (container?.dataset.sb) { - oldopt = JSON.parse(container.dataset.sb) as BannerData || defaultOptions; + const container = view?.containerEl; + if (container) { + const targets = container.querySelectorAll(`.${CSSClasses.Main}`); + targets.forEach((t) => { + t.remove() + }); + // @ts-ignore + Store.delete(view?.leaf.id); + delete container.dataset.sb; } - - const opt = this.createDefaultBannerData(view, oldopt.viewMode); - const file = view?.file || null; - if (file) { - const cachedMetadata = this.app.metadataCache.getFileCache(file); - const frontmatter = cachedMetadata?.frontmatter; - - if (frontmatter) { - const settingProps = this.settingProperties; - const imageProp = settingProps.image; - const iconProp = settingProps.icon; - - // parse for image property - if (frontmatter[imageProp]) { - opt.image = frontmatter[imageProp]; - opt.filepath = file.path; - if (oldopt.image !== opt.image) { - opt.needsUpdate = true; - opt.isImageChange = true; - if (this.isImagePropertiesUpdate(oldopt.image, opt.image, view)) { - opt.isImagePropsUpdate = true; - opt.isImageChange = false; - } - } - } - - // parse for icon property if enabled - if (this.deviceSettings.iconEnabled) { - if (frontmatter[iconProp]) { - opt.icon = frontmatter[iconProp]; - opt.filepath = file.path; - if (oldopt.icon !== opt.icon) { - opt.needsUpdate = true; - opt.isIconChange = true; - } - } else if (oldopt.icon) { - opt.icon = null; - opt.needsUpdate = true; - opt.isIconChange = true; - } - } - } - } - return opt; } - return null; } //---------------------------------- @@ -328,7 +213,7 @@ export default class SimpleBanner extends Plugin { vars['note-offset'] = `${offset}px`; vars['radius'] = `${radius[0]}px ${radius[1]}px ${radius[2]}px ${radius[3]}px`; vars['padding'] = `${padding}px`; - vars['fade'] = (fade) ? 'linear-gradient(180deg, var(--background-primary) 25%, transparent)' : 'none'; + vars['mask'] = (fade) ? CSSValue.RevertLayer : CSSValue.Initial; if (settings.iconEnabled) { const iconSize = settings.iconSize; @@ -338,23 +223,23 @@ export default class SimpleBanner extends Plugin { const iconAlignment = settings.iconAlignment; const iconOffset = settings.iconOffset; - vars['icon-size'] = `${iconSize}px`; + vars['icon-size-w'] = `${iconSize}px`; + vars['icon-size-h'] = `${iconSize}px`; vars['icon-radius'] = `${iconRadius}px`; - vars['icon-background'] = iconBackground ? 'var(--background-primary)' : 'transparent'; - vars['icon-border'] = `${iconBorder}px`; - vars['icon-alignh'] = iconAlignment[0]; - vars['icon-alignv'] = iconAlignment[1]; + vars['icon-align-h'] = iconAlignment[0]; + vars['icon-align-v'] = iconAlignment[1]; vars['icon-offset-x'] = `${iconOffset[0]}px`; vars['icon-offset-y'] = `${iconOffset[1]}px`; + vars['icon-border'] = `${iconBorder}px`; + vars['icon-background'] = iconBackground ? CSSValue.RevertLayer : CSSValue.Transparent; } - this.setCSSVariables(vars); + DomUtils.setCSSVariables(vars); - const AUTOHIDE_CLASS = 'smpbn-autohide'; if (this.settings.properties.autohide) { - document.body.classList.add(AUTOHIDE_CLASS); + document.body.classList.add(CSSClasses.Autohide); } else { - document.body.classList.remove(AUTOHIDE_CLASS); + document.body.classList.remove(CSSClasses.Autohide); } this.processAll(); @@ -363,21 +248,30 @@ export default class SimpleBanner extends Plugin { //---------------------------------- // Event Handlers //---------------------------------- - handleFileEvents(file: TFile) { - const options = this.computeBannerData(file); - const container = options?.container; - if (options && container && file.path !== options.filepath) { - delete container.dataset.sb; - this.process(); + handleLayoutChange() { + const view = this.getActiveView(); + if (view) { + // @ts-ignore + if (!Store.exists(view?.leaf.id)) { + this.process(view.file, view); + } + } else { + Store.delete(this.getMostRecentLeafId()); } } - async handleMetaEvents(file: TFile) { + handleFileOpen(file: TFile) { + const view = this.getActiveView(); + if (view instanceof MarkdownView) { + this.process(file, view); + } + } + + handleMetaChange(file: TFile) { this.app.workspace.iterateRootLeaves((leaf: WorkspaceLeaf) => { const view = leaf.view as MarkdownView; if (view.file === file) { - const options = this.computeBannerData(file, view); - this.process(options || null); + this.process(file, view); } }); } @@ -386,180 +280,19 @@ export default class SimpleBanner extends Plugin { // Helper Methods //---------------------------------- getActiveView(): MarkdownView | null { - return this.workspace.getActiveViewOfType(MarkdownView) || null; + return this.app.workspace.getActiveViewOfType(MarkdownView) || null; } - parseLinkString(str: string, view?: MarkdownView | null, settingProperty?: string | null) { - let url: string | null = null; - let displayText: string | null = null; - let external: boolean; - let obsidianUrl: boolean = false; - let options = { x: 0, y: 0, repeatable: false }; - - const wikilinkMatch = str.match(RegExpression.Wikilink); - if (wikilinkMatch) { - url = wikilinkMatch[1].trim(); - displayText = wikilinkMatch[3] ? wikilinkMatch[3].trim() : null; - } - - const markdownMatch = str.match(RegExpression.Markdown); - const markdownBareMatch = str.match(RegExpression.MarkdownBare); - if (markdownMatch) { - displayText = markdownMatch[1].trim(); - url = markdownMatch[2].trim(); - } else if (markdownBareMatch) { - url = markdownBareMatch[1].trim(); - displayText = null; - } - - if (!url) { - url = str; - displayText = null; - } - - external = RegExpression.Weblink.test(url); - - if (this.isObsidianUrl(url)) { - const str = url.replace('obsidian://open', ''); - const params = new URLSearchParams(str); - let file = params.get('file'); - if (file) { - url = file; - obsidianUrl = true; - external = false; - displayText = null; - } - } - - const hashIndex = url.indexOf('#'); - if ((external || obsidianUrl) && hashIndex !== -1) { - options = this.parseImageProperties(url.substring(hashIndex + 1)); - url = url.replace(/#.*/, '').trim(); - } - - if (displayText) { - options = this.parseImageProperties(displayText); - } - - if (!external) { - const vault = this.app.vault; - const files = vault.getFiles().filter(f => f.path === url || f.name === url); - let file = files.find(f => f.path === url); - if (file) { - url = vault.getResourcePath(file); - } - if (!file) { - file = files.find(f => f.name === url); - if (file) { - url = vault.getResourcePath(file); - } - } - - if (obsidianUrl && file && view) { - const activeFile = this.workspace.getActiveFile(); - if (activeFile) { - // noinspection JSIgnoredPromiseFromCall - this.app.fileManager.processFrontMatter(activeFile, (frontmatter) => { - const propName = settingProperty || this.settingProperties.image; - frontmatter[propName] = `[[${file?.path}]]` - }); - } - } - } - - return { - url: url.trim(), - external, - ...options, - }; - } - - isObsidianUrl(url: string): boolean { - return url.startsWith('obsidian://open'); - } - - parseImageProperties(str: string): ImageProperties { - let repeatable: boolean; - let x = 0; - let y = 0; - - const values = str.toLowerCase(); - repeatable = values.includes('repeat'); - - const sizes = str.split(/x|,/); - const numbers = sizes.filter(v => !isNaN(parseInt(v.trim(), 10))); - - if (numbers.length === 2) { - x = parseInt(numbers[0].trim(), 10); - y = parseInt(numbers[1].trim(), 10); - } else if (numbers.length === 1) { - y = parseInt(numbers[0].trim(), 10); - } - return { x, y, repeatable }; - } - - isImagePropertiesUpdate(oldstr?: string | null, newstr?: string | null, view?: MarkdownView | null): boolean { - if (!oldstr || !newstr) { - return false; - } - const oldopt = this.parseLinkString(oldstr, view); - const newopt = this.parseLinkString(newstr, view); - return oldopt.url === newopt.url; - } - - getIconData(icon: string, view?: MarkdownView | null): IconData { - const str = icon || ''; - const out = { value: null, type: IconType.Text } as IconData; - - if (RegExpression.Wikilink.test(str)) { - out.type = IconType.Link; - } else if (RegExpression.Markdown.test(str) || RegExpression.MarkdownBare.test(str)) { - out.type = IconType.Link; - } else if (RegExpression.Weblink.test(icon)) { - out.type = IconType.Link; - } else if (this.isObsidianUrl(icon)) { - out.type = IconType.Link; - } - - if (out.type === IconType.Link) { - const data = this.parseLinkString(str, view, this.settingProperties.icon); - out.value = data.url; - } else { - out.value = str; - } - - return out; - } - - getFontsize(textContent: string) { - const temp = document.createElement('span'); - temp.setAttribute('style', 'position: absolute; visibility: hidden; white-space: nowrap;'); - temp.style.padding = '0'; - temp.style.margin = '0'; - temp.style.left = '-9999px'; - temp.textContent = textContent.toUpperCase(); - document.body.appendChild(temp); - const size = this.deviceSettings.iconSize; - const checkWidth = size - 16; - - let fontSize = size; // Start big - temp.style.fontSize = fontSize + 'px'; - - while (temp.offsetWidth > checkWidth && fontSize > 1) { - fontSize -= 1; - temp.style.fontSize = fontSize + 'px'; - } - - document.body.removeChild(temp); - return `${fontSize}px`; + getMostRecentLeafId(): string | null { + const leaf = this.app.workspace.getMostRecentLeaf(); + // @ts-ignore + return leaf.id; } createDefaultBannerData(view?: MarkdownView | null, lastViewMode?: ViewMode | null): BannerData { let viewMode = null; - let container = null; if (view) { viewMode = ((view.getMode() || null) === ViewMode.Reading) ? ViewMode.Reading : ViewMode.Source; - container = view.containerEl; } return { filepath: null, @@ -569,16 +302,8 @@ export default class SimpleBanner extends Plugin { lastViewMode: lastViewMode || null, isImagePropsUpdate: false, isImageChange: false, - isIconChange: false, needsUpdate: false, - container, + view, } } - - setCSSVariables(variables: Record, target: HTMLElement = document.body) { - const style = target.style; - Object.keys(variables).forEach(k => { - style.setProperty(`--smpbn-${k}`, variables[k]); - }); - } } diff --git a/src/scss/animations.scss b/src/scss/animations.scss new file mode 100644 index 0000000..25fd2a0 --- /dev/null +++ b/src/scss/animations.scss @@ -0,0 +1,15 @@ +/* ------------------------------- + Animations +------------------------------- */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes banner-anim { + from { background-position-y: calc(50% + (var(--sb-img-y, 0) - 10px)); } + to { background-position-y: calc(50% + var(--sb-img-y, 0)); } +} +@keyframes icon-anim { + from { transform: translateX(10px) } + to { transform: translateX(0) } +} diff --git a/src/scss/banner.scss b/src/scss/banner.scss new file mode 100644 index 0000000..c6f2ae7 --- /dev/null +++ b/src/scss/banner.scss @@ -0,0 +1,66 @@ +/* ------------------------------- + Banner +------------------------------- */ +div.simple-banner { + position: absolute; + inset: 0; + z-index: 1; + width: calc(100% - (var(--sb-padding) * 2)); + height: 100%; + margin-left: var(--sb-padding); + max-height: var(--sb-height); + padding: var(--size-4-3) var(--size-4-3) 0 var(--size-4-3); + user-select: none; + + &:before { + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + content: ''; + box-shadow: inset 0 2px 4px 1px rgba(0, 0, 0, 0.1); + border-radius: var(--sb-radius); + background-color: rgba(0,0,0,.2); + mask-image: var(--sb-mask); + } + + &:after { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + content: ''; + border-radius: var(--sb-radius); + background-image: var(--sb-url); + background-repeat: var(--sb-repeat); + background-size: var(--sb-size); + background-position-x: calc(50% + var(--sb-img-x)); + background-position-y: calc(50% + var(--sb-img-y)); + animation: fade-in 0.7s linear forwards, banner-anim 0.8s ease-out; + transition: background-position-x 0.5s ease-in-out, background-position-y 0.5s ease-in-out; + will-change: transform, background-position-x, background-position-y; + transform-style: preserve-3d; + backface-visibility: hidden; + overflow: hidden; + mask-image: var(--sb-mask); + } + + &.static:after { + animation: none; + } + + & + div { + position: relative; + z-index: 3; + } +} + +body { + &.is-phone, &.is-tablet { + div.simple-banner { + width: calc(100% - (var(--sb-padding) - 2px)); + } + } +} diff --git a/src/scss/frontmatter.scss b/src/scss/frontmatter.scss new file mode 100644 index 0000000..6b931da --- /dev/null +++ b/src/scss/frontmatter.scss @@ -0,0 +1,143 @@ +/* ------------------------------- + Frontmatter Overrides +------------------------------- */ +.workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, +.workspace-leaf-content[data-sb] .view-content .markdown-preview-view { + padding-top: var(--sb-height, 240px); + + .inline-embed .el-pre.mod-frontmatter.mod-ui, + .inline-embed .markdown-preview-view { + padding-top: initial; + } +} + +body.sb-autohide { + .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, + .workspace-leaf-content[data-sb] .view-content .markdown-preview-view { + padding-top: 0; + + .metadata-container { + position: absolute; + container-type: normal; + left: 50%; + transform: translateX(-50%); + padding: var(--size-4-2) var(--size-4-6) var(--size-4-6) var(--size-4-6); + z-index: 2; + width: 100%; + + .metadata-properties-heading { + width: 100%; + transform: translateY(-3px); + transition: opacity 0.2s linear, transform 0.2s ease-in-out; + cursor: pointer; + } + + &:before { + content: ''; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + left: 0; + background-color: color-mix(in srgb, var(--background-primary) 90%, transparent); + backdrop-filter: blur(5px); + opacity: 1; + z-index: -1; + border-radius: 0 0 var(--radius-m) var(--radius-m); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.4); + transform: translateY(0); + transition: opacity 0.2s linear, transform 0.2s ease-in-out; + } + + &:after { + content: ''; + position: absolute; + inset: 0; + height: 2px; + width: 100%; + opacity: 0.1; + background-color: black; + box-shadow: 0 2px 2px black; + transition: box-shadow 0.2s linear, opacity 0.2s linear; + } + + &.is-collapsed { + .metadata-properties-heading { + opacity: 0.5; + transform: translateY(-70px); + & > .metadata-properties-title { + opacity: 0; + transition: opacity 0.2s linear; + } + } + &:before { + opacity: 0; + transform: translateY(-70px); + } + &:after { + opacity: 0; + box-shadow: 0 2px 2px black; + } + } + + &:hover, &:focus, &:focus-within { + &.is-collapsed { + &:before { + transform: translateY(-26px); + opacity: 0.85; + } + &:after { + opacity: 0.1; + } + } + + .metadata-properties-heading.is-collapsed { + opacity: 1; + transform: translateY(-3px); + &:before { + opacity: 0; + } + & > .metadata-properties-title { + opacity: 1; + } + } + } + } + + .cm-contentContainer { + position: relative; + z-index: 1; + padding-top: calc(var(--sb-height, 240px) + (var(--sb-note-offset) - 6px)); + } + + .el-pre.mod-frontmatter.mod-ui { + padding-top: calc(var(--sb-height, 240px) + (var(--sb-note-offset) + 12px)); + } + + .inline-embed .el-pre.mod-frontmatter.mod-ui { + padding-top: initial; + } + } + + &.show-inline-title .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, + &.show-inline-title .workspace-leaf-content[data-sb] .view-content > .markdown-preview-view { + .cm-contentContainer { + padding-top: 0; + } + .inline-title { + padding-top: calc(var(--sb-height, 240px) + (var(--sb-note-offset) + 12px)); + } + .el-pre.mod-frontmatter.mod-ui { + padding-top: 0; + } + } + + &.is-mobile { + .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, + .workspace-leaf-content[data-sb] .view-content .markdown-preview-view { + .metadata-container { + left: calc(50% + (var(--size-4-1) * 1)); + } + } + } +} diff --git a/src/scss/icon.scss b/src/scss/icon.scss new file mode 100644 index 0000000..9149837 --- /dev/null +++ b/src/scss/icon.scss @@ -0,0 +1,91 @@ +/* ------------------------------- + Icon +------------------------------- */ +div.simple-banner { + & > div.icon { + position: relative; + display: flex; + justify-content: var(--sb-icon-align-h); + align-items: var(--sb-icon-align-v); + width: inherit; + height: inherit; + z-index: 2; + margin: 0 auto; + max-width: var(--file-line-width); + transform: translate(0, 0); + animation: fade-in 0.7s linear forwards, icon-anim 0.8s ease-out; + will-change: transform, opacity; + + & > div { + display: block; + position: relative; + width: var(--sb-icon-size-w); + height: var(--sb-icon-size-h); + background-color: var(--sb-icon-background); + transform: translate(var(--sb-icon-offset-x), var(--sb-icon-offset-y)); + border-radius: var(--sb-icon-radius); + border: var(--sb-icon-border) solid var(--background-primary); + z-index: 2; + overflow: hidden; + + &[data-type="text"]:after { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + inset: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + text-transform: uppercase; + content: var(--sb-icon-value); + color: var(--text-normal); + line-height: 0; + font-size: var(--sb-icon-fontsize, inherit); + user-select: none; + } + &[data-type="link"]:after { + position: absolute; + text-transform: uppercase; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + transform: translate(0, 0); + content: ''; + background-image: var(--sb-icon-value); + background-repeat: no-repeat; + background-size: cover; + background-position: center; + overflow: hidden; + } + } + + &.is-windows { + & > div { + &[data-type="text"]:after { + top: calc(0% - (var(--sb-icon-fontsize) * 0.05)); + } + } + } + } + + &.static > div.icon { + animation: none; + } +} + +body { + &.is-tablet { + div.simple-banner > div.icon { + left: calc(var(--size-4-2) * -1); + } + } + + &.is-phone { + div.simple-banner > div.icon { + left: auto; + } + } +} diff --git a/src/scss/settings.scss b/src/scss/settings.scss new file mode 100644 index 0000000..58f332d --- /dev/null +++ b/src/scss/settings.scss @@ -0,0 +1,65 @@ +/* ------------------------------- + Settings +------------------------------- */ +.setting-item { + &.sbs-grid-radius > .setting-item-control { + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto auto; + + & > *:nth-child(1) { + grid-column: 1; + grid-row: 1 / 3; + } + & > *:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + & > *:nth-child(3) { + grid-column: 3; + grid-row: 1; + } + & > *:nth-child(5) { + grid-column: 2; + grid-row: 2; + } + & > *:nth-child(4) { + grid-column: 3; + grid-row: 2; + } + & > input { + max-width: 78.2px; + text-align: center; + } + } + + &.sbs-grid-xy > .setting-item-control { + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto; + + & > *:nth-child(1) { + grid-column: 1; + grid-row: 1; + } + & > *:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + & > *:nth-child(3) { + grid-column: 3; + grid-row: 1; + } + & > input { + max-width: 78.2px; + text-align: center; + } + } + + &.sbs-spacer { + padding-bottom: calc(var(--size-4-1) * 10); + &:last-of-type { + padding-bottom: revert; + } + } +} diff --git a/src/scss/variables.scss b/src/scss/variables.scss new file mode 100644 index 0000000..b584021 --- /dev/null +++ b/src/scss/variables.scss @@ -0,0 +1,39 @@ +body { + /* Banner Variables */ + --sb-padding: 8px; + --sb-radius: 0px; + --sb-height: 240px; + --sb-url: none; + --sb-repeat: no-repeat; + --sb-size: cover; + --sb-img-x: 0; + --sb-img-y: 0; + --sb-mask: linear-gradient(180deg, var(--background-primary) 25%, transparent); + + /* Icon Variables */ + --sb-icon-align-h: flex-start; + --sb-icon-align-v: flex-end; + --sb-icon-size-w: 96px; + --sb-icon-size-h: 96px; + --sb-icon-background: rgb(from var(--background-primary) r g b / 80%); + --sb-icon-border: 0; + --sb-icon-offset-x: 0; + --sb-icon-offset-y: 0; + --sb-icon-radius: 8px; + --sb-icon-value: ''; + --sb-icon-fontsize: inherit; + + /* Datetime Variables */ + --sb-dt-align-h: flex-start; + --sb-dt-align-v: flex-end; + --sb-dt-gap: var(--size-4-2); + --sb-dt-offset-x: 0; + --sb-dt-offset-y: 0; + --sb-dt-fontfamily-time: inherit; + --sb-dt-fontweight-time: inherit; + --sb-dt-fontsize-time: initial; + --sb-dt-fontfamily-date: inherit; + --sb-dt-fontweight-date: inherit; + --sb-dt-fontsize-date: initial; + --sb-dt-textshadow: initial; +} diff --git a/src/settings.ts b/src/settings.ts deleted file mode 100644 index 8b50a02..0000000 --- a/src/settings.ts +++ /dev/null @@ -1,625 +0,0 @@ -import {App, Platform, PluginSettingTab, Setting} from 'obsidian'; -import SimpleBanner from "./main"; - -//---------------------------------- -// Interfaces -//---------------------------------- -export interface DeviceSettings { - bannerEnabled: boolean; - height: number; - noteOffset: number; - bannerRadius: Array; - bannerPadding: number; - bannerFade: boolean; - - iconEnabled: boolean, - iconSize: number; - iconRadius: number; - iconBackground: boolean; - iconBorder: number; - iconAlignment: Array; - iconOffset: Array; -} - -export interface PropertySettings { - autohide: boolean; - image: string; - icon: string; -} - -export interface SimpleBannerSettings { - desktop: DeviceSettings - tablet: DeviceSettings - phone: DeviceSettings - properties: PropertySettings; -} - -export enum DeviceType { - Desktop = 'desktop', - Tablet = 'tablet', - Phone = 'phone', -} - -const DEFAULT_SETTINGS: SimpleBannerSettings = { - desktop: { - bannerEnabled: true, - height: 240, - noteOffset: -32, - bannerRadius: [8, 8, 8, 8], - bannerPadding: 8, - bannerFade: true, - - iconEnabled: false, - iconSize: 96, - iconRadius: 8, - iconBackground: true, - iconBorder: 2, - iconAlignment: ['flex-start', 'flex-end'], - iconOffset: [0, -24], - }, - - tablet: { - bannerEnabled: true, - height: 190, - noteOffset: -32, - bannerRadius: [8, 8, 8, 8], - bannerPadding: 8, - bannerFade: true, - - iconEnabled: false, - iconSize: 96, - iconRadius: 8, - iconBackground: true, - iconBorder: 2, - iconAlignment: ['flex-start', 'flex-end'], - iconOffset: [0, -24], - }, - - phone: { - bannerEnabled: true, - height: 160, - noteOffset: -32, - bannerRadius: [8, 8, 8, 8], - bannerPadding: 8, - bannerFade: true, - - iconEnabled: false, - iconSize: 56, - iconRadius: 8, - iconBackground: true, - iconBorder: 2, - iconAlignment: ['flex-start', 'flex-end'], - iconOffset: [0, -24], - }, - - properties: { - autohide: true, - image: 'banner', - icon: 'icon', - }, -} -const ICON_RESET = 'rotate-ccw'; -const TEXT_RESET = 'Restore default'; - -/** - * Represents the settings tab for configuring the SimpleBanner plugin. - * - */ -export class Settings extends PluginSettingTab { - plugin: SimpleBanner; - - constructor(app: App, plugin: SimpleBanner) { - super(app, plugin); - this.plugin = plugin; - } - - static get currentDevice(): DeviceType { - if (Platform.isPhone) { - return DeviceType.Phone; - } - if (Platform.isTablet) { - return DeviceType.Tablet; - } - return DeviceType.Desktop; - } - - static get DEFAULT_SETTINGS(): SimpleBannerSettings { - return DEFAULT_SETTINGS; - } - - display(): void { - const {containerEl} = this; - containerEl.empty(); - - this.createBannerSettings(containerEl); - this.createIconSettings(containerEl); - this.createFrontmatterSettings(containerEl); - } - - createBannerSettings(containerEl: HTMLElement) { - const plugin = this.plugin; - const currentDevice = Settings.currentDevice; - const settings = plugin.settings[currentDevice]; - const defaultSettings = DEFAULT_SETTINGS[currentDevice]; - const prettyDevice = currentDevice.charAt(0).toUpperCase() + currentDevice.slice(1); - - new Setting(containerEl) - .setHeading() - .setName(`Simple Banner - ${prettyDevice} Settings`) - - new Setting(containerEl) - .setName('Show Simple Banner') - .setDesc(`Enable or disable Simple Banner on your ${currentDevice} device.`) - .addToggle(component => component - .setValue(settings.bannerEnabled) - .onChange(async (value) => { - settings.bannerEnabled = value; - await plugin.saveSettings(); - this.display(); - })); - - if (settings.bannerEnabled) { - new Setting(containerEl) - .setName('Note Offset') - .setDesc('Move the position of the notes content in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.noteOffset = defaultSettings.noteOffset; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.noteOffset.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.noteOffset; - } - settings.noteOffset = num; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Border Radius') - .setDesc('Size of the border radius in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.bannerRadius = defaultSettings.bannerRadius; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.bannerRadius[0].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.bannerRadius[0]; - } - settings.bannerRadius[0] = num; - await plugin.saveSettings(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.bannerRadius[1].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.bannerRadius[1]; - } - settings.bannerRadius[1] = num; - await plugin.saveSettings(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.bannerRadius[2].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.bannerRadius[2]; - } - settings.bannerRadius[2] = num; - await plugin.saveSettings(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.bannerRadius[3].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.bannerRadius[3]; - } - settings.bannerRadius[3] = num; - await plugin.saveSettings(); - }) - ) - .setClass('smpbn-banner-radii'); - - new Setting(containerEl) - .setName('Padding') - .setDesc('Padding of the banner from the edges of the note in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.bannerPadding = defaultSettings.bannerPadding; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.bannerPadding.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.bannerPadding; - } - settings.bannerPadding = num; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Fade') - .setDesc('Fade the image out towards the content.') - .addToggle(component => component - .setValue(settings.bannerFade) - .onChange(async (value) => { - settings.bannerFade = value; - await plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Height') - .setDesc(`Height of the Banner on your ${currentDevice} device (in pixels).`) - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.height = defaultSettings.height; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.height.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.height; - } - settings.height = num; - await plugin.saveSettings(); - }) - ); - } - } - - createIconSettings(containerEl: HTMLElement) { - const plugin = this.plugin; - const currentDevice = Settings.currentDevice; - const settings = plugin.settings[currentDevice]; - const defaultSettings = DEFAULT_SETTINGS[currentDevice]; - - if (settings.bannerEnabled) { - new Setting(containerEl) - .setHeading() - .setName('Icon Settings'); - - new Setting(containerEl) - .setName('Show Icon') - .setDesc('Enable or disable the icon.') - .addToggle(component => component - .setValue(settings.iconEnabled) - .onChange(async (value) => { - settings.iconEnabled = value; - await plugin.saveSettings(); - this.display(); - })); - - if (settings.iconEnabled) { - new Setting(containerEl) - .setName('Icon Size') - .setDesc('Size of the icon in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconSize = defaultSettings.iconSize; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.iconSize.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.iconSize; - } - settings.iconSize = num; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Border Radius') - .setDesc('Size of the border radius in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconRadius = defaultSettings.iconRadius; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.iconRadius.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.iconRadius; - } - settings.iconRadius = num; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Icon Background') - .setDesc('Enable or disable the icon background.') - .addToggle(component => component - .setValue(settings.iconBackground) - .onChange(async (value) => { - settings.iconBackground = value; - await plugin.saveSettings(); - this.display(); - })); - - new Setting(containerEl) - .setName('Icon Border Size') - .setDesc('Size of the border in pixels.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconBorder = defaultSettings.iconBorder; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Enter a number') - .setValue(settings.iconBorder.toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.iconBorder; - } - settings.iconBorder = num; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Icon Alignment - Horizontal') - .setDesc('Horizontal alignment of the icon.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconAlignment[0] = defaultSettings.iconAlignment[0]; - await plugin.saveSettings(); - this.display(); - }) - ) - .addDropdown(dropdown => { - dropdown.addOption('flex-start', 'Left'); - dropdown.addOption('center', 'Middle'); - dropdown.addOption('flex-end', 'Right'); - dropdown.setValue(settings.iconAlignment[0]); - dropdown.onChange(async (value) => { - settings.iconAlignment[0] = value; - await plugin.saveSettings(); - }) - }) - - new Setting(containerEl) - .setName('Icon Alignment - Vertical') - .setDesc('Vertical alignment of the icon.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconAlignment[1] = defaultSettings.iconAlignment[1]; - await plugin.saveSettings(); - this.display(); - }) - ) - .addDropdown(dropdown => { - dropdown.addOption('flex-start', 'Top'); - dropdown.addOption('center', 'Middle'); - dropdown.addOption('flex-end', 'Bottom'); - dropdown.setValue(settings.iconAlignment[1]); - dropdown.onChange(async (value) => { - settings.iconAlignment[1] = value; - await plugin.saveSettings(); - }) - }) - - new Setting(containerEl) - .setName('Icon Offset') - .setDesc('Offset the X and Y position of the icon in pixels') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.iconOffset = defaultSettings.iconOffset; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.iconOffset[0].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.iconOffset[0]; - } - settings.iconOffset[0] = num; - await plugin.saveSettings(); - }) - ) - .addText(text => text - .setPlaceholder('8') - .setValue(settings.iconOffset[1].toString()) - .onChange(async (value) => { - let num = parseInt(value, 10); - if (isNaN(num)) { - num = defaultSettings.iconOffset[1]; - } - settings.iconOffset[1] = num; - await plugin.saveSettings(); - }) - ) - .setClass('smpbn-banner-offset'); - } - } - } - - createFrontmatterSettings(containerEl: HTMLElement) { - const plugin = this.plugin; - const settings = plugin.settings; - - new Setting(containerEl) - .setHeading() - .setName('Frontmatter Settings (Global)'); - - new Setting(containerEl) - .setName('Autohide Frontmatter/Properties') - .setDesc(`Enable or disables the frontmatter/properties autohide feature.`) - .addToggle(component => component - .setValue(settings.properties.autohide) - .onChange(async (value) => { - settings.properties.autohide = value; - await plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Banner Property') - .setDesc('Name of the banner property this plugin will look for in the frontmatter.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.properties.image = DEFAULT_SETTINGS.properties.image; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Default: banner') - .setValue(settings.properties.image.toString()) - .onChange(async (value) => { - settings.properties.image = (value !== '') ? value : DEFAULT_SETTINGS.properties.image; - await plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Icon Property') - .setDesc('Name of the icon property this plugin will look for in the frontmatter.') - .addExtraButton(button => button - .setIcon(ICON_RESET) - .setTooltip(TEXT_RESET) - .onClick(async () => { - settings.properties.icon = DEFAULT_SETTINGS.properties.icon; - await plugin.saveSettings(); - this.display(); - }) - ) - .addText(text => text - .setPlaceholder('Default: icon') - .setValue(settings.properties.icon.toString()) - .onChange(async (value) => { - settings.properties.icon = (value !== '') ? value : DEFAULT_SETTINGS.properties.icon; - await plugin.saveSettings(); - }) - ); - } -} - -export class SettingsMigrator { - static async migrate(data: any, plugin: SimpleBanner): Promise { - const DESKTOP = DeviceType.Desktop; - const TABLET = DeviceType.Tablet; - const PHONE = DeviceType.Phone; - const migrationMap: { [oldKey: string]: { target: string; devices?: string[] } } = { - desktopHeight: { target: 'desktop.height' }, - tabletHeight: { target: 'tablet.height' }, - mobileHeight: { target: 'phone.height' }, - offset: { target: 'noteOffset', devices: [DESKTOP, TABLET, PHONE] }, - fade: { target: 'bannerFade', devices: [DESKTOP, TABLET, PHONE] }, - radius: { target: 'bannerRadius', devices: [DESKTOP, TABLET, PHONE] }, - padding: { target: 'bannerPadding', devices: [DESKTOP, TABLET, PHONE] }, - propertyName: { target: 'properties.image' }, - }; - - let neededMigration = false; - const newData = { ...data }; - - for (const oldKey in migrationMap) { - if (newData.hasOwnProperty(oldKey) && newData[oldKey] !== undefined) { - neededMigration = true; - const migration = migrationMap[oldKey]; - - if (migration.devices) { - migration.devices.forEach((device) => { - const path = device + '.' + migration.target; - this.setValueByPath(newData, path, newData[oldKey]); - }); - } else { - this.setValueByPath(newData, migration.target, newData[oldKey]); - } - delete newData[oldKey]; - } - } - - if (neededMigration) { - console.log('migrated settings'); - await plugin.saveData(newData); - } - - return newData; - } - - static setValueByPath(obj: any, path: string, value: any) { - const parts = path.split('.'); - let current = obj; - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== 'object') { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = value; - } -} diff --git a/src/settings/core.ts b/src/settings/core.ts new file mode 100644 index 0000000..dcecf54 --- /dev/null +++ b/src/settings/core.ts @@ -0,0 +1,465 @@ +import { App, Platform, PluginSettingTab, Setting } from 'obsidian'; +import SimpleBanner from '../main'; +import { DeviceType } from '../types/enums'; +import { SettingCompOptions, SimpleBannerSettings } from '../types/interfaces'; + +const DEFAULT_SETTINGS: SimpleBannerSettings = { + desktop: { + bannerEnabled: true, + height: 240, + noteOffset: -32, + bannerRadius: [8, 8, 8, 8], + bannerPadding: 8, + bannerFade: true, + + iconEnabled: false, + iconSize: 96, + iconRadius: 8, + iconBackground: true, + iconBorder: 2, + iconAlignment: ['flex-start', 'flex-end'], + iconOffset: [0, -24], + }, + + tablet: { + bannerEnabled: true, + height: 190, + noteOffset: -32, + bannerRadius: [8, 8, 8, 8], + bannerPadding: 8, + bannerFade: true, + + iconEnabled: false, + iconSize: 96, + iconRadius: 8, + iconBackground: true, + iconBorder: 2, + iconAlignment: ['flex-start', 'flex-end'], + iconOffset: [0, -24], + }, + + phone: { + bannerEnabled: true, + height: 160, + noteOffset: -32, + bannerRadius: [8, 8, 8, 8], + bannerPadding: 8, + bannerFade: true, + + iconEnabled: false, + iconSize: 56, + iconRadius: 8, + iconBackground: true, + iconBorder: 2, + iconAlignment: ['flex-start', 'flex-end'], + iconOffset: [0, -24], + }, + + properties: { + autohide: true, + image: 'banner', + icon: 'icon', + }, +} +const ICON_RESET = 'rotate-ccw'; +const TEXT_RESET = 'Restore default'; + +/** + * Represents the settings tab for configuring the SimpleBanner plugin. + * + */ +export default class Settings extends PluginSettingTab { + plugin: SimpleBanner; + + constructor(app: App, plugin: SimpleBanner) { + super(app, plugin); + this.plugin = plugin; + } + + static get currentDevice(): DeviceType { + if (Platform.isPhone) { + return DeviceType.Phone; + } + if (Platform.isTablet) { + return DeviceType.Tablet; + } + return DeviceType.Desktop; + } + + static get DEFAULT_SETTINGS(): SimpleBannerSettings { + return DEFAULT_SETTINGS; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + const currentDevice = Settings.currentDevice; + const settings = this.plugin.settings[currentDevice]; + + this.createBannerSettings(); + if (settings.bannerEnabled) { + this.createFrontmatterSettings(); + this.createIconSettings(); + } + } + + createBannerSettings() { + const currentDevice = Settings.currentDevice; + const prettyDevice = currentDevice.charAt(0).toUpperCase() + currentDevice.slice(1); + const settings = this.plugin.settings[currentDevice]; + const defaultSettings = DEFAULT_SETTINGS[currentDevice]; + + this.addHeading(`Simple Banner - ${prettyDevice} Settings`); + this.addToggle({ + title: 'Show Simple Banner', + description: `Enable or disable Simple Banner on your ${currentDevice} device.`, + refreshOnUpdate: true, + }, settings, 'bannerEnabled'); + + if (settings.bannerEnabled) { + this.addNumber({ + title: 'Height', + description: `Height of the Banner on your ${currentDevice} device (in pixels).`, + placeholder: 'Enter a number', + resetValue: defaultSettings.height, + }, settings, 'height'); + + this.addNumber({ + title: 'Padding', + description: `Padding of the banner from the edges of the note in pixels.`, + placeholder: 'Enter a number', + resetValue: defaultSettings.bannerPadding, + }, settings, 'bannerPadding'); + + this.addNumber({ + title: 'Note Offset', + description: `Move the position of the notes content in pixels.`, + placeholder: 'Enter a number', + resetValue: defaultSettings.noteOffset, + }, settings, 'noteOffset'); + + + this.addNumber({ + title: 'Border Radius', + description: `Size of the border radius in pixels.`, + placeholder: '8', + isValueArray: true, + length: 4, + resetValue: defaultSettings.bannerRadius, + classes: ['sbs-grid-radius'], + }, settings, 'bannerRadius'); + + this.addToggle({ + title: 'Fade', + description: `Fade the image out towards the content.`, + classes: ['sbs-spacer'], + }, settings, 'bannerFade'); + } + } + + createFrontmatterSettings() { + const plugin = this.plugin; + const settings = plugin.settings; + + this.addHeading('Frontmatter Settings (Global)'); + this.addToggle({ + title: 'Autohide Frontmatter/Properties', + description: `Enable or disables the frontmatter/properties autohide feature.`, + }, settings.properties, 'autohide'); + + this.addText({ + title: 'Banner Property', + description: 'Name of the banner property this plugin will look for in the frontmatter.', + placeholder: 'Default: banner', + resetValue: DEFAULT_SETTINGS.properties.image, + }, settings.properties, 'image'); + + this.addText({ + title: 'Icon Property', + description: 'Name of the icon property this plugin will look for in the frontmatter.', + placeholder: 'Default: icon', + resetValue: DEFAULT_SETTINGS.properties.icon, + classes: ['sbs-spacer'], + }, settings.properties, 'icon'); + } + + createIconSettings() { + const currentDevice = Settings.currentDevice; + const settings = this.plugin.settings[currentDevice]; + const defaultSettings = DEFAULT_SETTINGS[currentDevice]; + + this.addHeading(`Icon Settings`); + this.addToggle({ + title: 'Show Icon', + description: `Enable or disable the icon.`, + refreshOnUpdate: true, + }, settings, 'iconEnabled'); + + if (settings.iconEnabled) { + this.addNumber({ + title: 'Icon Size', + description: `Size of the icon in pixels.`, + placeholder: 'Enter a number', + resetValue: defaultSettings.iconSize, + }, settings, 'iconSize'); + + this.addToggle({ + title: 'Icon Background', + description: `Enable or disable the icon background.`, + }, settings, 'iconBackground'); + + this.addNumber({ + title: 'Border Size', + description: `Size of the border in pixels.`, + placeholder: 'Enter a number', + resetValue: defaultSettings.iconBorder, + }, settings, 'iconBorder'); + + this.addNumber({ + title: 'Border Radius', + description: `Size of the border radius in pixels.`, + placeholder: 'Enter a number', + resetValue: defaultSettings.iconRadius, + }, settings, 'iconRadius'); + + this.addDropdown({ + title: 'Icon Alignment - Horizontal', + description: `Horizontal alignment of the icon.`, + choices: [ + { label: 'Left', value: 'flex-start' }, + { label: 'Middle', value: 'center' }, + { label: 'Right', value: 'flex-end' }, + ], + resetValue: defaultSettings.iconAlignment[0], + }, settings, 'iconAlignment', 0); + + this.addDropdown({ + title: 'Icon Alignment - Vertical', + description: 'Vertical alignment of the icon.', + choices: [ + { label: 'Top', value: 'flex-start' }, + { label: 'Middle', value: 'center' }, + { label: 'Bottom', value: 'flex-end' }, + ], + resetValue: defaultSettings.iconAlignment[1], + }, settings, 'iconAlignment', 1); + + this.addNumber({ + title: 'Icon Offset', + description: 'Offset the X and Y position of the icon in pixels', + placeholder: '0', + isValueArray: true, + length: 2, + resetValue: defaultSettings.iconOffset, + classes: ['sbs-grid-xy', 'sbs-spacer'], + }, settings, 'iconOffset'); + } + } + + //---------------------------------- + // Helper Methods + //---------------------------------- + addHeading(text: string) { + new Setting(this.containerEl).setHeading().setName(text); + } + + addToggle(options: SettingCompOptions, obj: any, prop: string) { + const instance = new Setting(this.containerEl); + if (options.title) { + instance.setName(options.title); + } + if (options.description) { + instance.setDesc(options.description); + } + + this.setClasses(instance, options.classes); + + instance.addToggle(component => component + .setValue(obj[prop]) + .onChange(async (value) => { + obj[prop] = value; + await this.plugin.saveSettings(); + if (options.refreshOnUpdate) { + this.display(); + } + }) + ); + } + + addDropdown(options: SettingCompOptions, obj: any, prop: string, index?: number) { + const isResettable = options.resetValue !== undefined; + const resetValue = options.resetValue; + const instance = new Setting(this.containerEl); + const hasIndex = index !== undefined; + + if (options.title) { + instance.setName(options.title); + } + if (options.description) { + instance.setDesc(options.description); + } + + this.setClasses(instance, options.classes); + + if (isResettable) { + instance.addExtraButton(button => button + .setIcon(ICON_RESET) + .setTooltip(TEXT_RESET) + .onClick(async () => { + if (hasIndex) { + obj[prop][index] = resetValue; + } else { + obj[prop] = resetValue; + } + await this.plugin.saveSettings(); + this.display(); + }) + ) + } + + instance.addDropdown((dropdown) => { + const choices = options?.choices || []; + choices.forEach((choice) => { + dropdown.addOption(choice.value, choice.label); + }) + if (hasIndex) { + dropdown.setValue(obj[prop][index]); + } else { + dropdown.setValue(obj[prop]); + } + + dropdown.onChange(async (v) => { + if (hasIndex) { + obj[prop][index] = v; + } else { + obj[prop] = v; + } + await this.plugin.saveSettings(); + }) + return dropdown; + }) + } + + addText(options: SettingCompOptions, obj: any, prop: string) { + const isResettable = options.resetValue !== undefined; + const resetValue = options.resetValue; + const instance = new Setting(this.containerEl); + + if (options.title) { + instance.setName(options.title); + } + if (options.description) { + instance.setDesc(options.description); + } + + this.setClasses(instance, options.classes); + + if (isResettable) { + instance.addExtraButton(button => button + .setIcon(ICON_RESET) + .setTooltip(TEXT_RESET) + .onClick(async () => { + obj[prop] = resetValue; + await this.plugin.saveSettings(); + this.display(); + }) + ) + } + + instance.addText((text) => { + if (options.placeholder) { + text.setPlaceholder(options.placeholder); + } + text.setValue(obj[prop].toString()) + .onChange(async (value) => { + obj[prop] = (value !== '') ? value : resetValue || ''; + await this.plugin.saveSettings(); + if (options.refreshOnUpdate) { + this.display(); + } + }); + return text; + }); + } + + addNumber(options: SettingCompOptions, obj: any, prop: string) { + const asFloat = options.float; + const isResettable = options.resetValue !== undefined; + const resetValue = options.resetValue; + const instance = new Setting(this.containerEl); + + if (options.title) { + instance.setName(options.title); + } + if (options.description) { + instance.setDesc(options.description); + } + + this.setClasses(instance, options.classes); + + if (isResettable) { + instance.addExtraButton(button => button + .setIcon(ICON_RESET) + .setTooltip(TEXT_RESET) + .onClick(async () => { + obj[prop] = (options.isValueArray) ? [...resetValue] : resetValue; + await this.plugin.saveSettings(); + this.display(); + }) + ) + } + + if (options.isValueArray && options.length !== undefined) { + const hasPlaceholders = options.placeholders !== undefined; + for (let i = 0; i < options.length; i++) { + instance.addText((text) => { + if (hasPlaceholders && options?.placeholders) { + text.setPlaceholder(options.placeholders[i] || ''); + } else if (options.placeholder) { + text.setPlaceholder(options.placeholder); + } + text.setValue(obj[prop][i].toString()) + .onChange(async (value) => { + let num = (asFloat) ? parseFloat(value) : parseInt(value, 10); + if (isNaN(num) && isResettable) { + num = resetValue[i] || 0; + } + obj[prop][i] = num; + await this.plugin.saveSettings(); + if (options.refreshOnUpdate) { + this.display(); + } + }); + return text; + }); + } + } else { + instance.addText((text) => { + if (options.placeholder) { + text.setPlaceholder(options.placeholder); + } + text.setValue(obj[prop].toString()) + .onChange(async (value) => { + let num = (asFloat) ? parseFloat(value) : parseInt(value, 10); + if (isNaN(num) && isResettable) { + num = resetValue; + } + obj[prop] = num; + await this.plugin.saveSettings(); + if (options.refreshOnUpdate) { + this.display(); + } + }); + return text; + }); + } + } + + setClasses(instance: Setting, classes?: Array) { + if (classes) { + classes.forEach((c) => instance.setClass(c)); + } + } +} + diff --git a/src/settings/migrator.ts b/src/settings/migrator.ts new file mode 100644 index 0000000..af39d16 --- /dev/null +++ b/src/settings/migrator.ts @@ -0,0 +1,60 @@ +import SimpleBanner from '../main'; +import { DeviceType } from '../types/enums'; +import { SimpleBannerSettings } from '../types/interfaces'; + +export default class SettingsMigrator { + static async migrate(data: any, plugin: SimpleBanner): Promise { + const DESKTOP = DeviceType.Desktop; + const TABLET = DeviceType.Tablet; + const PHONE = DeviceType.Phone; + const migrationMap: { [oldKey: string]: { target: string; devices?: string[] } } = { + desktopHeight: { target: 'desktop.height' }, + tabletHeight: { target: 'tablet.height' }, + mobileHeight: { target: 'phone.height' }, + offset: { target: 'noteOffset', devices: [DESKTOP, TABLET, PHONE] }, + fade: { target: 'bannerFade', devices: [DESKTOP, TABLET, PHONE] }, + radius: { target: 'bannerRadius', devices: [DESKTOP, TABLET, PHONE] }, + padding: { target: 'bannerPadding', devices: [DESKTOP, TABLET, PHONE] }, + propertyName: { target: 'properties.image' }, + }; + + let neededMigration = false; + const newData = { ...data }; + + for (const oldKey in migrationMap) { + if (newData.hasOwnProperty(oldKey) && newData[oldKey] !== undefined) { + neededMigration = true; + const migration = migrationMap[oldKey]; + + if (migration.devices) { + migration.devices.forEach((device) => { + const path = device + '.' + migration.target; + this.setValueByPath(newData, path, newData[oldKey]); + }); + } else { + this.setValueByPath(newData, migration.target, newData[oldKey]); + } + delete newData[oldKey]; + } + } + + if (neededMigration) { + await plugin.saveData(newData); + } + + return newData; + } + + static setValueByPath(obj: any, path: string, value: any) { + const parts = path.split('.'); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part] || typeof current[part] !== 'object') { + current[part] = {}; + } + current = current[part]; + } + current[parts[parts.length - 1]] = value; + } +} diff --git a/src/styles.scss b/src/styles.scss deleted file mode 100644 index 61c6f6e..0000000 --- a/src/styles.scss +++ /dev/null @@ -1,378 +0,0 @@ -/* ------------------------------- - Animations -------------------------------- */ -@keyframes fadein { - from { opacity: 0; } - to { opacity: 1; } -} -@keyframes movedown { - from { background-position-y: calc(50% + (var(--smpbn-img-y, 0) - 10px)); } - to { background-position-y: calc(50% + var(--smpbn-img-y, 0)); } -} -@keyframes moveup { - from { transform: translateX(10px) } - to { transform: translateX(0) } -} - -/* ------------------------------- - Simple Banner Styles -------------------------------- */ -div.simple-banner { - position: absolute; - inset: 0; - z-index: 1; - width: calc(100% - (var(--smpbn-padding, 8px) * 2)); - height: 100%; - margin-left: var(--smpbn-padding, 8px); - max-height: var(--smpbn-height, 240px); - padding: var(--size-4-3) var(--size-4-3) 0 var(--size-4-3); - user-select: none; - - &:before { - position: absolute; - inset: 0; - z-index: -1; - width: 100%; - height: 100%; - content: ''; - box-shadow: inset 0 2px 4px 1px rgba(0, 0, 0, 0.1); - border-radius: var(--smpbn-radius, 0px); - background-color: rgba(0,0,0,.2); - mask-image: var(--smpbn-fade, none); - } - - &:after { - position: absolute; - inset: 0; - z-index: 1; - width: 100%; - height: 100%; - content: ''; - border-radius: var(--smpbn-radius, 0px); - background-image: var(--smpbn-url); - background-repeat: var(--smpbn-repeat, no-repeat); - background-size: var(--smpbn-size, cover); - background-position-x: calc(50% + var(--smpbn-img-x, 0)); - background-position-y: calc(50% + var(--smpbn-img-y, 0)); - animation: fadein 0.7s linear forwards, movedown 0.8s ease-out; - transition: background-position-x 0.5s ease-in-out, background-position-y 0.5s ease-in-out; - will-change: transform, background-position-x, background-position-y; - transform-style: preserve-3d; - image-rendering: crisp-edges; - backface-visibility: hidden; - overflow: hidden; - mask-image: var(--smpbn-fade, none); - } - - & > div.icon { - position: relative; - display: flex; - justify-content: var(--smpbn-icon-alignh); - align-items: var(--smpbn-icon-alignv); - width: inherit; - height: inherit; - z-index: 2; - margin: 0 auto; - max-width: var(--file-line-width); - transform: translate(0, 0); - animation: fadein 0.7s linear forwards, moveup 0.8s ease-out; - will-change: transform, opacity; - - & > div { - display: block; - position: relative; - width: var(--smpbn-icon-size, 96px); - height: var(--smpbn-icon-size, 96px); - background-color: color-mix(in srgb, var(--smpbn-icon-background) 80%, transparent); - transform: translate(var(--smpbn-icon-offset-x), var(--smpbn-icon-offset-y)); - border-radius: var(--smpbn-icon-radius, 8px); - border: var(--smpbn-icon-border, 0px) solid var(--background-primary); - z-index: 2; - overflow: hidden; - - &[data-type="text"]:after { - display: flex; - justify-content: center; - align-items: center; - position: absolute; - inset: 0; - top: 0; - width: 100%; - height: 100%; - z-index: 1; - text-transform: uppercase; - content: var(--smpbn-icon); - color: var(--text-normal); - line-height: 0; - font-size: var(--smpbn-icon-fontsize, inherit); - user-select: none; - } - &[data-type="link"]:after { - position: absolute; - text-transform: uppercase; - inset: 0; - width: 100%; - height: 100%; - z-index: 1; - transform: translate(0, 0); - content: ''; - background-image: var(--smpbn-icon); - background-repeat: no-repeat; - background-size: cover; - background-position: center; - overflow: hidden; - } - } - - &.is-windows { - & > div { - &[data-type="text"]:after { - top: calc(0% - (var(--smpbn-icon-fontsize, 0) * 0.05)); - } - } - } - - &.static { - animation: none; - } - } - - &.static:after { - animation: none; - } - - & + div { - position: relative; - z-index: 3; - } -} - -body { - &.is-phone, &.is-tablet { - div.simple-banner { - width: calc(100% - (var(--smpbn-padding, 8px) - 2px)); - } - } - - &.is-tablet { - div.simple-banner { - & > div.icon { - left: calc(var(--size-4-2) * -1); - } - } - } - - &.is-phone { - div.simple-banner { - & > div.icon { - left: auto; - } - } - } -} - -/* ------------------------------- - Settings -------------------------------- */ -.setting-item { - &.smpbn-banner-radii > .setting-item-control { - display: grid; - grid-template-columns: auto auto auto; - grid-template-rows: auto auto; - - & > *:nth-child(1) { - grid-column: 1; - grid-row: 1 / 3; - } - & > *:nth-child(2) { - grid-column: 2; - grid-row: 1; - } - & > *:nth-child(3) { - grid-column: 3; - grid-row: 1; - } - & > *:nth-child(4) { - grid-column: 2; - grid-row: 2; - } - & > *:nth-child(5) { - grid-column: 3; - grid-row: 2; - } - & > input { - max-width: 78.2px; - text-align: center; - } - } - - &.smpbn-banner-offset > .setting-item-control { - display: grid; - grid-template-columns: auto auto auto; - grid-template-rows: auto; - - & > *:nth-child(1) { - grid-column: 1; - grid-row: 1; - } - & > *:nth-child(2) { - grid-column: 2; - grid-row: 1; - } - & > *:nth-child(3) { - grid-column: 3; - grid-row: 1; - } - & > input { - max-width: 78.2px; - text-align: center; - } - } -} - -/* ------------------------------- - Obsidian Properties Overrides -------------------------------- */ -.workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, -.workspace-leaf-content[data-sb] .view-content .markdown-preview-view { - padding-top: var(--smpbn-height, 240px); - - .inline-embed .el-pre.mod-frontmatter.mod-ui, - .inline-embed .markdown-preview-view { - padding-top: initial; - } -} - -body.smpbn-autohide { - .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, - .workspace-leaf-content[data-sb] .view-content .markdown-preview-view { - padding-top: 0; - - .metadata-container { - position: absolute; - container-type: normal; - left: 50%; - transform: translateX(-50%); - padding: var(--size-4-2) var(--size-4-6) var(--size-4-6) var(--size-4-6); - z-index: 2; - width: 100%; - - .metadata-properties-heading { - width: 100%; - transform: translateY(-3px); - transition: opacity 0.2s linear, transform 0.2s ease-in-out; - cursor: pointer; - } - - &:before { - content: ''; - position: absolute; - inset: 0; - width: 100%; - height: 100%; - left: 0; - background-color: color-mix(in srgb, var(--background-primary) 90%, transparent); - backdrop-filter: blur(5px); - opacity: 1; - z-index: -1; - border-radius: 0 0 var(--radius-m) var(--radius-m); - box-shadow: 0 6px 8px rgba(0, 0, 0, 0.4); - transform: translateY(0); - transition: opacity 0.2s linear, transform 0.2s ease-in-out; - } - - &:after { - content: ''; - position: absolute; - inset: 0; - height: 2px; - width: 100%; - opacity: 0.1; - background-color: black; - box-shadow: 0 2px 2px black; - transition: box-shadow 0.2s linear, opacity 0.2s linear; - } - - &.is-collapsed { - .metadata-properties-heading { - opacity: 0.5; - transform: translateY(-70px); - & > .metadata-properties-title { - opacity: 0; - transition: opacity 0.2s linear; - } - } - &:before { - opacity: 0; - transform: translateY(-70px); - } - &:after { - opacity: 0; - box-shadow: 0 2px 2px black; - } - } - - &:hover, &:focus, &:focus-within { - &.is-collapsed { - &:before { - transform: translateY(-26px); - opacity: 0.85; - } - &:after { - opacity: 0.1; - } - } - - .metadata-properties-heading.is-collapsed { - opacity: 1; - transform: translateY(-3px); - &:before { - opacity: 0; - } - & > .metadata-properties-title { - opacity: 1; - } - } - } - } - - .cm-contentContainer { - position: relative; - z-index: 1; - padding-top: calc(var(--smpbn-height, 240px) + (var(--smpbn-note-offset) - 6px)); - } - - .el-pre.mod-frontmatter.mod-ui { - padding-top: calc(var(--smpbn-height, 240px) + (var(--smpbn-note-offset) + 12px)); - } - - .inline-embed .el-pre.mod-frontmatter.mod-ui { - padding-top: initial; - } - } - - &.show-inline-title .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, - &.show-inline-title .workspace-leaf-content[data-sb] .view-content > .markdown-preview-view { - .cm-contentContainer { - padding-top: 0; - } - .inline-title { - padding-top: calc(var(--smpbn-height, 240px) + (var(--smpbn-note-offset) + 12px)); - } - .el-pre.mod-frontmatter.mod-ui { - padding-top: 0; - } - } - - &.is-mobile { - .workspace-leaf-content[data-sb] .view-content > .markdown-source-view.mod-cm6 > .cm-editor > .cm-scroller, - .workspace-leaf-content[data-sb] .view-content .markdown-preview-view { - .metadata-container { - left: calc(50% + (var(--size-4-1) * 1)); - } - } - } -} - - - diff --git a/src/types/enums.ts b/src/types/enums.ts new file mode 100644 index 0000000..da512a0 --- /dev/null +++ b/src/types/enums.ts @@ -0,0 +1,39 @@ +//---------------------------------- +// Plugin Enums +//---------------------------------- +export enum IconType { + Link = 'link', + Text = 'text', +} + +export enum ViewMode { + Source = 'source', + Reading = 'preview', +} + +//---------------------------------- +// Settings Enums +//---------------------------------- +export enum DeviceType { + Desktop = 'desktop', + Tablet = 'tablet', + Phone = 'phone', +} + +export enum CSSValue { + RevertLayer = 'revert-layer', + Auto = 'auto', + Initial = 'initial', + // Inherit = 'inherit', + Transparent = 'transparent', + Repeat = 'repeat', +} + +export enum CSSClasses { + Main = 'simple-banner', + Icon = 'icon', + Datetime = 'date-time', + Static = 'static', + Autohide = 'sb-autohide', + IsWindows = 'is-windows', +} diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts new file mode 100644 index 0000000..8d0e750 --- /dev/null +++ b/src/types/interfaces.ts @@ -0,0 +1,87 @@ +import { MarkdownView } from 'obsidian'; +import { IconType, ViewMode } from './enums'; + +//---------------------------------- +// Plugin Interfaces +//---------------------------------- +export interface BannerData { + filepath: string | null; + image: string | null; + icon: string | null, + viewMode: ViewMode | null; + lastViewMode: ViewMode | null; + isImageChange: boolean; + isImagePropsUpdate: boolean; + needsUpdate: boolean; + view: MarkdownView | null | undefined; +} + +export interface ImageOptions { + url: string, + external: boolean, + x: number; + y: number; + repeatable: boolean; +} + +export interface IconData { + value: string | null, + type: IconType, +} + +export interface Datastore { + [key: string]: BannerData; +} + +//---------------------------------- +// Settings Interfaces +//---------------------------------- +export interface SimpleBannerSettings { + desktop: DeviceSettings + tablet: DeviceSettings + phone: DeviceSettings + properties: PropertySettings; +} + +export interface DeviceSettings { + bannerEnabled: boolean; + height: number; + noteOffset: number; + bannerRadius: Array; + bannerPadding: number; + bannerFade: boolean; + + iconEnabled: boolean, + iconSize: number; + iconRadius: number; + iconBackground: boolean; + iconBorder: number; + iconAlignment: Array; + iconOffset: Array; +} + +export interface PropertySettings { + autohide: boolean; + image: string; + icon: string; +} + +export interface SettingCompOptions { + title?: string; + description?: string; + placeholder?: string; + placeholders?: Array; + choices?: ValueLabelPair[]; + classes?: Array; + resettable?: boolean; + float?: boolean, + isValueArray?: boolean, + length?: number, + resetValue?: any, + refreshOnUpdate?: boolean; +} + +export interface ValueLabelPair { + value: any, + label: string, +} diff --git a/src/utils/domutils.ts b/src/utils/domutils.ts new file mode 100644 index 0000000..eba9eb8 --- /dev/null +++ b/src/utils/domutils.ts @@ -0,0 +1,37 @@ +import SimpleBanner from '../main'; + +let instance: SimpleBanner; +export default class DomUtils { + static init(plugin: SimpleBanner) { + instance = plugin; + } + static calculateFontsize(textContent: string) { + const temp = document.createElement('span'); + temp.setAttribute('style', 'position: absolute; visibility: hidden; white-space: nowrap;'); + temp.style.padding = '0'; + temp.style.margin = '0'; + temp.style.left = '-9999px'; + temp.textContent = textContent.toUpperCase(); + document.body.appendChild(temp); + const size = instance.deviceSettings.iconSize; + const checkWidth = size - 16; + + let fontSize = size; // Start big + temp.style.fontSize = fontSize + 'px'; + + while (temp.offsetWidth > checkWidth && fontSize > 1) { + fontSize -= 1; + temp.style.fontSize = fontSize + 'px'; + } + + document.body.removeChild(temp); + return `${fontSize}px`; + } + + static setCSSVariables(variables: Record, target: HTMLElement = document.body) { + const style = target.style; + Object.keys(variables).forEach(k => { + style.setProperty(`--sb-${k}`, variables[k]); + }); + } +} diff --git a/src/utils/parse.ts b/src/utils/parse.ts new file mode 100644 index 0000000..202ef55 --- /dev/null +++ b/src/utils/parse.ts @@ -0,0 +1,161 @@ +import { MarkdownView } from 'obsidian'; +import SimpleBanner from '../main'; +import { IconData, ImageOptions } from '../types/interfaces'; +import { IconType } from '../types/enums'; + +let instance: SimpleBanner; +const RegExpression = { + Wikilink: /^!?\[\[([^\]]+?)(\|([^\]]+?))?\]\]$/, + Markdown: /^!?\[([^\]]*)\]\(([^)]+?)\)$/, + MarkdownBare: /^!?<([^>]+)>$/, + Weblink: /^https?:\/\//i, +}; + +export default class Parse { + + static init(plugin: SimpleBanner) { + instance = plugin; + } + + static link(str: string, view?: MarkdownView | null, settingProperty?: string | null): ImageOptions { + let url: string | null = null; + let displayText: string | null = null; + let external: boolean; + let obsidianUrl: boolean = false; + let options = { x: 0, y: 0, repeatable: false }; + + const wikilinkMatch = str.match(RegExpression.Wikilink); + if (wikilinkMatch) { + url = wikilinkMatch[1].trim(); + displayText = wikilinkMatch[3] ? wikilinkMatch[3].trim() : null; + } + + const markdownMatch = str.match(RegExpression.Markdown); + const markdownBareMatch = str.match(RegExpression.MarkdownBare); + if (markdownMatch) { + displayText = markdownMatch[1].trim(); + url = markdownMatch[2].trim(); + } else if (markdownBareMatch) { + url = markdownBareMatch[1].trim(); + displayText = null; + } + + if (!url) { + url = str; + displayText = null; + } + + external = RegExpression.Weblink.test(url); + + if (this.isObsidianUrl(url)) { + const str = url.replace('obsidian://open', ''); + const params = new URLSearchParams(str); + let file = params.get('file'); + if (file) { + url = file; + obsidianUrl = true; + external = false; + displayText = null; + } + } + + const hashIndex = url.indexOf('#'); + if ((external || obsidianUrl) && hashIndex !== -1) { + options = this.imageProperties(url.substring(hashIndex + 1)); + url = url.replace(/#.*/, '').trim(); + } + + if (displayText) { + options = this.imageProperties(displayText); + } + + if (!external) { + const vault = instance.app.vault; + const files = vault.getFiles().filter(f => f.path === url || f.name === url); + let file = files.find(f => f.path === url); + if (file) { + url = vault.getResourcePath(file); + } + if (!file) { + file = files.find(f => f.name === url); + if (file) { + url = vault.getResourcePath(file); + } + } + + if (obsidianUrl && file && view) { + const activeFile = instance.app.workspace.getActiveFile(); + if (activeFile) { + // noinspection JSIgnoredPromiseFromCall + instance.app.fileManager.processFrontMatter(activeFile, (frontmatter) => { + const propName = settingProperty || instance.settingProperties.image; + frontmatter[propName] = `[[${file?.path}]]` + }); + } + } + } + + return { + url: url.trim(), + external, + ...options, + }; + } + + static imageProperties(str: string): { x: number, y: number, repeatable: boolean } { + let repeatable: boolean; + let x = 0; + let y = 0; + + const values = str.toLowerCase(); + repeatable = values.includes('repeat'); + + const sizes = str.split(/x|,/); + const numbers = sizes.filter(v => !isNaN(parseInt(v.trim(), 10))); + + if (numbers.length === 2) { + x = parseInt(numbers[0].trim(), 10); + y = parseInt(numbers[1].trim(), 10); + } else if (numbers.length === 1) { + y = parseInt(numbers[0].trim(), 10); + } + return { x, y, repeatable }; + } + + static icon(icon: string, view?: MarkdownView | null): IconData { + const str = icon || ''; + const out = { value: null, type: IconType.Text } as IconData; + + if (RegExpression.Wikilink.test(str)) { + out.type = IconType.Link; + } else if (RegExpression.Markdown.test(str) || RegExpression.MarkdownBare.test(str)) { + out.type = IconType.Link; + } else if (RegExpression.Weblink.test(icon)) { + out.type = IconType.Link; + } else if (this.isObsidianUrl(icon)) { + out.type = IconType.Link; + } + + if (out.type === IconType.Link) { + const data = this.link(str, view, instance.settingProperties.icon); + out.value = data.url; + } else { + out.value = str; + } + + return out; + } + + static isImagePropertiesUpdate(oldstr?: string | null, newstr?: string | null, view?: MarkdownView | null): boolean { + if (!oldstr || !newstr) { + return false; + } + const oldopt = this.link(oldstr, view); + const newopt = this.link(newstr, view); + return oldopt.url === newopt.url; + } + + static isObsidianUrl(url: string): boolean { + return url.startsWith('obsidian://open'); + } +}