From 1a4ae417feb5dd25289379eacca08be5fcb52805 Mon Sep 17 00:00:00 2001 From: hyper Date: Thu, 25 Jun 2026 20:26:34 +0800 Subject: [PATCH] feat: Add multi-source font and style manager Rename md-emfont to md-font with support for Google, emfont, and local sources. Introduce article-style-manager.ts for centralized lifecycle management of article-level styles and external stylesheet link injection with reference counting. Refactor md-bg to use the new manager, removing direct createEffect on article styles. --- src/components/index.ts | 4 +- src/components/md-bg.tsx | 40 ++-- src/components/md-emfont.tsx | 53 ----- src/components/md-font.tsx | 63 ++++++ src/components/utils/article-style-manager.ts | 181 ++++++++++++++++++ 5 files changed, 264 insertions(+), 77 deletions(-) delete mode 100644 src/components/md-emfont.tsx create mode 100644 src/components/md-font.tsx create mode 100644 src/components/utils/article-style-manager.ts diff --git a/src/components/index.ts b/src/components/index.ts index 9719c58..11b8faf 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,7 +4,7 @@ import "./md-table"; import "./md-link"; import "./md-pins"; import "./md-bg"; -import "./md-emfont"; +import "./md-font"; import "./md-deck"; import "./md-commander/index"; import "./md-yarn-spinner"; @@ -22,7 +22,7 @@ export { FileTreeNode, HeadingNode } from "./FileTree"; export type { DiceProps } from "./md-dice"; export type { TableProps } from "./md-table"; export type { BgProps } from "./md-bg"; -export type { EmfontProps } from "./md-emfont"; +export type { FontProps } from "./md-font"; export type { YarnSpinnerProps } from "./md-yarn-spinner"; export type { TokenProps } from "./md-token"; diff --git a/src/components/md-bg.tsx b/src/components/md-bg.tsx index 707393c..d501dbf 100644 --- a/src/components/md-bg.tsx +++ b/src/components/md-bg.tsx @@ -1,43 +1,39 @@ import { customElement, noShadowDOM } from "solid-element"; -import {createEffect, onCleanup} from "solid-js"; +import { onCleanup } from "solid-js"; import { resolvePath } from "./utils/path"; +import { registerStyle } from "./utils/article-style-manager"; export interface BgProps { - fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'; + fit?: "cover" | "contain" | "fill" | "none" | "scale-down"; } customElement("md-bg", { fit: "cover" }, (props, { element }) => { noShadowDOM(); - // 从 element 的 textContent 获取图片路径 - const rawSrc = element?.textContent?.trim() || ''; + const rawSrc = element?.textContent?.trim() || ""; - // 隐藏原始文本内容 if (element) { element.textContent = ""; } - // 从父节点 article 的 data-src 获取当前 markdown 文件完整路径 - const articleEl = element?.closest('article') as HTMLElement; - const articlePath = element?.closest('article[data-src]')?.getAttribute('data-src') || ''; + const articleEl = element?.closest("article") as HTMLElement; + const articlePath = + element?.closest("article[data-src]")?.getAttribute("data-src") || ""; - // 解析相对路径 const resolvedSrc = resolvePath(articlePath, rawSrc); - createEffect(() => { - if(!articleEl) return; - articleEl.dataset.mdbg = resolvedSrc; - articleEl.style.backgroundImage = `url(${resolvedSrc})`; - articleEl.style.backgroundSize = props.fit || 'cover'; - articleEl.style.backgroundPosition = 'center'; - articleEl.style.backgroundRepeat = 'no-repeat'; - }); - - onCleanup(() => { - if(articleEl?.dataset?.mdbg !== resolvedSrc) - return; - articleEl.style.backgroundImage = ''; + const handle = registerStyle({ + key: "backgroundImage", + article: articleEl, + styles: { + backgroundImage: `url(${resolvedSrc})`, + backgroundSize: props.fit || "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } as any, }); + onCleanup(() => handle.dispose()); + return null; }); diff --git a/src/components/md-emfont.tsx b/src/components/md-emfont.tsx deleted file mode 100644 index 649326b..0000000 --- a/src/components/md-emfont.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { customElement, noShadowDOM } from "solid-element"; -import { createEffect, onCleanup } from "solid-js"; - -export interface EmfontProps { - weight?: string; -} - -customElement("md-emfont", { weight: "400" }, (props, { element }) => { - noShadowDOM(); - - // 从 element 的 textContent 获取字体名称 - const font = element?.textContent?.trim() || ""; - - // 隐藏原始文本内容 - if (element) { - element.textContent = ""; - } - - // 从父节点 article 获取当前 article 元素 - const articleEl = element?.closest("article") as HTMLElement; - - // 构建 emfont CSS URL - const weight = props.weight || "400"; - const linkHref = `https://font.emtech.cc/css/${font}?weight=${weight}`; - - // 避免重复加载:检查是否已有相同字体的 - let linkEl = document.head.querySelector( - `link[data-md-emfont="${font}"]`, - ) as HTMLLinkElement | null; - - if (!linkEl) { - linkEl = document.createElement("link"); - linkEl.rel = "stylesheet"; - linkEl.href = linkHref; - linkEl.dataset.mdEmfont = font; - document.head.appendChild(linkEl); - } - - createEffect(() => { - if (!articleEl) return; - articleEl.dataset.mdEmfont = font; - articleEl.style.fontFamily = font; - }); - - onCleanup(() => { - // 清理 article 上的样式 - if (articleEl?.dataset?.mdEmfont === font) { - delete articleEl.dataset.mdEmFont; - } - }); - - return null; -}); diff --git a/src/components/md-font.tsx b/src/components/md-font.tsx new file mode 100644 index 0000000..ac558e1 --- /dev/null +++ b/src/components/md-font.tsx @@ -0,0 +1,63 @@ +import { customElement, noShadowDOM } from "solid-element"; +import { onCleanup } from "solid-js"; +import { registerStyle } from "./utils/article-style-manager"; + +export interface FontProps { + /** Font source: "google" (Google Fonts), "emfont" (emtech.cc), + * or "local" (already loaded, just set fontFamily) */ + source?: "google" | "emfont" | "local"; + /** Font weight (for google and emfont sources) */ + weight?: string; +} + +function buildLinkHref( + font: string, + source: FontProps["source"], + weight: string, +): string | null { + if (source === "google") { + const family = font.replace(/\s+/g, "+"); + return `https://fonts.googleapis.com/css2?family=${family}:wght@${weight}&display=swap`; + } + if (source === "emfont") { + return `https://font.emtech.cc/css/${font}?weight=${weight}`; + } + return null; +} + +customElement( + "md-font", + { source: "local", weight: "400" }, + (props, { element }) => { + noShadowDOM(); + + const font = element?.textContent?.trim() || ""; + + // Hide original text content + if (element) { + element.textContent = ""; + } + + const articleEl = element?.closest("article") as HTMLElement; + if (!articleEl) return null; + + const source = (props.source as FontProps["source"]) || "google"; + const weight = props.weight || "400"; + + const href = buildLinkHref(font, source, weight); + const links = href + ? [{ href, dataset: { mdFontSource: source, mdFontName: font } }] + : []; + + const handle = registerStyle({ + key: "fontFamily", + article: articleEl, + styles: { fontFamily: `"${font}", sans-serif` }, + links, + }); + + onCleanup(() => handle.dispose()); + + return null; + }, +); diff --git a/src/components/utils/article-style-manager.ts b/src/components/utils/article-style-manager.ts new file mode 100644 index 0000000..121c1b4 --- /dev/null +++ b/src/components/utils/article-style-manager.ts @@ -0,0 +1,181 @@ +/** + * ArticleStyleManager — centralized lifecycle manager for article-level + * style mutations (fontFamily, backgroundImage, etc.) and external + * stylesheet injection with reference counting. + * + * Each md-* component registers a StyleRegistration and gets back a + * StyleHandle. On disposal, only that component's contribution is + * removed; if a previous registration for the same key is still active, + * its styles are restored. + */ + +type StyleKey = string; + +interface StyleRegistration { + /** Unique namespace key (e.g. "fontFamily", "backgroundImage") */ + key: StyleKey; + /** Target article element */ + article: HTMLElement; + /** CSS properties to apply to the article */ + styles: Partial; + /** + * External stylesheets to inject into . + * Reference-counted: removed when no registrations reference it. + */ + links?: Array<{ href: string; dataset: Record }>; + /** Called when this registration becomes active */ + onActivate?: () => void; + /** Called when this registration is disposed */ + onDeactivate?: () => void; +} + +interface StyleHandle { + dispose(): void; +} + +/** Per-key stack of registrations for a single article */ +interface ArticleRegistry { + article: HTMLElement; + stacks: Map; +} + +const articleRegistries = new Map(); +/** href -> number of active registrations referencing it */ +const linkRefCounts = new Map(); +/** href -> element */ +const linkElements = new Map(); + +function getRegistry(article: HTMLElement): ArticleRegistry { + let reg = articleRegistries.get(article); + if (!reg) { + reg = { article, stacks: new Map() }; + articleRegistries.set(article, reg); + } + return reg; +} + +function applyStyles(article: HTMLElement, styles: Partial) { + for (const [prop, value] of Object.entries(styles)) { + if (value !== undefined && value !== null) { + (article.style as any)[prop] = value; + } + } +} + +function clearStyles(article: HTMLElement, styles: Partial) { + for (const prop of Object.keys(styles)) { + (article.style as any)[prop] = ""; + } +} + +function incrementLinkRef(href: string, dataset: Record) { + const count = linkRefCounts.get(href) || 0; + if (count === 0) { + const linkEl = document.createElement("link"); + linkEl.rel = "stylesheet"; + linkEl.href = href; + for (const [key, val] of Object.entries(dataset)) { + linkEl.dataset[key] = val; + } + document.head.appendChild(linkEl); + linkElements.set(href, linkEl); + } + linkRefCounts.set(href, count + 1); +} + +function decrementLinkRef(href: string) { + const count = linkRefCounts.get(href); + if (count === undefined) return; + if (count <= 1) { + const linkEl = linkElements.get(href); + linkEl?.remove(); + linkRefCounts.delete(href); + linkElements.delete(href); + } else { + linkRefCounts.set(href, count - 1); + } +} + +// Expose debug info on articles +function updateDebugData(registry: ArticleRegistry) { + const activeKeys: string[] = []; + for (const [key, stack] of registry.stacks) { + if (stack.length > 0) { + activeKeys.push(key); + } + } + if (activeKeys.length > 0) { + registry.article.dataset.mdStyles = activeKeys.join(","); + } else { + delete registry.article.dataset.mdStyles; + if (registry.stacks.size === 0) { + articleRegistries.delete(registry.article); + } + } +} + +export function registerStyle(reg: StyleRegistration): StyleHandle { + const registry = getRegistry(reg.article); + let stack = registry.stacks.get(reg.key); + if (!stack) { + stack = []; + registry.stacks.set(reg.key, stack); + } + + stack.push(reg); + + // Increment link refcounts + for (const link of reg.links || []) { + incrementLinkRef(link.href, link.dataset); + } + + // If this is the active (top) registration, apply its styles + if (stack[stack.length - 1] === reg) { + applyStyles(reg.article, reg.styles); + reg.onActivate?.(); + } + + updateDebugData(registry); + + let disposed = false; + + return { + dispose() { + if (disposed) return; + disposed = true; + + // Remove this registration from its stack + const currentStack = registry.stacks.get(reg.key); + if (!currentStack) return; + + const idx = currentStack.lastIndexOf(reg); + if (idx === -1) return; + + const wasActive = idx === currentStack.length - 1; + currentStack.splice(idx, 1); + + if (currentStack.length === 0) { + registry.stacks.delete(reg.key); + } + + // If this was the active registration, restore previous or clear + if (wasActive) { + clearStyles(reg.article, reg.styles); + const prev = currentStack.length > 0 ? currentStack[currentStack.length - 1] : null; + if (prev) { + applyStyles(reg.article, prev.styles); + prev.onActivate?.(); + } + } + + reg.onDeactivate?.(); + + // Decrement link refcounts + for (const link of reg.links || []) { + decrementLinkRef(link.href); + } + + updateDebugData(registry); + }, + }; +}