// 自定义侧栏工具的 Shadow DOM 运行时(仅浏览器端,由 CustomWidgetBlock 的 effect 调用): // - 每个工具实例独占一个 shadow root:用户 CSS 注入 shadow 内,天然不外泄; // - 用户 HTML 挂在 .tool-root 下,用户 JS 以函数形式手动执行, // 通过参数拿到 (root, shadow, toolId, registerCleanup),DOM 查找一律从 root 出发; // - mount / unmount 幂等:同一 host 复用 shadow root、先清空再注入, // 兼容 React StrictMode 双调用、依赖变更重挂与同页多个同款工具实例。 export interface WidgetSource { id: number; html: string; css: string; js: string; } /** 用户脚本通过 registerCleanup 注册的卸载回调(停定时器、移除监听等) */ export type WidgetCleanup = () => void; /** 传给用户脚本的运行时上下文 */ export interface WidgetRuntimeContext { /** 工具根节点:所有 querySelector / getElementById 都应从这里出发 */ root: HTMLElement; shadow: ShadowRoot; toolId: number; registerCleanup: (fn: WidgetCleanup) => void; } export interface WidgetHandle { unmount: () => void; } // shadow 边界内没有 Tailwind preflight,这里补一份最小 reset: // 只做「盒模型一致 + 外边距归零 + 字体颜色继承」,视觉仍完全交给用户 CSS。 // CSS 变量(var(--accent) 等)沿 DOM 继承,可自动穿过 shadow 边界,无需在此声明。 const BASE_CSS = ` :host { display: block; min-width: 0; } .tool-root { overflow: hidden; min-width: 0; } .tool-root, .tool-root *, .tool-root *::before, .tool-root *::after { box-sizing: border-box; } .tool-root blockquote, .tool-root dl, .tool-root dd, .tool-root h1, .tool-root h2, .tool-root h3, .tool-root h4, .tool-root h5, .tool-root h6, .tool-root hr, .tool-root figure, .tool-root p, .tool-root pre { margin: 0; } .tool-root button, .tool-root input, .tool-root select, .tool-root textarea { font: inherit; letter-spacing: inherit; color: inherit; } .tool-root button { background: transparent; border: 0; padding: 0; cursor: pointer; } .tool-root img, .tool-root svg, .tool-root video, .tool-root canvas { display: block; max-width: 100%; } `.trim(); function reportError(toolId: number, phase: string, err: unknown): void { // 用户代码出错不静默:统一带工具 id 前缀,便于在控制台定位 console.error(`[自定义工具#${toolId}] ${phase}失败:`, err); } /** * 把工具挂载到 host:attachShadow → 注入基础/用户样式 → 放 .tool-root → 执行用户 JS。 * 返回的 unmount 幂等,重复调用只有第一次生效。 */ export function mountWidget(host: HTMLElement, source: WidgetSource): WidgetHandle { const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); // 幂等重挂(StrictMode / 依赖变更):清掉上一次的全部内容后重建 shadow.replaceChildren(); const baseStyle = document.createElement("style"); baseStyle.dataset.widgetBase = ""; baseStyle.textContent = BASE_CSS; shadow.appendChild(baseStyle); if (source.css.trim()) { const userStyle = document.createElement("style"); userStyle.dataset.widgetStyle = String(source.id); // shadow 内天然隔离,用户 CSS 原样注入、不再加选择器前缀 userStyle.textContent = source.css; shadow.appendChild(userStyle); } const root = document.createElement("div"); root.className = "tool-root"; root.dataset.tool = String(source.id); if (source.html.trim()) root.innerHTML = source.html; shadow.appendChild(root); const cleanups: WidgetCleanup[] = []; let disposed = false; const context: WidgetRuntimeContext = { root, shadow, toolId: source.id, registerCleanup(fn) { if (disposed) { // 卸载后注册没有意义,直接丢弃并提示,避免用户误以为还会执行 try { fn(); } catch (err) { reportError(source.id, "卸载后注册的 cleanup 执行", err); } return; } if (typeof fn === "function") cleanups.push(fn); }, }; if (source.js.trim()) { try { // 站长可信内容:函数构造器手动执行,参数即工具运行时上下文 const fn = new Function( "root", "shadow", "toolId", "registerCleanup", `"use strict";\n${source.js}` ) as ( root: HTMLElement, shadow: ShadowRoot, toolId: number, registerCleanup: WidgetRuntimeContext["registerCleanup"] ) => unknown; fn(context.root, context.shadow, context.toolId, context.registerCleanup); } catch (err) { reportError(source.id, "脚本执行", err); } } return { unmount() { if (disposed) return; disposed = true; // 后注册的先执行,符合多数资源(监听/定时器)的释放直觉 for (let i = cleanups.length - 1; i >= 0; i--) { try { cleanups[i](); } catch (err) { reportError(source.id, "cleanup 执行", err); } } cleanups.length = 0; shadow.replaceChildren(); }, }; }