Files
jiang13-bbs/frontend/lib/widgetRuntime.ts
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

180 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自定义侧栏工具的 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();
},
};
}