major refactor

- added data store for banners
- better css variable handling
- more modularity
- reduced redundancy
- better performance
This commit is contained in:
Sandro Ducceschi 2025-05-02 01:57:33 +02:00
parent 83a66ec233
commit 4374ba0b35
21 changed files with 1572 additions and 1438 deletions

View file

@ -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',

View file

@ -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",

33
src/data/store.ts Normal file
View file

@ -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);
}
}

50
src/feature/banner.ts Normal file
View file

@ -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>): 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<HTMLElement>): 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);
})
}
}

51
src/feature/icon.ts Normal file
View file

@ -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();
}
});
}
}

6
src/main.scss Normal file
View file

@ -0,0 +1,6 @@
@use 'scss/variables';
@use 'scss/animations';
@use 'scss/banner';
@use 'scss/icon';
@use 'scss/frontmatter';
@use 'scss/settings';

View file

@ -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<HTMLElement>;
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<string, string>, target: HTMLElement = document.body) {
const style = target.style;
Object.keys(variables).forEach(k => {
style.setProperty(`--smpbn-${k}`, variables[k]);
});
}
}

15
src/scss/animations.scss Normal file
View file

@ -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) }
}

66
src/scss/banner.scss Normal file
View file

@ -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));
}
}
}

143
src/scss/frontmatter.scss Normal file
View file

@ -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));
}
}
}
}

91
src/scss/icon.scss Normal file
View file

@ -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;
}
}
}

65
src/scss/settings.scss Normal file
View file

@ -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;
}
}
}

39
src/scss/variables.scss Normal file
View file

@ -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;
}

View file

@ -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<number>;
bannerPadding: number;
bannerFade: boolean;
iconEnabled: boolean,
iconSize: number;
iconRadius: number;
iconBackground: boolean;
iconBorder: number;
iconAlignment: Array<string>;
iconOffset: Array<number>;
}
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<SimpleBannerSettings> {
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;
}
}

465
src/settings/core.ts Normal file
View file

@ -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<string>) {
if (classes) {
classes.forEach((c) => instance.setClass(c));
}
}
}

60
src/settings/migrator.ts Normal file
View file

@ -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<SimpleBannerSettings> {
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;
}
}

View file

@ -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));
}
}
}
}

39
src/types/enums.ts Normal file
View file

@ -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',
}

87
src/types/interfaces.ts Normal file
View file

@ -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<number>;
bannerPadding: number;
bannerFade: boolean;
iconEnabled: boolean,
iconSize: number;
iconRadius: number;
iconBackground: boolean;
iconBorder: number;
iconAlignment: Array<string>;
iconOffset: Array<number>;
}
export interface PropertySettings {
autohide: boolean;
image: string;
icon: string;
}
export interface SettingCompOptions {
title?: string;
description?: string;
placeholder?: string;
placeholders?: Array<string>;
choices?: ValueLabelPair[];
classes?: Array<string>;
resettable?: boolean;
float?: boolean,
isValueArray?: boolean,
length?: number,
resetValue?: any,
refreshOnUpdate?: boolean;
}
export interface ValueLabelPair {
value: any,
label: string,
}

37
src/utils/domutils.ts Normal file
View file

@ -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<string, string>, target: HTMLElement = document.body) {
const style = target.style;
Object.keys(variables).forEach(k => {
style.setProperty(`--sb-${k}`, variables[k]);
});
}
}

161
src/utils/parse.ts Normal file
View file

@ -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');
}
}