diff --git a/src/embed_source.ts b/src/embed_source.ts index 92dcf76..3bc9e03 100644 --- a/src/embed_source.ts +++ b/src/embed_source.ts @@ -30,63 +30,55 @@ export abstract class EmbedSource { return false; } loaded(): void { - EmbedSourceRegistry.dispatchLoaded(this.url); + eventTarget.dispatchEvent(new CustomEvent("loaded", { detail: { url: this.url } })); } } -export class EmbedSourceRegistry { - static #sources: Set = new Set(); - static #instances: Map = new Map(); - static #eventTarget = new EventTarget(); - static { - this.#eventTarget.addEventListener("loaded", (event) => { - if (!isLoadedEvent(event)) { - return; - } - const { url } = event.detail; - for (const view of retriveViews(url)) { - loaded(view, url); - } - for (const dom of retriveReadingViewDoms(url)) { - dom.dispatchEvent(new Event("embed-plus:loaded")); - } - }); +const sources: Set = new Set(); +const instances: Map = new Map(); +const eventTarget = new EventTarget(); +eventTarget.addEventListener("loaded", (event) => { + if (!isLoadedEvent(event)) { + return; } - - static register(sources: readonly EmbedSourceStatic[]): void { - for (const source of sources) { - this.#sources.add(source); - } + const { url } = event.detail; + for (const view of retriveViews(url)) { + loaded(view, url); } + for (const dom of retriveReadingViewDoms(url)) { + dom.dispatchEvent(new Event("embed-plus:loaded")); + } +}); - static lookup(url: string): EmbedSource | null { - const instance = this.#instances.get(url); - if (instance) { +export function register(newSources: readonly EmbedSourceStatic[]): void { + for (const source of newSources) { + sources.add(source); + } +} + +export function lookup(url: string): EmbedSource | null { + const instance = instances.get(url); + if (instance) { + return instance; + } + const origin = new URL(url).origin; + for (const source of sources) { + if (source.meta.origin === origin) { + // TODO: proper src check + const instance = new source(url); + instances.set(url, instance); return instance; } - const origin = new URL(url).origin; - for (const source of this.#sources) { - if (source.meta.origin === origin) { - // TODO: proper src check - const instance = new source(url); - this.#instances.set(url, instance); - return instance; - } - } - return null; } + return null; +} - static handleMessage(event: MessageEvent): void { - for (const instance of this.#instances.values()) { - if (instance.onMessage(event)) { - break; - } +export function handleMessage(event: MessageEvent): void { + for (const instance of instances.values()) { + if (instance.onMessage(event)) { + break; } } - - static dispatchLoaded(url: string): void { - this.#eventTarget.dispatchEvent(new CustomEvent("loaded", { detail: { url } })); - } } const isDetail = (detail: unknown): detail is { url: string } => diff --git a/src/extension.ts b/src/extension.ts index e6ff19e..c390233 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,66 +1,24 @@ import { syntaxTree } from "@codemirror/language"; -import { EditorState, StateEffect, StateField } from "@codemirror/state"; +import { EditorState, StateField } from "@codemirror/state"; import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; import { constructWidget } from "./effect.ts"; -import { EmbedSourceRegistry } from "./embed_source.ts"; +import { lookup } from "./embed_source.ts"; import { EmbedWidget } from "./widget.ts"; -class WidgetRegistry { - #pos: Map = new Map(); - #widgets: Map = new Map(); +type WidgetRegistry = { pos: Map; widgets: Map }; - static compare(a: WidgetRegistry, b: WidgetRegistry): boolean { - return ( - compareIter(a.#pos.keys(), b.#pos.keys()) && - compareIter(a.#widgets.keys(), b.#widgets.keys()) && - a.#widgets.entries().every(([url, widget]) => { - const other = b.#widgets.get(url); - return other && widget.eq(other); - }) - ); - } - - cloned(): WidgetRegistry { - const cloned = new WidgetRegistry(); - cloned.#pos = new Map(this.#pos); - cloned.#widgets = new Map(this.#widgets); - return cloned; - } - - gather(state: EditorState): void { - this.#pos.clear(); - for (const { pos, url } of gatherUrlPos(state)) { - if (!EmbedSourceRegistry.lookup(url)) { - continue; - } - this.#pos.set(url, pos); - if (this.#widgets.has(url)) { - continue; - } - const widget = new EmbedWidget({ state: "resolving", url }); - this.#widgets.set(url, widget); - } - } - - handleEffect(effects: readonly StateEffect[]): void { - for (const [url, widget] of constructWidget(effects, EmbedWidget)) { - this.#widgets.set(url, widget); - } - } - - toDecorations(): DecorationSet { - const decorations = this.#pos - .entries() - .map(([url, pos]) => - Decoration.widget({ - widget: this.#widgets.get(url)!, - side: 1, - block: true, - }).range(pos), - ) - .toArray(); - return Decoration.set(decorations); - } +function toDecorations(registry: WidgetRegistry): DecorationSet { + const decorations = registry.pos + .entries() + .map(([url, pos]) => + Decoration.widget({ + widget: registry.widgets.get(url)!, + side: 1, + block: true, + }).range(pos), + ) + .toArray(); + return Decoration.set(decorations); } function compareIter(a: IteratorObject, b: IteratorObject): boolean { @@ -69,6 +27,14 @@ function compareIter(a: IteratorObject, b: IteratorObject): boolean { return aSet.size === bSet.size && aSet.isSubsetOf(bSet) && bSet.isSubsetOf(aSet); } +function compare(a: WidgetRegistry, b: WidgetRegistry): boolean { + return ( + compareIter(a.pos.keys(), b.pos.keys()) && + compareIter(a.widgets.keys(), b.widgets.keys()) && + a.widgets.entries().every(([url, widget]) => b.widgets.get(url)?.eq(widget)) + ); +} + function gatherUrlPos(state: EditorState): { pos: number; url: string }[] { const result: { pos: number; url: string }[] = []; @@ -98,19 +64,34 @@ function gatherUrlPos(state: EditorState): { pos: number; url: string }[] { const widgetField = StateField.define({ create() { - return new WidgetRegistry(); + return { pos: new Map(), widgets: new Map() }; }, update(oldValue, transaction) { - const value = oldValue.cloned(); - value.handleEffect(transaction.effects); - value.gather(transaction.state); + const value = { + pos: new Map(oldValue.pos), + widgets: new Map(oldValue.widgets), + }; + for (const [url, widget] of constructWidget(transaction.effects, EmbedWidget)) { + value.widgets.set(url, widget); + } + value.pos.clear(); + for (const { pos, url } of gatherUrlPos(transaction.state)) { + if (!lookup(url)) { + continue; + } + value.pos.set(url, pos); + if (!value.widgets.has(url)) { + const widget = new EmbedWidget({ state: "resolving", url }); + value.widgets.set(url, widget); + } + } return value; }, compare(a, b) { - return WidgetRegistry.compare(a, b); + return compare(a, b); }, provide(field) { - return EditorView.decorations.from(field, (value) => value.toDecorations()); + return EditorView.decorations.from(field, (value) => toDecorations(value)); }, }); diff --git a/src/main.ts b/src/main.ts index a3ab206..44062c1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,12 @@ import { Plugin } from "obsidian"; -import { EmbedSourceRegistry } from "./embed_source.ts"; +import { register, handleMessage } from "./embed_source.ts"; import { extensions } from "./extension.ts"; -import { Bluesky } from "./source/bluesky.ts"; import { createElement } from "./widget.ts"; import "./styles.css"; export default class extends Plugin { override onload() { - EmbedSourceRegistry.register([Bluesky]); + register(Object.values(import.meta.glob("./source/*.ts", { eager: true, import: "default" }))); this.registerMarkdownPostProcessor(async (element, _context) => { const embeds = element.querySelectorAll("img[src^='https://']"); @@ -18,8 +17,6 @@ export default class extends Plugin { this.registerEditorExtension(extensions); - this.registerDomEvent(window, "message", (event) => { - EmbedSourceRegistry.handleMessage(event); - }); + this.registerDomEvent(window, "message", (event) => handleMessage(event)); } } diff --git a/src/source/bluesky.ts b/src/source/bluesky.ts index cf32cc9..61529c6 100644 --- a/src/source/bluesky.ts +++ b/src/source/bluesky.ts @@ -8,14 +8,16 @@ const urlPattern = new URLPattern({ baseURL: "https://bsky.app", }); +const API_URL = "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle"; const didCache = new Map(); async function resolveHandle(handle: string): Promise { if (didCache.has(handle)) { return didCache.get(handle)!; } - const url = new URL("/xrpc/com.atproto.identity.resolveHandle", "https://api.bsky.app"); - url.searchParams.set("handle", handle); - const payload: unknown = await requestUrl({ url: url.toString(), throw: false }).json; + const payload: unknown = await requestUrl({ + url: API_URL + "?" + new URLSearchParams({ handle }).toString(), + throw: false, + }).json; if ( typeof payload === "object" && payload && @@ -45,7 +47,7 @@ async function resolveEmbedSrc(url: string): Promise { return `${EMBED_URL}/embed/${did}/app.bsky.feed.post/${post}`; } -export class Bluesky extends EmbedSource { +export default class Bluesky extends EmbedSource { static #heightCache: Map = new Map(); static id = 0; #id = (Bluesky.id++).toString(); diff --git a/src/widget.ts b/src/widget.ts index 1dd375c..9aa97c2 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -1,10 +1,10 @@ import { EditorView, WidgetType } from "@codemirror/view"; import { setIcon } from "obsidian"; import { type WidgetInit, resolved, failed } from "./effect.ts"; -import { type EmbedSource, EmbedSourceRegistry } from "./embed_source.ts"; +import { type EmbedSource, lookup } from "./embed_source.ts"; -function Loading(height?: number): HTMLElement { - const loadingEl = createDiv({ cls: "loading-embed" }); +function Loading(parent: HTMLElement, height?: number): HTMLElement { + const loadingEl = parent.createDiv({ cls: "loading-embed" }); if (height) { loadingEl.style.height = `${height}px`; } @@ -13,8 +13,8 @@ function Loading(height?: number): HTMLElement { return loadingEl; } -function ErrorMessage(error: Error): HTMLElement { - const errorEl = createDiv({ cls: "error-embed" }); +function ErrorMessage(parent: HTMLElement, error: Error): HTMLElement { + const errorEl = parent.createDiv({ cls: "error-embed" }); setIcon(errorEl.createDiv({ cls: "icon-wrapper" }), "circle-x"); errorEl.createEl("p", { text: error.toString() }); return errorEl; @@ -36,12 +36,10 @@ export class EmbedWidget extends WidgetType { super(); this.#url = init.url; this.#state = init.state; - switch (init.state) { - case "failed": - this.#error = init.error; - break; + if (init.state === "failed") { + this.#error = init.error; } - const embedSourceClass = EmbedSourceRegistry.lookup(init.url); + const embedSourceClass = lookup(init.url); if (embedSourceClass) { this.#embedSource = embedSourceClass; } else { @@ -53,7 +51,7 @@ export class EmbedWidget extends WidgetType { const container = Container(this.#url, this.#state); switch (this.#state) { case "resolving": { - container.appendChild(Loading(this.#embedSource.height)); + Loading(container, this.#embedSource.height); const needResolve = this.#embedSource.resolveSrc(); if (needResolve instanceof Promise) { needResolve @@ -72,28 +70,28 @@ export class EmbedWidget extends WidgetType { break; } case "resolved": - container.appendChild(Loading(this.#embedSource.height)); + Loading(container, this.#embedSource.height); container.appendChild(this.#embedSource.render()); break; case "loaded": container.appendChild(this.#embedSource.render()); break; case "failed": - container.appendChild(ErrorMessage(this.#error!)); + ErrorMessage(container, this.#error!); break; } return container; } - eq(other: this): boolean { + override eq(other: this): boolean { return this.#url === other.#url && this.#state === other.#state; } - get estimatedHeight(): number { + override get estimatedHeight(): number { return this.#embedSource.height ?? 150; } - updateDOM(dom: HTMLElement): boolean { + override updateDOM(dom: HTMLElement): boolean { const prevUrl = dom.getAttribute("data-url"); if (!prevUrl || this.#url !== prevUrl) { return false; @@ -115,20 +113,19 @@ export class EmbedWidget extends WidgetType { } case "failed": dom.querySelector(".loading-embed")?.remove(); - dom.appendChild(ErrorMessage(this.#error!)); + ErrorMessage(dom, this.#error!); return true; } } } export function createElement(url: string, dom: HTMLElement): void { - const embedSource = EmbedSourceRegistry.lookup(url); + const embedSource = lookup(url); if (!embedSource) { return; } const container = Container(url, "resolving"); - const loading = Loading(embedSource.height); - container.appendChild(loading); + const loading = Loading(container, embedSource.height); container.addEventListener("embed-plus:loaded", () => { container.setAttribute("data-state", "loaded"); const iframe = container.querySelector("iframe"); @@ -146,7 +143,7 @@ export function createElement(url: string, dom: HTMLElement): void { }) .catch((error) => { container.setAttribute("data-state", "failed"); - container.appendChild(ErrorMessage(error as Error)); + ErrorMessage(container, error as Error); loading.remove(); }); } else { diff --git a/tsconfig.json b/tsconfig.json index fe467e0..f6bd338 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "es2025", "module": "esnext", "lib": ["es2025", "dom"], - "types": ["vite/client", "node"], + "types": ["node", "vite/client"], "skipLibCheck": true, /* Bundler mode */ @@ -15,11 +15,12 @@ /* Linting */ "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true } }