/* eslint-disable @typescript-eslint/no-explicit-any */ /** * The `obsidian` module, as far as FineNotes' notebook view needs it, for a * plain web page. Written from two public sources only: the names FineNotes * 2.3.0's `src/` imports from `obsidian` (and how it calls them), and the API * typings in the `obsidian` npm package (MIT). Obsidian's public DOM class * names (`modal`, `menu-item`, `setting-item`, ...) are kept so FineNotes' * stylesheet and playground.css can style what is built here. * * Original code for this site. GPL-3.0-or-later. * * Contents * 1. Failures where an error in a callback goes * 2. DOM helpers createEl and friends, on the prototypes and as globals * 3. Platform what kind of device this is * 4. Small helpers debounce, normalizePath, requireApiVersion, network, PDF * 5. Icons the Lucide table from the build * 6. Events, Component * 7. Keys Scope, Keymap, pushScope/popScope * 8. Files TAbstractFile, TFile, TFolder * 9. Views View, ItemView, FileView, TextFileView, WorkspaceLeaf * 10. Notice * 11. Modal, SuggestModal, FuzzySuggestModal * 12. Menu * 13. Setting and its controls, PluginSettingTab * 14. MarkdownRenderer */ import lucideTable from "virtual:lucide-icons"; // ============================================================================================= // 1. Failures // ============================================================================================= let onFailure: (error: unknown) => void = (error) => console.error(error); /** Where an error thrown by a callback this module runs (a menu click, a dialog's onOpen…) goes. */ export function setShimErrorHandler(handler: (error: unknown) => void): void { onFailure = handler; } /** Run `work`; a throw or a rejected promise is reported, never left loose. */ function shielded(work: () => R): R | undefined { try { const out = work(); if (out && typeof (out as any).then === "function") (out as any).then(undefined, onFailure); return out; } catch (error) { onFailure(error); return undefined; } } // ============================================================================================= // 2. DOM helpers // ============================================================================================= type AttrValue = string | number | boolean | null; export interface DomElementInfo { cls?: string | string[]; text?: string | DocumentFragment; attr?: Record; title?: string; parent?: Node; value?: string; type?: string; prepend?: boolean; placeholder?: string; href?: string; } export type SvgElementInfo = Pick; declare global { interface Node { detach(): void; empty(): void; appendText(text: string): void; insertAfter(node: T, child: Node | null): T; indexOf(other: Node): number; setChildrenInPlace(children: Node[]): void; instanceOf(type: new () => T): this is T; readonly doc: Document; readonly win: Window; createEl( tag: K, o?: DomElementInfo | string, cb?: (el: HTMLElementTagNameMap[K]) => void, ): HTMLElementTagNameMap[K]; createDiv(o?: DomElementInfo | string, cb?: (el: HTMLDivElement) => void): HTMLDivElement; createSpan(o?: DomElementInfo | string, cb?: (el: HTMLSpanElement) => void): HTMLSpanElement; createSvg( tag: K, o?: SvgElementInfo | string, cb?: (el: SVGElementTagNameMap[K]) => void, ): SVGElementTagNameMap[K]; } interface Element { getText(): string; setText(text: string | DocumentFragment): void; addClass(...classes: string[]): void; addClasses(classes: string[]): void; removeClass(...classes: string[]): void; removeClasses(classes: string[]): void; toggleClass(classes: string | string[], on: boolean): void; hasClass(cls: string): boolean; setAttr(name: string, value: AttrValue): void; setAttrs(attrs: Record): void; getAttr(name: string): string | null; matchParent(selector: string, lastParent?: Element): Element | null; find(selector: string): any; findAll(selector: string): HTMLElement[]; isActiveElement(): boolean; getCssPropertyValue(property: string, pseudo?: string): string; setCssStyles(styles: Partial): void; setCssProps(props: Record): void; } interface HTMLElement { show(): void; hide(): void; toggle(show: boolean): void; toggleVisibility(visible: boolean): void; isShown(): boolean; } interface Array { first(): T | undefined; last(): T | undefined; contains(target: T): boolean; remove(target: T): void; unique(): T[]; } interface String { contains(target: string): boolean; } interface Math { clamp(value: number, min: number, max: number): number; } function createEl( tag: K, o?: DomElementInfo | string, cb?: (el: HTMLElementTagNameMap[K]) => void, ): HTMLElementTagNameMap[K]; function createDiv(o?: DomElementInfo | string, cb?: (el: HTMLDivElement) => void): HTMLDivElement; function createSpan(o?: DomElementInfo | string, cb?: (el: HTMLSpanElement) => void): HTMLSpanElement; function createSvg( tag: K, o?: SvgElementInfo | string, cb?: (el: SVGElementTagNameMap[K]) => void, ): SVGElementTagNameMap[K]; function createFragment(cb?: (fragment: DocumentFragment) => void): DocumentFragment; // eslint-disable-next-line no-var var activeWindow: Window; // eslint-disable-next-line no-var var activeDocument: Document; } const SVG_NS = "http://www.w3.org/2000/svg"; /** Put `value` on `proto` under `name` unless the browser already has one. */ function graft(proto: object, name: string, value: unknown): void { if (Object.prototype.hasOwnProperty.call(proto, name)) return; Object.defineProperty(proto, name, { value, writable: true, configurable: true, enumerable: false }); } function graftGetter(proto: object, name: string, get: () => unknown): void { if (Object.prototype.hasOwnProperty.call(proto, name)) return; Object.defineProperty(proto, name, { get, configurable: true, enumerable: false }); } const classList = (classes: string | string[] | undefined): string[] => (Array.isArray(classes) ? classes : (classes ?? "").split(/\s+/)).filter((c) => c.length > 0); /** Write one attribute; null (or false) takes it away. */ function writeAttr(el: Element, name: string, value: AttrValue | undefined): void { if (value === null || value === undefined || value === false) el.removeAttribute(name); else el.setAttribute(name, value === true ? "" : String(value)); } /** Make an element as `createEl` describes it, under `defaultParent` unless `info.parent` says otherwise. */ function makeElement( ns: "html" | "svg", tag: string, spec: DomElementInfo | string | undefined, cb: ((el: any) => void) | undefined, defaultParent: Node | null, ): any { const info: DomElementInfo = typeof spec === "string" ? { cls: spec } : (spec ?? {}); const home = defaultParent ?? info.parent ?? null; const doc = (home?.nodeType === Node.DOCUMENT_NODE ? (home as Document) : home?.ownerDocument) ?? document; const el: Element = ns === "svg" ? doc.createElementNS(SVG_NS, tag) : doc.createElement(tag); const names = classList(info.cls); if (names.length) el.classList.add(...names); if (info.type !== undefined) writeAttr(el, "type", info.type); if (info.text !== undefined) el.setText(info.text); if (info.title !== undefined) writeAttr(el, "title", info.title); if (info.placeholder !== undefined) writeAttr(el, "placeholder", info.placeholder); if (info.href !== undefined) writeAttr(el, "href", info.href); for (const [k, v] of Object.entries(info.attr ?? {})) writeAttr(el, k, v); if (info.value !== undefined) (el as HTMLInputElement).value = info.value; const parent = info.parent ?? defaultParent; if (parent && parent.nodeType !== Node.DOCUMENT_NODE) { if (info.prepend) parent.insertBefore(el, parent.firstChild); else parent.appendChild(el); } cb?.(el); return el; } function installDomHelpers(): void { const N = Node.prototype; graft(N, "detach", function (this: Node) { this.parentNode?.removeChild(this); }); graft(N, "empty", function (this: Node) { while (this.lastChild) this.removeChild(this.lastChild); }); graft(N, "appendText", function (this: Node, text: string) { this.appendChild((this.ownerDocument ?? document).createTextNode(text)); }); graft(N, "insertAfter", function (this: Node, node: Node, child: Node | null) { return this.insertBefore(node, child ? child.nextSibling : this.firstChild); }); graft(N, "indexOf", function (this: Node, other: Node) { return Array.prototype.indexOf.call(this.childNodes, other); }); graft(N, "setChildrenInPlace", function (this: Node, children: Node[]) { const keep = new Set(children); for (const old of Array.from(this.childNodes)) if (!keep.has(old)) this.removeChild(old); let at: ChildNode | null = this.firstChild; for (const kid of children) { if (kid === at) at = at.nextSibling; else this.insertBefore(kid, at); } }); graft(N, "instanceOf", function (this: Node, type: new () => unknown) { return this instanceof type; }); graftGetter(N, "doc", function (this: Node) { return this.ownerDocument ?? (this as unknown as Document); }); graftGetter(N, "win", function (this: Node) { return (this.ownerDocument ?? (this as unknown as Document)).defaultView ?? window; }); graft(N, "createEl", function (this: Node, tag: string, o?: DomElementInfo | string, cb?: (el: any) => void) { return makeElement("html", tag, o, cb, this); }); graft(N, "createDiv", function (this: Node, o?: DomElementInfo | string, cb?: (el: any) => void) { return makeElement("html", "div", o, cb, this); }); graft(N, "createSpan", function (this: Node, o?: DomElementInfo | string, cb?: (el: any) => void) { return makeElement("html", "span", o, cb, this); }); graft(N, "createSvg", function (this: Node, tag: string, o?: SvgElementInfo | string, cb?: (el: any) => void) { return makeElement("svg", tag, o, cb, this); }); const E = Element.prototype; graft(E, "getText", function (this: Element) { return this.textContent ?? ""; }); graft(E, "setText", function (this: Element, text: string | DocumentFragment) { if (typeof text === "string") this.textContent = text; else { this.empty(); this.appendChild(text); } }); graft(E, "addClass", function (this: Element, ...classes: string[]) { this.classList.add(...classes.flatMap(classList)); }); graft(E, "addClasses", function (this: Element, classes: string[]) { this.classList.add(...classes.flatMap(classList)); }); graft(E, "removeClass", function (this: Element, ...classes: string[]) { this.classList.remove(...classes.flatMap(classList)); }); graft(E, "removeClasses", function (this: Element, classes: string[]) { this.classList.remove(...classes.flatMap(classList)); }); graft(E, "toggleClass", function (this: Element, classes: string | string[], on: boolean) { for (const c of classList(classes)) this.classList.toggle(c, !!on); }); graft(E, "hasClass", function (this: Element, cls: string) { return this.classList.contains(cls); }); graft(E, "setAttr", function (this: Element, name: string, value: AttrValue) { writeAttr(this, name, value); }); graft(E, "setAttrs", function (this: Element, attrs: Record) { for (const [k, v] of Object.entries(attrs)) writeAttr(this, k, v); }); graft(E, "getAttr", function (this: Element, name: string) { return this.getAttribute(name); }); graft(E, "matchParent", function (this: Element, selector: string, lastParent?: Element) { for (let at: Element | null = this; at && at !== lastParent; at = at.parentElement) if (at.matches(selector)) return at; return null; }); graft(E, "find", function (this: Element, selector: string) { return this.querySelector(selector); }); graft(E, "findAll", function (this: Element, selector: string) { return Array.from(this.querySelectorAll(selector)); }); graft(E, "isActiveElement", function (this: Element) { return (this.ownerDocument ?? document).activeElement === this; }); graft(E, "getCssPropertyValue", function (this: Element, property: string, pseudo?: string) { return getComputedStyle(this, pseudo).getPropertyValue(property); }); graft(E, "setCssStyles", function (this: HTMLElement, styles: Partial) { Object.assign(this.style, styles); }); graft(E, "setCssProps", function (this: HTMLElement, props: Record) { for (const [k, v] of Object.entries(props)) this.style.setProperty(k, v); }); const H = HTMLElement.prototype; graft(H, "show", function (this: HTMLElement) { this.style.removeProperty("display"); }); graft(H, "hide", function (this: HTMLElement) { this.style.display = "none"; }); graft(H, "toggle", function (this: HTMLElement, show: boolean) { if (show) this.show(); else this.hide(); }); graft(H, "toggleVisibility", function (this: HTMLElement, visible: boolean) { this.style.visibility = visible ? "" : "hidden"; }); graft(H, "isShown", function (this: HTMLElement) { return this.isConnected && this.offsetParent !== null; }); const DF = DocumentFragment.prototype; graft(DF, "find", function (this: DocumentFragment, selector: string) { return this.querySelector(selector); }); graft(DF, "findAll", function (this: DocumentFragment, selector: string) { return Array.from(this.querySelectorAll(selector)); }); const A = Array.prototype; graft(A, "first", function (this: unknown[]) { return this[0]; }); graft(A, "last", function (this: unknown[]) { return this[this.length - 1]; }); graft(A, "contains", function (this: unknown[], target: unknown) { return this.includes(target); }); graft(A, "remove", function (this: unknown[], target: unknown) { for (let i = this.length - 1; i >= 0; i--) if (this[i] === target) this.splice(i, 1); }); graft(A, "unique", function (this: unknown[]) { return [...new Set(this)]; }); graft(String.prototype, "contains", function (this: string, target: string) { return this.includes(target); }); graft(Math, "clamp", (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))); const g = window as any; g.createEl ??= (tag: string, o?: DomElementInfo | string, cb?: (el: any) => void) => makeElement("html", tag, o, cb, null); g.createDiv ??= (o?: DomElementInfo | string, cb?: (el: any) => void) => makeElement("html", "div", o, cb, null); g.createSpan ??= (o?: DomElementInfo | string, cb?: (el: any) => void) => makeElement("html", "span", o, cb, null); g.createSvg ??= (tag: string, o?: SvgElementInfo | string, cb?: (el: any) => void) => makeElement("svg", tag, o, cb, null); g.createFragment ??= (cb?: (fragment: DocumentFragment) => void) => { const fragment = document.createDocumentFragment(); cb?.(fragment); return fragment; }; g.activeWindow ??= window; g.activeDocument ??= document; g.sleep ??= (ms: number) => new Promise((done) => window.setTimeout(done, ms)); g.nextFrame ??= () => new Promise((done) => window.requestAnimationFrame(() => done())); } installDomHelpers(); // ============================================================================================= // 3. Platform // ============================================================================================= function sensePlatform() { const nav = typeof navigator === "undefined" ? null : navigator; const agent = nav?.userAgent ?? ""; const os = (nav as any)?.userAgentData?.platform ?? nav?.platform ?? ""; const query = (q: string): boolean => typeof matchMedia === "function" && matchMedia(q).matches; // A device whose only pointer is a finger: a phone or a tablet. (The same // test finger-draw.ts uses to switch "Finger draws" on by default.) const touchOnly = query("(pointer: coarse)") && !query("(hover: hover)"); const shortSide = typeof screen === "undefined" ? 1000 : Math.min(screen.width, screen.height); const apple = /Mac|iPhone|iPad|iPod/i.test(os) || /Macintosh|iPhone|iPad|iPod/.test(agent); const android = /Android/i.test(agent); return { isDesktop: !touchOnly, isMobile: touchOnly, isDesktopApp: false, isMobileApp: false, isIosApp: false, isAndroidApp: false, isPhone: touchOnly && shortSide < 600, isTablet: touchOnly && shortSide >= 600, isMacOS: apple, isWin: /Win/i.test(os) || /Windows/.test(agent), isLinux: !android && (/Linux/i.test(os) || /Linux/.test(agent)), isSafari: /Safari\//.test(agent) && !/Chrome|Chromium|CriOS|Edg|Android|FxiOS/.test(agent), resourcePathPrefix: "", }; } export const Platform = sensePlatform(); // ============================================================================================= // 4. Small helpers // ============================================================================================= /** The API version this stand-in answers to (FineNotes 2.3.0's minAppVersion). */ export const apiVersion = "1.13.0"; export function requireApiVersion(version: string): boolean { const parts = (v: string): number[] => v.split(".").map((n) => parseInt(n, 10) || 0); const have = parts(apiVersion); const want = parts(version); for (let i = 0; i < Math.max(have.length, want.length); i++) { const d = (have[i] ?? 0) - (want[i] ?? 0); if (d !== 0) return d > 0; } return true; } export interface Debouncer { (...args: A): Debouncer; cancel(): Debouncer; run(): R | void; } /** * Call `cb` `timeout` ms after the first call of a burst (or after the last * one, with `resetTimer`), with the latest arguments. */ export function debounce( cb: (...args: A) => R, timeout = 0, resetTimer = false, ): Debouncer { let timer = 0; let latest: A | null = null; const fire = (): R | void => { timer = 0; const args = latest; latest = null; if (args) return cb(...args); }; const call = ((...args: A) => { latest = args; if (timer && !resetTimer) return call; window.clearTimeout(timer); timer = window.setTimeout(fire, timeout); return call; }) as Debouncer; call.cancel = () => { window.clearTimeout(timer); timer = 0; latest = null; return call; }; call.run = () => { if (!timer) return; window.clearTimeout(timer); return fire(); }; return call; } /** No-break spaces, which Obsidian turns into plain ones in a path. */ const ODD_SPACES = new RegExp(`[${String.fromCharCode(0xa0, 0x202f)}]`, "g"); export function normalizePath(path: string): string { const tidy = path .replace(/[\\/]+/g, "/") .replace(/^\/+|\/+$/g, "") .replace(ODD_SPACES, " ") .normalize("NFC"); return tidy === "" ? "/" : tidy; } export interface RequestUrlParam { url: string; method?: string; contentType?: string; body?: string | ArrayBuffer; headers?: Record; throw?: boolean; } export interface RequestUrlResponse { status: number; headers: Record; arrayBuffer: ArrayBuffer; json: any; text: string; } /** The playground never goes on the network: every request fails as an unreachable server would. */ export function requestUrl(_request: RequestUrlParam | string): Promise { return Promise.reject(new Error("The playground does not use the network.")); } /** Obsidian's bundled pdf.js is not part of the playground. */ export function loadPdfJs(): Promise { return Promise.reject(new Error("Reading PDFs works in the FineNotes plugin.")); } // ============================================================================================= // 5. Icons // ============================================================================================= /** Inner SVG markup by icon id: Lucide's (24-unit grid) and any added with addIcon (100-unit). */ const iconMarkup = new Map( Object.entries(lucideTable as Record).map(([id, markup]) => [id, { markup, grid: 24 }]), ); const unknownIcons = new Set(); /** Icon ids asked for that the table does not have (to spot a gap in the build's icon list). */ export function missingIconNames(): string[] { return [...unknownIcons].sort(); } export function addIcon(iconId: string, svgContent: string): void { iconMarkup.set(iconId, { markup: svgContent, grid: 100 }); } export function getIconIds(): string[] { return [...iconMarkup.keys()]; } export function getIcon(iconId: string): SVGSVGElement | null { const id = iconId.startsWith("lucide-") && !iconMarkup.has(iconId) ? iconId.slice(7) : iconId; const entry = iconMarkup.get(id); if (!entry) { if (id) unknownIcons.add(id); return null; } const svg = document.createElementNS(SVG_NS, "svg"); const size = entry.grid === 24 ? 24 : 100; writeAttr(svg, "xmlns", SVG_NS); writeAttr(svg, "width", 24); writeAttr(svg, "height", 24); writeAttr(svg, "viewBox", `0 0 ${size} ${size}`); writeAttr(svg, "fill", "none"); writeAttr(svg, "stroke", "currentColor"); writeAttr(svg, "stroke-width", entry.grid === 24 ? 2 : 8); writeAttr(svg, "stroke-linecap", "round"); writeAttr(svg, "stroke-linejoin", "round"); svg.setAttribute("class", `svg-icon lucide-${id}`); svg.innerHTML = entry.markup; return svg; } /** Show icon `iconId` in `parent`, in place of the icon it showed before (its text stays). */ export function setIcon(parent: HTMLElement, iconId: string): void { const icon = getIcon(iconId); const old = Array.from(parent.children).filter((c) => c instanceof SVGSVGElement); const spot = old[0] ?? parent.firstChild; if (icon) parent.insertBefore(icon, spot); for (const svg of old) svg.remove(); } // ============================================================================================= // 6. Events, Component // ============================================================================================= type Listener = (...data: any[]) => unknown; export interface EventRef { /** The emitter this subscription belongs to. */ emitter: Events; name: string; fn: Listener; ctx?: unknown; } export class Events { private readonly subscriptions = new Map(); on(name: string, callback: Listener, ctx?: unknown): EventRef { const ref: EventRef = { emitter: this, name, fn: callback, ctx }; const list = this.subscriptions.get(name); if (list) list.push(ref); else this.subscriptions.set(name, [ref]); return ref; } off(name: string, callback: Listener): void { const list = this.subscriptions.get(name); if (!list) return; this.subscriptions.set( name, list.filter((ref) => ref.fn !== callback), ); } offref(ref: EventRef): void { const list = this.subscriptions.get(ref.name); if (list) list.remove(ref); } trigger(name: string, ...data: unknown[]): void { for (const ref of [...(this.subscriptions.get(name) ?? [])]) this.tryTrigger(ref, data); } tryTrigger(ref: EventRef, args: unknown[]): void { shielded(() => ref.fn.apply(ref.ctx, args)); } } /** Something with a lifetime: what it set up is taken down when it unloads. */ export class Component { _loaded = false; private readonly kids: Component[] = []; private teardown: Array<() => unknown> = []; load(): void { if (this._loaded) return; this._loaded = true; this.onload(); for (const kid of [...this.kids]) kid.load(); } onload(): void {} unload(): void { if (!this._loaded) return; this._loaded = false; while (this.kids.length) this.kids.pop()?.unload(); const steps = this.teardown.reverse(); this.teardown = []; for (const step of steps) shielded(step); this.onunload(); } onunload(): void {} addChild(component: T): T { this.kids.push(component); if (this._loaded) component.load(); return component; } removeChild(component: T): T { if (this.kids.includes(component)) { this.kids.remove(component); component.unload(); } return component; } register(cb: () => unknown): void { this.teardown.push(cb); } registerEvent(ref: EventRef): void { this.register(() => ref.emitter.offref(ref)); } registerDomEvent( el: EventTarget, type: string, callback: (ev: any) => unknown, options?: boolean | AddEventListenerOptions, ): void { el.addEventListener(type, callback as EventListener, options); this.register(() => el.removeEventListener(type, callback as EventListener, options)); } registerInterval(id: number): number { this.register(() => window.clearInterval(id)); return id; } } // ============================================================================================= // 7. Keys // ============================================================================================= export type Modifier = "Mod" | "Ctrl" | "Meta" | "Shift" | "Alt"; export interface KeymapInfo { modifiers: string | null; key: string | null; } export interface KeymapContext extends KeymapInfo { vkey: string; } export type KeymapEventListener = (evt: KeyboardEvent, ctx: KeymapContext) => false | any; export interface KeymapEventHandler extends KeymapInfo { scope: Scope; } interface Binding extends KeymapEventHandler { run: KeymapEventListener; } /** "Mod" is Cmd on Apple devices and Ctrl elsewhere; the rest name the physical keys. */ function physicalModifiers(mods: readonly string[]): string { const real = mods.map((m) => (m === "Mod" ? (Platform.isMacOS ? "Meta" : "Ctrl") : m)); return [...new Set(real)].sort().join(","); } function heldModifiers(evt: KeyboardEvent | MouseEvent | TouchEvent): string { const held: string[] = []; if (evt.altKey) held.push("Alt"); if (evt.ctrlKey) held.push("Ctrl"); if (evt.metaKey) held.push("Meta"); if (evt.shiftKey) held.push("Shift"); return held.join(","); } /** The key as a layout-independent name where one exists: "KeyZ" is "z" even with Shift held. */ function virtualKey(evt: KeyboardEvent): string { const code = evt.code ?? ""; if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase(); if (/^Digit\d$/.test(code)) return code.slice(5); return evt.key; } export class Scope { private readonly bindings: Binding[] = []; constructor(readonly parent?: Scope) {} register(modifiers: Modifier[] | null, key: string | null, func: KeymapEventListener): KeymapEventHandler { const binding: Binding = { scope: this, modifiers: modifiers ? physicalModifiers(modifiers) : null, key, run: func, }; this.bindings.push(binding); return binding; } unregister(handler: KeymapEventHandler): void { this.bindings.remove(handler as Binding); } /** * Offer a key press to this scope's bindings: "stop" once one answers * (false also cancels the browser's default), undefined to let the parent * scope try. */ offer(evt: KeyboardEvent): "stop" | undefined { const ctx: KeymapContext = { modifiers: heldModifiers(evt), key: evt.key, vkey: virtualKey(evt) }; for (const b of [...this.bindings]) { if (b.modifiers !== null && b.modifiers !== ctx.modifiers) continue; if (b.key !== null) { const want = b.key.toLowerCase(); if (want !== evt.key.toLowerCase() && want !== ctx.vkey.toLowerCase()) continue; } let answer: unknown; try { answer = b.run(evt, ctx); } catch (error) { onFailure(error); answer = false; } if (answer === false) { evt.preventDefault(); return "stop"; } if (answer !== undefined) return "stop"; } return undefined; } } /** The scope stack: the newest scope hears a key press first, then its parents. */ export class Keymap { private readonly stack: Scope[] = []; private listening = false; pushScope(scope: Scope): void { this.stack.remove(scope); this.stack.push(scope); if (this.listening) return; this.listening = true; window.addEventListener("keydown", (evt) => this.route(evt), true); } popScope(scope: Scope): void { this.stack.remove(scope); } private route(evt: KeyboardEvent): void { for (let scope: Scope | undefined = this.stack.last(); scope; scope = scope.parent) if (scope.offer(evt) === "stop") return; } static isModifier(evt: MouseEvent | TouchEvent | KeyboardEvent, modifier: Modifier): boolean { return heldModifiers(evt).split(",").includes(physicalModifiers([modifier])); } static isModEvent(evt?: MouseEvent | KeyboardEvent | null): "tab" | "split" | "window" | boolean { if (!evt) return false; const mod = Keymap.isModifier(evt, "Mod"); if (mod && evt.altKey && evt.shiftKey) return "window"; if (mod && evt.altKey) return "split"; if (mod || (evt instanceof MouseEvent && evt.button === 1)) return "tab"; return false; } } export const keymap = new Keymap(); export const pushScope = (scope: Scope): void => keymap.pushScope(scope); export const popScope = (scope: Scope): void => keymap.popScope(scope); // ============================================================================================= // 8. Files // ============================================================================================= export interface FileStats { ctime: number; mtime: number; size: number; } export abstract class TAbstractFile { vault: any = null; parent: TFolder | null = null; constructor(public path = "") {} get name(): string { return this.path.slice(this.path.lastIndexOf("/") + 1); } } export class TFile extends TAbstractFile { stat: FileStats = { ctime: 0, mtime: 0, size: 0 }; get extension(): string { const dot = this.name.lastIndexOf("."); return dot > 0 ? this.name.slice(dot + 1) : ""; } get basename(): string { const dot = this.name.lastIndexOf("."); return dot > 0 ? this.name.slice(0, dot) : this.name; } } export class TFolder extends TAbstractFile { children: TAbstractFile[] = []; isRoot(): boolean { return this.path === "" || this.path === "/"; } } /** What the views here need of Obsidian's App; the playground's host supplies it. */ export interface App { vault: any; workspace: any; metadataCache: any; fileManager: any; scope: Scope; [other: string]: any; } // ============================================================================================= // 9. Views // ============================================================================================= export class WorkspaceLeaf extends Events { view: View | null = null; constructor(readonly app: App) { super(); } } export interface ViewStateResult { history: boolean; } export abstract class View extends Component { app: App; leaf: WorkspaceLeaf; icon = ""; navigation = false; containerEl: HTMLElement; scope: Scope | null = null; constructor(leaf: WorkspaceLeaf) { super(); this.leaf = leaf; this.app = leaf.app; this.containerEl = createDiv({ cls: "workspace-leaf-content" }); // Obsidian tells a view when its pane changes size; here the pane is the element itself. if (typeof ResizeObserver === "function") { let queued = 0; new ResizeObserver(() => { if (queued) return; queued = window.requestAnimationFrame(() => { queued = 0; if (this._loaded) shielded(() => this.onResize()); }); }).observe(this.containerEl); } } protected async onOpen(): Promise {} protected async onClose(): Promise {} abstract getViewType(): string; abstract getDisplayText(): string; getIcon(): string { return this.icon; } getState(): Record { return {}; } async setState(_state: unknown, _result: ViewStateResult): Promise {} getEphemeralState(): Record { return {}; } setEphemeralState(_state: unknown): void {} onResize(): void {} onPaneMenu(_menu: Menu, _source: string): void {} } export abstract class ItemView extends View { contentEl: HTMLElement; constructor(leaf: WorkspaceLeaf) { super(leaf); this.contentEl = this.containerEl.createDiv({ cls: "view-content" }); } } export abstract class FileView extends ItemView { allowNoFile = false; file: TFile | null = null; navigation = true; getDisplayText(): string { return this.file?.basename ?? ""; } /** Show `file` here: the file before is unloaded first. */ async loadFile(file: TFile): Promise { if (this.file) await this.onUnloadFile(this.file); this.file = file; await this.onLoadFile(file); } async onLoadFile(_file: TFile): Promise {} async onUnloadFile(_file: TFile): Promise {} } export abstract class EditableFileView extends FileView {} /** A view over a text file: the text comes in with setViewData and goes back with save. */ export abstract class TextFileView extends EditableFileView { /** The text as last loaded or saved. */ data = ""; /** Saves two seconds after the first request of a burst (the timer does not restart). */ requestSave: () => void; constructor(leaf: WorkspaceLeaf) { super(leaf); const later = debounce(() => shielded(() => this.save()), 2000); this.requestSave = () => void later(); } async onLoadFile(file: TFile): Promise { const text = await this.app.vault.read(file); this.data = text; this.setViewData(text, true); } async onUnloadFile(_file: TFile): Promise { await this.save(true); } /** Write the view's text to its file (through the host's vault); `clear` then empties the view. */ async save(clear?: boolean): Promise { const file = this.file; if (!file) return; // Read before the first await: a subclass may hand over text only for this moment. const text = this.getViewData(); const changed = text !== this.data; this.data = text; if (changed) await this.app.vault.modify(file, text); if (clear) this.clear(); } abstract getViewData(): string; abstract setViewData(data: string, clear: boolean): void; abstract clear(): void; } // ============================================================================================= // 10. Notice // ============================================================================================= let noticeTray: HTMLElement | null = null; /** A short message in the corner; a tap dismisses it. */ export class Notice { containerEl: HTMLElement; messageEl: HTMLElement; /** @deprecated Obsidian's older name for messageEl. */ noticeEl: HTMLElement; private timer = 0; constructor(message: string | DocumentFragment, duration?: number) { if (!noticeTray || !noticeTray.isConnected) noticeTray = document.body.createDiv({ cls: "notice-container" }); this.containerEl = noticeTray.createDiv({ cls: "notice" }); this.messageEl = this.containerEl.createDiv({ cls: "notice-message" }); this.noticeEl = this.messageEl; this.setMessage(message); this.containerEl.addEventListener("click", () => this.hide()); const ms = duration ?? 5000; if (ms > 0) this.timer = window.setTimeout(() => this.hide(), ms); } setMessage(message: string | DocumentFragment): this { this.messageEl.setText(message); return this; } hide(): void { window.clearTimeout(this.timer); this.containerEl.detach(); } } // ============================================================================================= // 11. Modal, SuggestModal, FuzzySuggestModal // ============================================================================================= /** A dialog over the page, closed by Escape, its ✕, or a tap on the dimmed backdrop. */ export class Modal { app: App; scope: Scope; containerEl: HTMLElement; modalEl: HTMLElement; titleEl: HTMLElement; contentEl: HTMLElement; shouldRestoreSelection = false; protected headerEl: HTMLElement; protected closeButtonEl: HTMLElement; private isOpen = false; private afterClose: (() => unknown) | null = null; private focusBefore: Element | null = null; constructor(app: App) { this.app = app; this.scope = new Scope(); this.scope.register(null, "Escape", () => { this.close(); return false; }); this.containerEl = createDiv({ cls: "modal-container mod-dim" }); const backdrop = this.containerEl.createDiv({ cls: "modal-bg" }); backdrop.addEventListener("click", () => this.close()); this.modalEl = this.containerEl.createDiv({ cls: "modal", attr: { tabindex: -1 } }); this.closeButtonEl = this.modalEl.createDiv({ cls: "modal-close-button clickable-icon", attr: { "aria-label": "Close" } }); setIcon(this.closeButtonEl, "x"); this.closeButtonEl.addEventListener("click", () => this.close()); this.headerEl = this.modalEl.createDiv({ cls: "modal-header" }); this.titleEl = this.headerEl.createDiv({ cls: "modal-title" }); this.contentEl = this.modalEl.createDiv({ cls: "modal-content" }); } open(): void { if (this.isOpen) return; this.isOpen = true; this.focusBefore = document.activeElement; document.body.appendChild(this.containerEl); keymap.pushScope(this.scope); shielded(() => this.onOpen()); if (!this.modalEl.contains(document.activeElement)) this.modalEl.focus({ preventScroll: true }); } close(): void { if (!this.isOpen) return; this.isOpen = false; this.containerEl.detach(); keymap.popScope(this.scope); shielded(() => this.onClose()); const after = this.afterClose; if (after) shielded(after); if (this.shouldRestoreSelection && this.focusBefore instanceof HTMLElement && this.focusBefore.isConnected) this.focusBefore.focus({ preventScroll: true }); } onOpen(): Promise | void {} onClose(): void {} setTitle(title: string): this { this.titleEl.setText(title); return this; } setContent(content: string | DocumentFragment): this { this.contentEl.setText(content); return this; } setCloseCallback(callback: () => unknown): this { this.afterClose = callback; return this; } } export interface Instruction { command: string; purpose: string; } /** A search field over a list: type to narrow it, arrows and Enter (or a tap) to choose. */ export abstract class SuggestModal extends Modal { limit = 100; emptyStateText = "No results found."; inputEl: HTMLInputElement; resultContainerEl: HTMLElement; private readonly instructionsEl: HTMLElement; private rows: Array<{ value: T; el: HTMLElement }> = []; private highlighted = -1; /** Bumped per query, so a slow answer to an old query is dropped. */ private generation = 0; constructor(app: App) { super(app); this.modalEl.addClass("prompt"); // A list has no title bar or ✕: Escape and the backdrop close it. this.headerEl.detach(); this.closeButtonEl.detach(); const field = createDiv({ cls: "prompt-input-container" }); this.modalEl.insertBefore(field, this.contentEl); this.inputEl = field.createEl("input", { cls: "prompt-input", type: "text", attr: { enterkeyhint: "done", spellcheck: "false", autocomplete: "off" }, }); this.resultContainerEl = createDiv({ cls: "prompt-results" }); this.modalEl.insertBefore(this.resultContainerEl, this.contentEl); this.instructionsEl = this.modalEl.createDiv({ cls: "prompt-instructions" }); this.inputEl.addEventListener("input", () => this.refresh()); this.inputEl.addEventListener("keydown", (evt) => this.onFieldKey(evt)); } onOpen(): Promise | void { this.inputEl.value = ""; this.refresh(); this.inputEl.focus({ preventScroll: true }); } setPlaceholder(placeholder: string): void { this.inputEl.placeholder = placeholder; } setInstructions(instructions: Instruction[]): void { this.instructionsEl.empty(); for (const { command, purpose } of instructions) { const line = this.instructionsEl.createDiv({ cls: "prompt-instruction" }); line.createSpan({ cls: "prompt-instruction-command", text: command }); line.createSpan({ text: purpose }); } } onNoSuggestion(): void {} selectSuggestion(value: T, evt: MouseEvent | KeyboardEvent): void { this.close(); shielded(() => this.onChooseSuggestion(value, evt)); } selectActiveSuggestion(evt: MouseEvent | KeyboardEvent): void { const row = this.rows[this.highlighted]; if (row) this.selectSuggestion(row.value, evt); } abstract getSuggestions(query: string): T[] | Promise; abstract renderSuggestion(value: T, el: HTMLElement): void; abstract onChooseSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void; private refresh(): void { const ticket = ++this.generation; const answer = shielded(() => this.getSuggestions(this.inputEl.value)); if (answer && typeof (answer as Promise).then === "function") { (answer as Promise).then((list) => { if (ticket === this.generation) this.showRows(list ?? []); }, onFailure); } else { this.showRows((answer as T[] | undefined) ?? []); } } private showRows(list: T[]): void { this.resultContainerEl.empty(); this.rows = []; this.highlighted = -1; const shown = this.limit > 0 ? list.slice(0, this.limit) : list; if (shown.length === 0) { if (this.emptyStateText) this.resultContainerEl.createDiv({ cls: "suggestion-empty", text: this.emptyStateText }); shielded(() => this.onNoSuggestion()); return; } shown.forEach((value, index) => { const el = this.resultContainerEl.createDiv({ cls: "suggestion-item" }); shielded(() => this.renderSuggestion(value, el)); el.addEventListener("mousemove", () => this.highlight(index, false)); el.addEventListener("click", (evt) => this.selectSuggestion(value, evt)); this.rows.push({ value, el }); }); this.highlight(0, false); } private highlight(index: number, reveal: boolean): void { if (index === this.highlighted || this.rows.length === 0) return; this.rows[this.highlighted]?.el.removeClass("is-selected"); this.highlighted = (index + this.rows.length) % this.rows.length; const row = this.rows[this.highlighted]; row.el.addClass("is-selected"); if (reveal) row.el.scrollIntoView({ block: "nearest" }); } private onFieldKey(evt: KeyboardEvent): void { if (evt.isComposing) return; if (evt.key === "ArrowDown" || evt.key === "ArrowUp") { evt.preventDefault(); this.highlight(this.highlighted + (evt.key === "ArrowDown" ? 1 : -1), true); } else if (evt.key === "Enter") { evt.preventDefault(); this.selectActiveSuggestion(evt); } } } export interface FuzzyMatch { item: T; match: { score: number; matches: Array<[number, number]> }; } /** * Where `query`'s letters appear, in order, in `text` (spaces in the query * are ignored), as [start, end) runs; null when they do not all appear. * Fewer, earlier and longer runs score higher. */ function lettersInOrder(query: string, text: string): FuzzyMatch["match"] | null { const wanted = query.toLowerCase().replace(/\s+/g, ""); if (!wanted) return { score: 0, matches: [] }; const hay = text.toLowerCase(); const runs: Array<[number, number]> = []; let from = 0; let skipped = 0; for (const ch of wanted) { const at = hay.indexOf(ch, from); if (at < 0) return null; const run = runs.last(); if (run && run[1] === at) run[1] = at + 1; else runs.push([at, at + 1]); skipped += at - from; from = at + 1; } return { score: -(runs.length * 10 + skipped + runs[0][0] / 10), matches: runs }; } export abstract class FuzzySuggestModal extends SuggestModal> { getSuggestions(query: string): Array> { const found: Array> = []; for (const item of this.getItems()) { const match = lettersInOrder(query, this.getItemText(item)); if (match) found.push({ item, match }); } if (query.trim()) found.sort((a, b) => b.match.score - a.match.score); return found; } renderSuggestion(result: FuzzyMatch, el: HTMLElement): void { const text = this.getItemText(result.item); let at = 0; for (const [start, end] of result.match.matches) { if (start > at) el.appendText(text.slice(at, start)); el.createSpan({ cls: "suggestion-highlight", text: text.slice(start, end) }); at = end; } if (at < text.length) el.appendText(text.slice(at)); } onChooseSuggestion(result: FuzzyMatch, evt: MouseEvent | KeyboardEvent): void { this.onChooseItem(result.item, evt); } abstract getItems(): T[]; abstract getItemText(item: T): string; abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; } // ============================================================================================= // 12. Menu // ============================================================================================= export interface MenuPositionDef { x: number; y: number; width?: number; overlap?: boolean; left?: boolean; } /** One line of a menu. */ export class MenuItem { readonly dom: HTMLElement; private readonly iconEl: HTMLElement; private readonly titleEl: HTMLElement; private checkEl: HTMLElement | null = null; /** The section this item sorts into ("" for none). */ section = ""; private disabled = false; private action: ((evt: MouseEvent | KeyboardEvent) => unknown) | null = null; constructor(private readonly owner: Menu) { this.dom = createDiv({ cls: "menu-item tappable" }); this.iconEl = this.dom.createDiv({ cls: "menu-item-icon" }); this.titleEl = this.dom.createDiv({ cls: "menu-item-title" }); this.dom.addEventListener("click", (evt) => this.activate(evt)); } setTitle(title: string | DocumentFragment): this { this.titleEl.setText(title); return this; } setIcon(icon: string | null): this { this.iconEl.empty(); if (icon) setIcon(this.iconEl, icon); return this; } setChecked(checked: boolean | null): this { this.dom.toggleClass("mod-checked", checked === true); if (checked === true && !this.checkEl) { this.checkEl = this.dom.createDiv({ cls: "menu-item-check" }); setIcon(this.checkEl, "check"); } else if (checked !== true && this.checkEl) { this.checkEl.detach(); this.checkEl = null; } return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.dom.toggleClass("is-disabled", disabled); this.dom.setAttr("aria-disabled", disabled ? "true" : null); return this; } setWarning(isWarning: boolean): this { this.dom.toggleClass("is-warning", isWarning); return this; } setIsLabel(isLabel: boolean): this { this.dom.toggleClass("is-label", isLabel); return this; } setSection(section: string): this { this.section = section; this.dom.dataset.section = section; return this; } onClick(callback: (evt: MouseEvent | KeyboardEvent) => unknown): this { this.action = callback; return this; } private activate(evt: MouseEvent): void { evt.preventDefault(); if (this.disabled || this.dom.hasClass("is-label")) return; const action = this.action; this.owner.hide(); if (action) shielded(() => action(evt)); } } const SEPARATOR = "separator" as const; let openMenu: Menu | null = null; /** A pop-up list of actions, closed by a choice, Escape, or a tap anywhere else. */ export class Menu extends Component { readonly dom: HTMLElement; private readonly entries: Array = []; private readonly scope = new Scope(); private readonly hiddenCallbacks: Array<() => unknown> = []; private parentEl: HTMLElement | null = null; private shown = false; private readonly outsidePress = (evt: Event): void => { if (!this.dom.contains(evt.target as Node)) this.hide(); }; private readonly dismiss = (): void => void this.hide(); constructor() { super(); this.dom = createDiv({ cls: "menu", attr: { role: "menu" } }); this.dom.addEventListener("contextmenu", (evt) => evt.preventDefault()); this.scope.register(null, "Escape", () => { this.hide(); return false; }); } setNoIcon(): this { this.dom.addClass("mod-no-icon"); return this; } setUseNativeMenu(_useNativeMenu: boolean): this { return this; } setParentElement(el: HTMLElement): this { this.parentEl = el; return this; } addItem(cb: (item: MenuItem) => unknown): this { const item = new MenuItem(this); cb(item); this.entries.push(item); return this; } addSeparator(): this { this.entries.push(SEPARATOR); return this; } onHide(callback: () => unknown): void { this.hiddenCallbacks.push(callback); } showAtMouseEvent(evt: MouseEvent): this { let { clientX: x, clientY: y } = evt; if (!x && !y && evt.target instanceof Element) { const box = evt.target.getBoundingClientRect(); x = box.left; y = box.bottom; } return this.showAtPosition({ x, y }); } showAtPosition(position: MenuPositionDef, doc: Document = document): this { openMenu?.hide(); this.layOut(); (this.parentEl ?? doc.body).appendChild(this.dom); this.place(position, doc); this.shown = true; openMenu = this; keymap.pushScope(this.scope); this.load(); // Listen from the next task on, so the press that opened the menu does not close it. window.setTimeout(() => { if (!this.shown) return; doc.addEventListener("pointerdown", this.outsidePress, true); window.addEventListener("resize", this.dismiss); window.addEventListener("blur", this.dismiss); }, 0); return this; } hide(): this { if (!this.shown) return this; this.shown = false; if (openMenu === this) openMenu = null; this.dom.detach(); keymap.popScope(this.scope); this.dom.ownerDocument.removeEventListener("pointerdown", this.outsidePress, true); window.removeEventListener("resize", this.dismiss); window.removeEventListener("blur", this.dismiss); this.unload(); for (const cb of this.hiddenCallbacks) shielded(cb); return this; } close(): void { this.hide(); } static forEvent(_evt: PointerEvent | MouseEvent): Menu { return new Menu(); } /** * Items with a section are gathered by section, in the order each section * first appears; a separator follows the item before it. A line divides * sections, and no line is doubled or left at either end. */ private layOut(): void { this.dom.empty(); const order = new Map(); let section = ""; const placed = this.entries.map((entry, index) => { if (entry !== SEPARATOR) section = entry.section; if (!order.has(section)) order.set(section, order.size); return { entry, index, rank: order.get(section) ?? 0 }; }); placed.sort((a, b) => a.rank - b.rank || a.index - b.index); let lastRank = -1; let lineDue = false; let any = false; for (const { entry, rank } of placed) { if (entry === SEPARATOR) { lineDue = any; continue; } if ((lineDue || (any && rank !== lastRank)) && any) this.dom.createDiv({ cls: "menu-separator" }); lineDue = false; this.dom.appendChild(entry.dom); any = true; lastRank = rank; } } /** At (x, y), kept inside the window: flipped above or left when it would run off. */ private place(position: MenuPositionDef, doc: Document): void { const view = doc.defaultView ?? window; const margin = 4; const style = this.dom.style; style.left = "0px"; style.top = "0px"; const { width, height } = this.dom.getBoundingClientRect(); const roomW = view.innerWidth; const roomH = view.innerHeight; let left = position.left ? position.x - width : position.x; if (left + width > roomW - margin) left = position.left ? margin : position.x - width; left = Math.min(Math.max(margin, left), Math.max(margin, roomW - width - margin)); let top = position.y; if (top + height > roomH - margin) top = position.y - height >= margin ? position.y - height : roomH - height - margin; top = Math.max(margin, top); style.left = `${Math.round(left)}px`; style.top = `${Math.round(top)}px`; } } // ============================================================================================= // 13. Setting and its controls // ============================================================================================= type Changed = (value: T) => unknown; /** A text field (input or textarea) that reports every edit. */ abstract class TextLikeComponent { disabled = false; constructor(readonly inputEl: E) {} getValue(): string { return this.inputEl.value; } setValue(value: string): this { this.inputEl.value = value; return this; } setPlaceholder(placeholder: string): this { this.inputEl.placeholder = placeholder; return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.inputEl.disabled = disabled; return this; } onChanged(): void {} onChange(callback: Changed): this { this.inputEl.addEventListener("input", () => shielded(() => callback(this.inputEl.value))); return this; } then(cb: (component: this) => unknown): this { cb(this); return this; } } export class TextComponent extends TextLikeComponent { constructor(containerEl: HTMLElement) { super(containerEl.createEl("input", { type: "text", attr: { spellcheck: "false" } })); } } export class TextAreaComponent extends TextLikeComponent { constructor(containerEl: HTMLElement) { super(containerEl.createEl("textarea")); } } export class ToggleComponent { readonly toggleEl: HTMLElement; private on = false; disabled = false; private listeners: Array> = []; constructor(containerEl: HTMLElement) { this.toggleEl = containerEl.createDiv({ cls: "checkbox-container", attr: { role: "switch", tabindex: 0 } }); this.toggleEl.createEl("input", { type: "checkbox", attr: { tabindex: -1 } }); const flip = (): void => { if (this.disabled) return; this.setValue(!this.on); for (const cb of this.listeners) shielded(() => cb(this.on)); }; this.toggleEl.addEventListener("click", flip); this.toggleEl.addEventListener("keydown", (evt) => { if (evt.key !== " " && evt.key !== "Enter") return; evt.preventDefault(); flip(); }); } getValue(): boolean { return this.on; } setValue(on: boolean): this { this.on = on; this.toggleEl.toggleClass("is-enabled", on); this.toggleEl.setAttr("aria-checked", String(on)); return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.toggleEl.toggleClass("is-disabled", disabled); return this; } setTooltip(tooltip: string): this { this.toggleEl.setAttr("aria-label", tooltip); return this; } onChange(callback: Changed): this { this.listeners.push(callback); return this; } } export class DropdownComponent { readonly selectEl: HTMLSelectElement; disabled = false; constructor(containerEl: HTMLElement) { this.selectEl = containerEl.createEl("select", { cls: "dropdown" }); } addOption(value: string, display: string): this { this.selectEl.createEl("option", { value, text: display }); return this; } addOptions(options: Record): this { for (const [value, display] of Object.entries(options)) this.addOption(value, display); return this; } getValue(): string { return this.selectEl.value; } setValue(value: string): this { this.selectEl.value = value; return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.selectEl.disabled = disabled; return this; } onChange(callback: Changed): this { this.selectEl.addEventListener("change", () => shielded(() => callback(this.selectEl.value))); return this; } } export class ButtonComponent { readonly buttonEl: HTMLButtonElement; disabled = false; constructor(containerEl: HTMLElement) { this.buttonEl = containerEl.createEl("button", { type: "button" }); } setButtonText(text: string): this { this.buttonEl.setText(text); return this; } setIcon(icon: string): this { this.buttonEl.empty(); setIcon(this.buttonEl, icon); return this; } setCta(): this { this.buttonEl.addClass("mod-cta"); return this; } removeCta(): this { this.buttonEl.removeClass("mod-cta"); return this; } setWarning(): this { this.buttonEl.addClass("mod-warning"); return this; } setClass(cls: string): this { this.buttonEl.addClass(cls); return this; } setTooltip(tooltip: string): this { this.buttonEl.setAttr("aria-label", tooltip); this.buttonEl.title = tooltip; return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.buttonEl.disabled = disabled; return this; } onClick(callback: (evt: MouseEvent) => unknown): this { this.buttonEl.addEventListener("click", (evt) => shielded(() => callback(evt))); return this; } } export class ExtraButtonComponent { readonly extraSettingsEl: HTMLElement; disabled = false; constructor(containerEl: HTMLElement) { this.extraSettingsEl = containerEl.createDiv({ cls: "clickable-icon extra-setting-button", attr: { tabindex: 0 } }); setIcon(this.extraSettingsEl, "settings"); } setIcon(icon: string): this { this.extraSettingsEl.empty(); setIcon(this.extraSettingsEl, icon); return this; } setTooltip(tooltip: string): this { this.extraSettingsEl.setAttr("aria-label", tooltip); return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.extraSettingsEl.toggleClass("is-disabled", disabled); return this; } onClick(callback: () => unknown): this { this.extraSettingsEl.addEventListener("click", () => { if (!this.disabled) shielded(callback); }); return this; } } export class SliderComponent { readonly sliderEl: HTMLInputElement; disabled = false; private instant = false; constructor(containerEl: HTMLElement) { this.sliderEl = containerEl.createEl("input", { type: "range", cls: "slider" }); } setLimits(min: number | null, max: number | null, step: number | "any"): this { if (min !== null) this.sliderEl.min = String(min); if (max !== null) this.sliderEl.max = String(max); this.sliderEl.step = String(step); return this; } getValue(): number { return Number(this.sliderEl.value); } setValue(value: number): this { this.sliderEl.value = String(value); return this; } setInstant(instant: boolean): this { this.instant = instant; return this; } setDynamicTooltip(): this { return this; } setDisabled(disabled: boolean): this { this.disabled = disabled; this.sliderEl.disabled = disabled; return this; } onChange(callback: Changed): this { const report = (): void => void shielded(() => callback(this.getValue())); this.sliderEl.addEventListener("input", () => this.instant && report()); this.sliderEl.addEventListener("change", () => !this.instant && report()); return this; } } /** One row of a settings list: a name and description on the left, its controls on the right. */ export class Setting { readonly settingEl: HTMLElement; readonly infoEl: HTMLElement; readonly nameEl: HTMLElement; readonly descEl: HTMLElement; readonly controlEl: HTMLElement; constructor(containerEl: HTMLElement) { this.settingEl = containerEl.createDiv({ cls: "setting-item" }); this.infoEl = this.settingEl.createDiv({ cls: "setting-item-info" }); this.nameEl = this.infoEl.createDiv({ cls: "setting-item-name" }); this.descEl = this.infoEl.createDiv({ cls: "setting-item-description" }); this.controlEl = this.settingEl.createDiv({ cls: "setting-item-control" }); } setName(name: string | DocumentFragment): this { this.nameEl.setText(name); return this; } setDesc(desc: string | DocumentFragment): this { this.descEl.setText(desc); return this; } setClass(cls: string): this { this.settingEl.addClass(cls); return this; } setHeading(): this { this.settingEl.addClass("setting-item-heading"); return this; } setTooltip(tooltip: string): this { this.nameEl.setAttr("aria-label", tooltip); return this; } setDisabled(disabled: boolean): this { this.settingEl.toggleClass("is-disabled", disabled); return this; } // Each control is made in the row's control area and handed to `cb` to set up. addText(cb: (text: TextComponent) => unknown): this { cb(new TextComponent(this.controlEl)); return this; } addTextArea(cb: (text: TextAreaComponent) => unknown): this { cb(new TextAreaComponent(this.controlEl)); return this; } addToggle(cb: (toggle: ToggleComponent) => unknown): this { cb(new ToggleComponent(this.controlEl)); return this; } addDropdown(cb: (dropdown: DropdownComponent) => unknown): this { cb(new DropdownComponent(this.controlEl)); return this; } addButton(cb: (button: ButtonComponent) => unknown): this { cb(new ButtonComponent(this.controlEl)); return this; } addExtraButton(cb: (button: ExtraButtonComponent) => unknown): this { cb(new ExtraButtonComponent(this.controlEl)); return this; } addSlider(cb: (slider: SliderComponent) => unknown): this { cb(new SliderComponent(this.controlEl)); return this; } then(cb: (setting: this) => unknown): this { cb(this); return this; } clear(): this { this.controlEl.empty(); return this; } } /** A plugin's settings page; the playground opens none, so this only holds the shape. */ export abstract class PluginSettingTab { readonly containerEl: HTMLElement = createDiv({ cls: "vertical-tab-content" }); constructor( readonly app: App, readonly plugin: unknown, ) {} abstract display(): void; hide(): void { this.containerEl.empty(); } } // ============================================================================================= // 14. MarkdownRenderer // ============================================================================================= /** Markdown shown as plain paragraphs: enough for the few places a demo shows any. */ export class MarkdownRenderer { static render( _app: App, markdown: string, el: HTMLElement, _sourcePath: string, _component: Component, ): Promise { for (const block of markdown.split(/\n\s*\n/)) { const text = block.trim(); if (!text) continue; const p = el.createEl("p"); text.split("\n").forEach((line, i) => { if (i > 0) p.createEl("br"); p.appendText(line); }); } return Promise.resolve(); } static renderMarkdown(markdown: string, el: HTMLElement, sourcePath: string, component: Component): Promise { return MarkdownRenderer.render({} as App, markdown, el, sourcePath, component); } }