refactor: Prefer function to static method

This commit is contained in:
4513ECHO 2026-05-31 20:05:00 +09:00
parent f29bf5f6dd
commit db08008580
No known key found for this signature in database
GPG key ID: 3A5BDFC6E5C365DD
6 changed files with 114 additions and 144 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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