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.
This commit is contained in:
hyper
2026-06-25 20:26:34 +08:00
parent 771fe6a112
commit 1a4ae417fe
5 changed files with 264 additions and 77 deletions
@@ -0,0 +1,181 @@
/**
* ArticleStyleManager — centralized lifecycle manager for article-level
* style mutations (fontFamily, backgroundImage, etc.) and external
* stylesheet <link> 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<CSSStyleDeclaration>;
/**
* External <link> stylesheets to inject into <head>.
* Reference-counted: removed when no registrations reference it.
*/
links?: Array<{ href: string; dataset: Record<string, string> }>;
/** 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<StyleKey, StyleRegistration[]>;
}
const articleRegistries = new Map<HTMLElement, ArticleRegistry>();
/** href -> number of active registrations referencing it */
const linkRefCounts = new Map<string, number>();
/** href -> <link> element */
const linkElements = new Map<string, HTMLLinkElement>();
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<CSSStyleDeclaration>) {
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<CSSStyleDeclaration>) {
for (const prop of Object.keys(styles)) {
(article.style as any)[prop] = "";
}
}
function incrementLinkRef(href: string, dataset: Record<string, string>) {
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);
},
};
}