mirror of
https://github.com/4513echo/obsidian-embed-plus.git
synced 2026-07-22 06:55:40 +00:00
refactor: Prefer function to static method
This commit is contained in:
parent
f29bf5f6dd
commit
db08008580
6 changed files with 114 additions and 144 deletions
|
|
@ -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<EmbedSourceStatic> = new Set();
|
||||
static #instances: Map<string, EmbedSource> = 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<EmbedSourceStatic> = new Set();
|
||||
const instances: Map<string, EmbedSource> = 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 } =>
|
||||
|
|
|
|||
107
src/extension.ts
107
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<string, number> = new Map();
|
||||
#widgets: Map<string, EmbedWidget> = new Map();
|
||||
type WidgetRegistry = { pos: Map<string, number>; widgets: Map<string, EmbedWidget> };
|
||||
|
||||
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<unknown>[]): 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<T>(a: IteratorObject<T>, b: IteratorObject<T>): boolean {
|
||||
|
|
@ -69,6 +27,14 @@ function compareIter<T>(a: IteratorObject<T>, b: IteratorObject<T>): 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<WidgetRegistry>({
|
||||
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));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
async function resolveHandle(handle: string): Promise<string> {
|
||||
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<string> {
|
|||
return `${EMBED_URL}/embed/${did}/app.bsky.feed.post/${post}`;
|
||||
}
|
||||
|
||||
export class Bluesky extends EmbedSource {
|
||||
export default class Bluesky extends EmbedSource {
|
||||
static #heightCache: Map<string, number> = new Map();
|
||||
static id = 0;
|
||||
#id = (Bluesky.id++).toString();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue