新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
// 自定义侧栏工具的 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();
|
||
},
|
||
};
|
||
}
|