diff --git a/examples/ai_chat_stream.html b/examples/ai_chat_stream.html index 1ecb54b4f..d561a468d 100644 --- a/examples/ai_chat_stream.html +++ b/examples/ai_chat_stream.html @@ -137,58 +137,135 @@ cursor: pointer; } + /* 插件选项 - 胶囊样式 */ .plugin-options { width: 100%; max-width: var(--ai-max-width); - margin: 0 auto 16px; - padding: 16px; - background: var(--ai-surface); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin: 0 auto 12px; + padding: 0; + background: transparent; + box-shadow: none; box-sizing: border-box; } .plugin-options h3 { - margin: 0 0 12px; - font-size: 14px; - color: #666; + display: none; } .plugin-list { display: flex; - gap: 20px; + gap: 8px; flex-wrap: wrap; + align-items: center; + } + + .plugin-sep { + width: 1px; + height: 20px; + background: var(--ai-muted); + margin: 0 4px; + } + + .plugin-group-label { + font-size: 12px; + color: #999; + margin-right: 2px; + } + + .plugin-or { + font-size: 11px; + color: #bbb; + font-style: italic; } .plugin-item { - display: flex; - align-items: center; - gap: 8px; + position: relative; + cursor: pointer; + user-select: none; } .plugin-item input { - width: 18px; - height: 18px; - cursor: pointer; + position: absolute; + opacity: 0; + width: 0; + height: 0; } .plugin-item label { - cursor: pointer; - font-size: 14px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + border: 1px solid var(--ai-muted); + background: var(--ai-surface); + color: #666; + transition: all 0.2s ease; + white-space: nowrap; } - .plugin-item .plugin-status { - font-size: 12px; - color: #999; - margin-left: 4px; + .plugin-item input:checked+label { + background: #e6f0ff; + border-color: var(--ai-accent); + color: var(--ai-accent); } - .plugin-item .plugin-status.loaded { - color: #52c41a; + .plugin-item input:disabled+label, + .plugin-item input[disabled]+label { + opacity: 0.5; + pointer-events: none; + } + + .plugin-item .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: transparent; + transition: background 0.3s ease; + flex-shrink: 0; } - .plugin-item .plugin-status.loading { - color: #faad14; + .plugin-item .status-dot.loading { + background: #faad14; + animation: pulse-dot 1s infinite; + } + + .plugin-item .status-dot.loaded { + background: #52c41a; + } + + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + + /* 消息选择器 */ + .msg-picker { + width: 100%; + max-width: var(--ai-max-width); + margin: 0 auto 12px; + box-sizing: border-box; + } + + .msg-picker-label { + font-size: 13px; + color: #666; + margin-bottom: 8px; + } + + .msg-picker-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .j-msg-pick-btn:disabled, + .j-msg-pick-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; } .custom-input { @@ -230,8 +307,12 @@ } .plugin-list { - flex-direction: column; - gap: 12px; + gap: 6px; + } + + .plugin-item label { + font-size: 12px; + padding: 5px 10px; } } @@ -243,39 +324,33 @@
-

🔌 插件懒加载选项(勾选后懒加载对应插件)

- - +
+ +
数学公式(二选一)
- - - + +
+
or
- - +
-
- - - + - \ No newline at end of file + diff --git a/examples/assets/scripts/ai-chat-stream-demo.js b/examples/assets/scripts/ai-chat-stream-demo.js index 23f3670dc..7aeeefd3b 100644 --- a/examples/assets/scripts/ai-chat-stream-demo.js +++ b/examples/assets/scripts/ai-chat-stream-demo.js @@ -1,34 +1,76 @@ -// 插件配置 -const pluginConfig = { +/** + * AI Chat Stream Demo - 插件懒加载与启用/禁用管理 + * + * 关键机制: + * - 数学公式:未勾选时 engine/src/css 置空,initMath() 直接跳过;默认 MathJax 需显式覆盖 + * - Mermaid:通过 customRenderer 注入(不用 usePlugin 避免污染全局静态配置), + * wrapperRender 替换 class 阻止 MutationObserver 自动渲染 + * - 流式打印:每条消息创建独立 Cherry 实例,setMarkdown() 逐字更新 + */ + +// ============================================================================ +// 插件 CDN 配置(纯静态,不含运行时状态) +// ============================================================================ +const PLUGIN_CDN = { mermaid: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js', pluginSrc: '../packages/cherry-markdown/dist/addons/cherry-code-block-mermaid-plugin.js', }, katex: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', css: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', }, mathjax: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js', }, }; -// 示例消息列表 +/** 插件运行时状态 */ +const pluginState = { + mermaid: { loaded: false, loading: false }, + katex: { loaded: false, loading: false }, + mathjax: { loaded: false, loading: false }, +}; + +/** KaTeX ↔ MathJax 互斥映射 */ +const MUTUAL_EXCLUSION = { katex: 'mathjax', mathjax: 'katex' }; + +// ============================================================================ +// 示例消息 +// ============================================================================ const msgList = [ - '### 概述\n通过以下方式打开Cherry Markdown的流式渲染能力:\n```javascript\nconst cherry = new Cherry({\n editor: {\n height: "auto",\n defaultModel: "previewOnly",\n },\n engine: {\n global: {\n flowSessionContext: true,\n flowSessionCursor: "default",\n },\n },\n});\n```\n', - '### 数学公式示例\n\n#### 行内公式\n质能方程:$E = mc^2$\n\n#### 块级公式\n高斯公式:\n$$\\oint_S \\vec{F} \\cdot d\\vec{A} = \\int_V (\\nabla \\cdot \\vec{F}) dV$$\n\n二次方程根:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n', - '### Mermaid 流程图示例\n\n```mermaid\ngraph TD\n A[开始] --> B{是否加载插件?}\n B -->|是| C[懒加载插件]\n B -->|否| D[使用默认渲染]\n C --> E[渲染内容]\n D --> E\n E --> F[结束]\n```\n\n#### 时序图\n\n```mermaid\nsequenceDiagram\n participant 用户\n participant Cherry\n participant 插件\n 用户->>Cherry: setMarkdown()\n Cherry->>插件: 检查是否需要渲染\n 插件-->>Cherry: 返回渲染结果\n Cherry-->>用户: 显示内容\n```\n', - '### 综合示例\n\n#### 代码块\n```python\ndef fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)\n\nprint(fibonacci(10)) # 输出: 55\n```\n\n#### 表格\n| 插件 | 用途 | 大小 |\n|:----:|:-----|-----:|\n| Mermaid | 流程图、时序图 | ~2MB |\n| KaTeX | 数学公式(快) | ~300KB |\n| MathJax | 数学公式(全) | ~3MB |\n\n#### 数学公式\n欧拉公式:$e^{i\\pi} + 1 = 0$\n', + { + title: '概述:流式渲染配置', + content: + '### 概述\n通过以下方式打开Cherry Markdown的流式渲染能力:\n```javascript\nconst cherry = new Cherry({\n editor: {\n height: "auto",\n defaultModel: "previewOnly",\n },\n engine: {\n global: {\n flowSessionContext: true,\n flowSessionCursor: "default",\n },\n },\n});\n```\n', + }, + { + title: '数学公式', + content: + '### 数学公式示例\n\n#### 行内公式\n质能方程:$E = mc^2$\n\n#### 块级公式\n高斯公式:\n$$\\oint_S \\vec{F} \\cdot d\\vec{A} = \\int_V (\\nabla \\cdot \\vec{F}) dV$$\n\n二次方程根:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n', + }, + { + title: 'Mermaid 流程图', + content: + '### Mermaid 流程图示例\n\n```mermaid\ngraph TD\n A[开始] --> B{是否加载插件?}\n B -->|是| C[懒加载插件]\n B -->|否| D[使用默认渲染]\n C --> E[渲染内容]\n D --> E\n E --> F[结束]\n```\n\n#### 时序图\n\n```mermaid\nsequenceDiagram\n participant 用户\n participant Cherry\n participant 插件\n 用户->>Cherry: setMarkdown()\n Cherry->>插件: 检查是否需要渲染\n 插件-->>Cherry: 返回渲染结果\n Cherry-->>用户: 显示内容\n```\n', + }, + { + title: '代码块 + 表格 + 公式', + content: + '### 综合示例\n\n#### 代码块\n```python\ndef fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)\n\nprint(fibonacci(10)) # 输出: 55\n```\n\n#### 表格\n| 插件 | 用途 | 大小 |\n|:----:|:-----|-----:|\n| Mermaid | 流程图、时序图 | ~2MB |\n| KaTeX | 数学公式(快) | ~300KB |\n| MathJax | 数学公式(全) | ~3MB |\n\n#### 数学公式\n欧拉公式:$e^{i\\pi} + 1 = 0$\n', + }, + { + title: '表格图表(ECharts)', + content: + '## 表格图表示例\n\n### 折线图\n| :line:{"title": "折线图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n\n### 柱状图\n| :bar:{"title": "柱状图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n\n### 热力图\n| :heatmap:{"title": "热力图"} | 周一 | 周二 | 周三 | 周四 | 周五 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 上午 | 10 | 20 | 30 | 40 | 50 |\n| 下午 | 15 | 25 | 35 | 45 | 55 |\n| 晚上 | 5 | 15 | 25 | 35 | 45 |\n\n### 饼图\n| :pie:{"title": "饼图"} | 数值 |\n| ------ | ------ |\n| 苹果 | 40 |\n| 香蕉 | 30 |\n| 橙子 | 20 |\n| 葡萄 | 10 |\n\n### 雷达图\n| :radar:{"title": "雷达图"} | 技能1 | 技能2 | 技能3 | 技能4 | 技能5 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 用户A | 90 | 85 | 75 | 80 | 88 |\n| 用户B | 75 | 90 | 88 | 85 | 78 |\n| 用户C | 85 | 78 | 90 | 88 | 85 |\n\n### 散点图\n| :scatter:{"title": "数据散点图"} | 横坐标 | 纵坐标 | 大小 | 系列 |\n| ------ | ------ | ------ | ------ | ------ |\n| A1 | 10 | 20 | 5 | 系列一 |\n| A2 | 15 | 25 | 10 | 系列一 |\n| A3 | 18 | 22 | 8 | 系列一 |\n| A4 | 22 | 28 | 12 | 系列一 |\n| A5 | 25 | 35 | 15 | 系列一 |\n| B1 | 12 | 18 | 8 | 系列二 |\n| B2 | 20 | 30 | 12 | 系列二 |\n| B3 | 28 | 25 | 10 | 系列二 |\n| B4 | 35 | 38 | 14 | 系列二 |\n| B5 | 40 | 45 | 16 | 系列二 |\n\n### 桑基图\n| :sankey:{"title": "能源流向图"} | 目标 | 数值 |\n| ------ | ------ | ------ |\n| 煤炭 | 发电 | 300 |\n| 天然气 | 发电 | 200 |\n| 石油 | 交通 | 250 |\n| 水力 | 发电 | 150 |\n| 发电 | 工业 | 400 |\n| 发电 | 居民 | 250 |\n| 交通 | 货运 | 150 |\n| 交通 | 客运 | 100 |\n\n### 地图\n| :map:{"title": "中国地图"} | 数值 |\n| :-: | :-: |\n| 北京 | 100 |\n| 上海 | 200 |\n| 广东 | 300 |\n| 四川 | 150 |\n| 江苏 | 250 |\n| 浙江 | 180 |\n', + }, ]; -// 加载脚本 -function loadScript(src, id) { +// ============================================================================ +// 工具函数 +// ============================================================================ + +function loadScript(src, id, { module = false } = {}) { return new Promise((resolve, reject) => { if (document.getElementById(id)) { resolve(); @@ -37,16 +79,13 @@ function loadScript(src, id) { const script = document.createElement('script'); script.id = id; script.src = src; - script.onload = () => { - // 等待一小段时间确保脚本执行完毕并挂载到 window - setTimeout(resolve, 100); - }; + if (module) script.type = 'module'; + script.onload = () => setTimeout(resolve, 100); script.onerror = reject; document.head.appendChild(script); }); } -// 加载样式 function loadCSS(href, id) { return new Promise((resolve) => { if (document.getElementById(id)) { @@ -62,80 +101,134 @@ function loadCSS(href, id) { }); } -// 更新插件状态显示 function updatePluginStatus(plugin, status) { - const statusEl = document.querySelector(`.j-plugin-status[data-plugin="${plugin}"]`); - if (statusEl) { - statusEl.className = `plugin-status j-plugin-status ${status}`; - switch (status) { - case 'loading': - statusEl.textContent = '(加载中...)'; - break; - case 'loaded': - statusEl.textContent = '(已加载)'; - break; - default: - statusEl.textContent = ''; - } + const dot = document.querySelector(`.j-status-dot-${plugin}`); + if (dot) { + dot.className = `status-dot j-plugin-status j-status-dot-${plugin}${status ? ` ${status}` : ''}`; } } -// 懒加载插件 +function isChecked(plugin) { + return document.getElementById(`plugin-${plugin}`)?.checked ?? false; +} + +// ============================================================================ +// 插件加载 / 卸载 +// ============================================================================ + +/** + * 懒加载插件脚本到 DOM。 + * 不调用 Cherry.usePlugin(),避免污染全局默认配置(静态属性,注册后无法撤销)。 + */ async function loadPlugin(plugin) { - const config = pluginConfig[plugin]; - if (config.loaded || config.loading) return; + const state = pluginState[plugin]; + const cdn = PLUGIN_CDN[plugin]; + if (state.loaded || state.loading) return; - config.loading = true; + state.loading = true; updatePluginStatus(plugin, 'loading'); try { - if (config.css) { - await loadCSS(config.css, `${plugin}-css`); - } - await loadScript(config.src, `${plugin}-js`); - - // mermaid 需要额外加载插件脚本 - if (plugin === 'mermaid' && config.pluginSrc) { - await loadScript(config.pluginSrc, `${plugin}-plugin-js`); - } - - // 特殊初始化 - if (plugin === 'mermaid' && window.mermaid && window.CherryCodeBlockMermaidPlugin) { - // 使用 usePlugin 注册 mermaid 插件 - Cherry.usePlugin(window.CherryCodeBlockMermaidPlugin, { - mermaid: window.mermaid, - mermaidAPI: window.mermaid, - }); + if (cdn.css) await loadCSS(cdn.css, `${plugin}-css`); + await loadScript(cdn.src, `${plugin}-js`); + + if (plugin === 'mermaid') { + // 禁用 mermaid 自动渲染 + if (window.mermaid) window.mermaid.initialize({ startOnLoad: false }); + // 加载 Cherry 适配插件(module 模式,Vite dev server 重定向到 ES module) + if (cdn.pluginSrc) { + await loadScript(cdn.pluginSrc, `${plugin}-plugin-js`, { module: true }); + // module script onload 不保证模块已执行,轮询等待 + await new Promise((resolve) => { + const check = () => (window.CherryCodeBlockMermaidPlugin ? resolve() : setTimeout(check, 50)); + check(); + }); + } } - config.loaded = true; - config.loading = false; + state.loaded = true; + state.loading = false; updatePluginStatus(plugin, 'loaded'); - console.log(`[Plugin] ${plugin} 加载完成`); } catch (e) { - config.loading = false; + state.loading = false; updatePluginStatus(plugin, ''); console.error(`[Plugin] ${plugin} 加载失败:`, e); } } -// 获取当前 Cherry 配置 -function getCherryConfig() { - const useMermaid = document.getElementById('plugin-mermaid').checked; - const useKatex = document.getElementById('plugin-katex').checked; - const useMathJax = document.getElementById('plugin-mathjax').checked; +/** + * 卸载插件。mermaid 不删除脚本(MutationObserver 不可撤销), + * 靠不传 customRenderer + wrapperRender 替换 class 阻止渲染。 + * katex/mathjax 删除 DOM 标签并清理 window 引用。 + */ +function unloadPlugin(plugin) { + const state = pluginState[plugin]; + state.loaded = false; + state.loading = false; + + if (plugin === 'mermaid') { + if (window.mermaid) window.mermaid.initialize({ startOnLoad: false }); + } else { + document.getElementById(`${plugin}-js`)?.remove(); + document.getElementById(`${plugin}-css`)?.remove(); + if (plugin === 'katex') delete window.katex; + if (plugin === 'mathjax') delete window.MathJax; + } - // 数学引擎配置 - let mathEngine = 'katex'; - let mathSrc = pluginConfig.katex.src; - let mathCss = pluginConfig.katex.css; + updatePluginStatus(plugin, ''); +} + +// ============================================================================ +// Cherry 配置生成 +// ============================================================================ - if (useMathJax && !useKatex) { +/** + * 根据当前 checkbox + 加载状态生成 Cherry 配置。 + * 未勾选则 engine/src/css 置空跳过加载;mermaid 通过 customRenderer 注入,不用 usePlugin; + * wrapperRender 始终设置,替换 class 防止 MutationObserver 自动渲染。 + */ +function getCherryConfig() { + // ---- 数学引擎 ---- + let mathEngine = ''; + let mathSrc = ''; + let mathCss = ''; + + if (isChecked('katex') && pluginState.katex.loaded) { + mathEngine = 'katex'; + mathSrc = PLUGIN_CDN.katex.src; + mathCss = PLUGIN_CDN.katex.css || ''; + } else if (isChecked('mathjax') && pluginState.mathjax.loaded) { mathEngine = 'MathJax'; - mathSrc = pluginConfig.mathjax.src; - mathCss = ''; + mathSrc = PLUGIN_CDN.mathjax.src; } + // ---- Mermaid ---- + const mermaidReady = + isChecked('mermaid') && pluginState.mermaid.loaded && window.CherryCodeBlockMermaidPlugin && window.mermaid; + + const codeBlockCfg = { + selfClosing: false, + mermaid: { showSourceToolbar: true }, + }; + + if (mermaidReady) { + codeBlockCfg.customRenderer = { + mermaid: new window.CherryCodeBlockMermaidPlugin({ + mermaid: window.mermaid, + mermaidAPI: window.mermaid, + }), + }; + } + + // 始终设置 wrapperRender:替换 class 阻止 mermaid MutationObserver 自动渲染,同时作为启用时的 fallback + codeBlockCfg.wrapperRender = (language, _code, innerHTML) => { + if (language === 'mermaid') { + return innerHTML.replace(/language-mermaid/g, 'language-mermaid-disabled'); + } + return innerHTML; + }; + + // Cherry 默认 mathBlock.engine='MathJax',必须显式置空 return { editor: { height: 'auto', @@ -147,48 +240,31 @@ function getCherryConfig() { flowSessionCursor: 'default', }, syntax: { - codeBlock: { - selfClosing: false, - mermaid: { - showSourceToolbar: true, - }, - }, + codeBlock: codeBlockCfg, + table: { enableChart: true, selfClosing: false }, inlineCode: { selfClosing: false }, header: { anchorStyle: 'none', selfClosing: false }, - table: { selfClosing: false }, fontEmphasis: { selfClosing: false }, link: { selfClosing: false }, image: { selfClosing: false }, - mathBlock: { - selfClosing: false, - engine: mathEngine, - src: mathSrc, - css: mathCss, - }, - inlineMath: { - selfClosing: false, - engine: mathEngine, - }, + mathBlock: { selfClosing: false, engine: mathEngine, src: mathSrc, css: mathCss }, + inlineMath: { selfClosing: false, engine: mathEngine, src: '' }, }, }, externals: { - // mermaid 通过 usePlugin 方式注册,不需要在这里配置 - }, - previewer: { - enablePreviewerBubble: true, + echarts: window.echarts, }, + previewer: { enablePreviewerBubble: true }, }; } -/** - * AI Chat Stream 场景初始化 - */ +// ============================================================================ +// 场景初始化 +// ============================================================================ export function aiChatStreamScenario() { - // 初始化 DOM 元素 const dialog = document.querySelector('.j-dialog'); const msgTemplate = document.querySelector('.j-one-msg'); - const button = document.querySelector('.j-button'); - const buttonTips = document.querySelector('.j-button-tips'); + const msgPickerList = document.querySelector('.j-msg-picker-list'); const pauseBtn = document.querySelector('.j-pause-button'); const customTextarea = document.querySelector('.j-custom-textarea'); const customButton = document.querySelector('.j-custom-button'); @@ -196,51 +272,112 @@ export function aiChatStreamScenario() { let currentCherry = null; let printing = false; let paused = false; - let currentMsgIndex = msgList.length; let currentWordIndex = 0; let interval = 30; - buttonTips.innerHTML = currentMsgIndex; + /** 打印期间禁用/恢复交互控件 */ + function setControlsDisabled(disabled) { + document.querySelectorAll('.j-msg-pick-btn, .j-custom-button').forEach((el) => { + el.disabled = disabled; + }); + customTextarea.disabled = disabled; + } + + // 渲染消息选择按钮 + msgList.forEach((item, index) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button secondary j-msg-pick-btn'; + btn.dataset.index = index; + btn.textContent = item.title; + msgPickerList.appendChild(btn); + }); - // 流式打印函数 + /** 确保所有已勾选的插件加载完成 */ + async function ensureCheckedPluginsLoaded() { + const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); + for (const cb of checkboxes) { + await loadPlugin(cb.dataset.plugin); + } + } + + /** 流式打印 */ function beginPrint(msg) { printing = true; + currentWordIndex = 0; + setControlsDisabled(true); + + // 销毁上一个实例 + if (currentCherry && typeof currentCherry.destroy === 'function') { + currentCherry.destroy(); + currentCherry = null; + } + + const msgEl = msgTemplate.cloneNode(true); + msgEl.classList.remove('j-one-msg'); + + const config = getCherryConfig(); + config.el = msgEl.querySelector('.chat-one-msg'); + currentCherry = new Cherry(config); + dialog.appendChild(msgEl); + msgEl.scrollIntoView({ behavior: 'smooth', block: 'end' }); + function step() { + try { + dialog.scrollTop = dialog.scrollHeight; + } catch (e) { + /* noop */ + } if (paused) { setTimeout(step, 100); return; } - const currentText = msg.substring(0, currentWordIndex); - currentCherry.setMarkdown(currentText); + currentCherry.setMarkdown(msg.substring(0, currentWordIndex)); try { dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} + } catch (e) { + /* noop */ + } if (currentWordIndex < msg.length) { currentWordIndex++; setTimeout(step, interval); } else { printing = false; - currentWordIndex = 0; + setControlsDisabled(false); + msgEl.scrollIntoView({ behavior: 'smooth', block: 'end' }); } } setTimeout(step, interval); } - // 插件复选框事件 + // ---- 事件绑定 ---- + + // 消息选择 + msgPickerList.addEventListener('click', async (e) => { + const btn = e.target.closest('.j-msg-pick-btn'); + if (!btn || printing) return; + await ensureCheckedPluginsLoaded(); + beginPrint(msgList[Number(btn.dataset.index)].content); + }); + + // 插件 checkbox(加载/卸载 + 互斥) document.querySelectorAll('.j-plugin-checkbox').forEach((checkbox) => { checkbox.addEventListener('change', async function () { const plugin = this.dataset.plugin; - // KaTeX 和 MathJax 互斥 - if (plugin === 'katex' && this.checked) { - document.getElementById('plugin-mathjax').checked = false; - } else if (plugin === 'mathjax' && this.checked) { - document.getElementById('plugin-katex').checked = false; - } - - // 懒加载插件 if (this.checked) { + // KaTeX ↔ MathJax 互斥 + const other = MUTUAL_EXCLUSION[plugin]; + if (other) { + const otherCb = document.getElementById(`plugin-${other}`); + if (otherCb?.checked) { + otherCb.checked = false; + unloadPlugin(other); + } + } await loadPlugin(plugin); + } else { + unloadPlugin(plugin); } }); }); @@ -248,74 +385,24 @@ export function aiChatStreamScenario() { // 流式适配开关 document.querySelector('.j-status-input').addEventListener('change', function () { interval = this.checked ? 30 : 50; - currentWordIndex = 0; - currentMsgIndex = msgList.length; - buttonTips.innerHTML = currentMsgIndex; dialog.innerHTML = ''; }); - // 暂停/继续按钮 - pauseBtn.addEventListener('click', function () { + // 暂停/继续 + pauseBtn.addEventListener('click', () => { paused = !paused; pauseBtn.innerText = paused ? '继续流式' : '暂停流式'; }); - // 获取消息按钮 - button.addEventListener('click', async function () { - if (printing || currentMsgIndex === 0) return; - - // 检查并加载需要的插件 - const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); - for (const cb of checkboxes) { - await loadPlugin(cb.dataset.plugin); - } - - const msg = msgTemplate.cloneNode(true); - msg.classList.remove('j-one-msg'); - const config = getCherryConfig(); - config.el = msg.querySelector('.chat-one-msg'); - currentCherry = new Cherry(config); - dialog.appendChild(msg); - - try { - dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} - - beginPrint(msgList[msgList.length - currentMsgIndex]); - currentMsgIndex--; - buttonTips.innerHTML = currentMsgIndex; - }); - - // 自定义内容按钮 - customButton.addEventListener('click', async function () { + // 自定义内容 + customButton.addEventListener('click', async () => { if (printing) return; - - const customContent = customTextarea.value.trim(); - if (!customContent) { + const content = customTextarea.value.trim(); + if (!content) { alert('请输入要流式打印的内容'); return; } - - // 检查并加载需要的插件 - const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); - for (const cb of checkboxes) { - await loadPlugin(cb.dataset.plugin); - } - - const msg = msgTemplate.cloneNode(true); - msg.classList.remove('j-one-msg'); - const config = getCherryConfig(); - config.el = msg.querySelector('.chat-one-msg'); - currentCherry = new Cherry(config); - dialog.appendChild(msg); - - try { - dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} - - beginPrint(customContent); + await ensureCheckedPluginsLoaded(); + beginPrint(content); }); - - // 默认加载 KaTeX - loadPlugin('katex'); } diff --git a/packages/cherry-markdown/src/Cherry.js b/packages/cherry-markdown/src/Cherry.js index 62cf776c7..06318ab7f 100644 --- a/packages/cherry-markdown/src/Cherry.js +++ b/packages/cherry-markdown/src/Cherry.js @@ -278,11 +278,15 @@ export default class Cherry extends CherryStatic { } destroy() { - // 先销毁编辑器实例(清理 EditorView 和资源) if (this.editor) { this.editor.destroy(); } + // 销毁预览器(含 pipeline 任务清理) + if (this.previewer) { + this.previewer.destroy(); + } + // 清理 DOM if (this.noMountEl) { this.cherryDom.remove(); diff --git a/packages/cherry-markdown/src/CherryStatic.js b/packages/cherry-markdown/src/CherryStatic.js index 81d2d2d4d..e54231e49 100644 --- a/packages/cherry-markdown/src/CherryStatic.js +++ b/packages/cherry-markdown/src/CherryStatic.js @@ -23,6 +23,7 @@ import TapdTablePlugin from './addons/advance/cherry-tapd-table-plugin'; import TapdHtmlTagPlugin from './addons/advance/cherry-tapd-html-tag-plugin'; import TapdCheckListPlugin from './addons/advance/cherry-tapd-checklist-plugin'; import EChartsCodeBlockEngine from './addons/advance/cherry-codeblock-echarts-plugin'; +import AsyncRenderPipeline from './utils/async-render-pipeline'; import { isBrowser } from './utils/env'; const constants = { HOOKS_TYPE_LIST }; @@ -33,13 +34,6 @@ const plugins = { TapdCheckListPlugin, EChartsCodeBlockEngine, }; -const nodeIgnorePlugin = []; - -if (!isBrowser()) { - nodeIgnorePlugin.forEach((key) => { - delete plugins[key]; - }); -} // @ts-expect-error process.env from build env const VERSION = `${process.env.BUILD_VERSION}`; @@ -50,11 +44,12 @@ export class CherryStatic { static constants = constants; static plugins = plugins; static VERSION = VERSION; + /** 全局异步渲染管线实例 */ + static asyncRenderPipeline = new AsyncRenderPipeline(); /** - * @this {typeof import('./Cherry').default | typeof CherryStatic} + * 注册插件,须在实例化前调用,只能通过子类(Cherry / CherryStream / CherryEngine)调用。 * @param {{ install: (defaultConfig: any, ...args: any[]) => void }} PluginClass 插件Class * @param {...any} args 初始化插件的参数 - * @returns */ static usePlugin(PluginClass, ...args) { if (this === CherryStatic) { @@ -73,9 +68,4 @@ export class CherryStatic { // @ts-ignore PluginClass.$cherry$mounted = true; } - - // for type check only - // TODO: fix this error - // eslint-disable-next-line no-useless-constructor - constructor(...args) {} } diff --git a/packages/cherry-markdown/src/CherryStream.js b/packages/cherry-markdown/src/CherryStream.js index 0f0a3434a..bae53cdc9 100644 --- a/packages/cherry-markdown/src/CherryStream.js +++ b/packages/cherry-markdown/src/CherryStream.js @@ -280,7 +280,7 @@ export default class CherryStream extends CherryStatic { previewerDom: previewer, value: this.options.value || '', isPreviewOnly: true, - enablePreviewerBubble: enablePreviewerBubble === true, // 流式渲染默认不开启预览区编辑功能,避免引入codemirror + enablePreviewerBubble: enablePreviewerBubble === true, // 避免引入 codemirror lazyLoadImg: this.options.previewer.lazyLoadImg, }); @@ -303,6 +303,11 @@ export default class CherryStream extends CherryStatic { } destroy() { + // 销毁预览器实例(清理滚动事件、ResizeObserver、LazyLoadImg、PreviewerBubble、pipeline 等) + if (this.previewer) { + this.previewer.destroy(); + } + if (this.noMountEl) { this.cherryDom.remove(); } else { diff --git a/packages/cherry-markdown/src/Previewer.js b/packages/cherry-markdown/src/Previewer.js index 894242e0b..25c4e9ef9 100644 --- a/packages/cherry-markdown/src/Previewer.js +++ b/packages/cherry-markdown/src/Previewer.js @@ -42,7 +42,7 @@ export default class Previewer { /** * @property * @private - * @type {number} 释放同步滚动锁定的定时器ID + * @type {number} 释放同步滚动锁定的 requestAnimationFrame handle */ syncScrollLockTimer = 0; @@ -815,7 +815,7 @@ export default class Previewer { const newHtml = this.lazyLoadImg.changeSrc2DataSrc(html); if (!this.isPreviewerHidden()) { // 标记当前正在更新预览区域,锁定同步滚动功能 - window.clearTimeout(this.syncScrollLockTimer); + window.cancelAnimationFrame(this.syncScrollLockTimer); this.applyingDomChanges = true; // 预览区未隐藏时,直接更新 const domContainer = this.getDomContainer(); @@ -840,10 +840,12 @@ export default class Previewer { this.$dealUpdate(domContainer, oldHtmlList, newHtmlList); this.afterUpdate(); } finally { - // 延时释放同步滚动功能,在DOM更新完成后执行 - this.syncScrollLockTimer = window.setTimeout(() => { - this.applyingDomChanges = false; - }, 50); + // 等 layout(第一帧)和 paint(第二帧)完成后再解锁同步滚动。 + this.syncScrollLockTimer = requestAnimationFrame(() => { + this.syncScrollLockTimer = requestAnimationFrame(() => { + this.applyingDomChanges = false; + }); + }); } } else { // 预览区隐藏时,先缓存起来,等到预览区打开再一次性更新 @@ -894,13 +896,13 @@ export default class Previewer { .forEach((dom) => dom.remove()); } } - setTimeout(() => { + requestAnimationFrame(() => { try { this.editor.editor.view.requestMeasure(); } catch (e) { - console.warn('Failed to refresh editor in Previewer:', e); + // 流式场景下可能没有 editor,静默忽略 } - }, 0); + }); } previewOnly() { @@ -955,13 +957,13 @@ export default class Previewer { this.$cherry.$event.emit('previewerOpen'); this.$cherry.$event.emit('editorOpen'); - setTimeout(() => { + requestAnimationFrame(() => { try { this.editor.editor.view.requestMeasure(); } catch (e) { - console.warn('Failed to refresh editor in Previewer:', e); + // 流式场景下可能没有 editor,静默忽略 } - }, 0); + }); } doHtmlCache(html) { @@ -981,6 +983,23 @@ export default class Previewer { return; } this.options.afterUpdateCallBack.map((fn) => fn()); + + // 在 rAF 中触发异步渲染管线,确保 DOM layout 完成后容器尺寸可用, + // 避免 echarts.init 等依赖容器宽高的渲染得到 0 值。 + const root = this.getDomContainer(); + if (root && this.$cherry) { + // 通过 constructor 访问静态属性,避免直接引入 CherryStatic 产生循环依赖 + const CherryCtor = /** @type {typeof import('./CherryStatic').CherryStatic} */ (this.$cherry.constructor); + const pipeline = CherryCtor.asyncRenderPipeline; + if (pipeline && typeof pipeline.flush === 'function') { + const { instanceId } = this; + requestAnimationFrame(() => { + if (this.isDestroyed) return; + pipeline.flush(root, { instanceId }); + }); + } + } + if (this.highlightLineNum === undefined) { this.highlightLineNum = 0; } @@ -1318,7 +1337,7 @@ export default class Previewer { onMouseDown() { addEvent(this.getDomContainer(), 'mousedown', () => { - setTimeout(() => { + queueMicrotask(() => { this.$cherry.$event.emit('cleanAllSubMenus'); }); }); @@ -1398,7 +1417,7 @@ export default class Previewer { // 清理同步滚动锁定定时器 if (this.syncScrollLockTimer) { - clearTimeout(this.syncScrollLockTimer); + cancelAnimationFrame(this.syncScrollLockTimer); this.syncScrollLockTimer = 0; } @@ -1408,6 +1427,19 @@ export default class Previewer { this.animation.timer = 0; } + // 清理异步渲染管线中当前实例的任务 + if (this.$cherry) { + try { + const CherryCtor = /** @type {typeof import('./CherryStatic').CherryStatic} */ (this.$cherry.constructor); + const pipeline = CherryCtor.asyncRenderPipeline; + if (pipeline && typeof pipeline.reset === 'function') { + pipeline.reset(this.instanceId); + } + } catch (e) { + // 静默处理,不影响销毁流程 + } + } + // 清理引用 this.$cherry = null; this.editor = null; diff --git a/packages/cherry-markdown/src/addons/advance/cherry-codeblock-echarts-plugin.js b/packages/cherry-markdown/src/addons/advance/cherry-codeblock-echarts-plugin.js index c5fd727d5..561be2bab 100644 --- a/packages/cherry-markdown/src/addons/advance/cherry-codeblock-echarts-plugin.js +++ b/packages/cherry-markdown/src/addons/advance/cherry-codeblock-echarts-plugin.js @@ -16,6 +16,7 @@ import mergeWith from 'lodash/mergeWith'; import JSON5 from 'json5'; import { getExternal } from '@/utils/external'; +import { generateContainerId, createErrorElement } from '@/utils/async-render-pipeline'; export default class EChartsCodeBlockEngine { static install(cherryOptions, ...args) { @@ -79,31 +80,49 @@ export default class EChartsCodeBlockEngine { const width = this.size?.width || '100%'; const height = this.size?.height || '300px'; const styleStr = `width: ${width}; height: ${height};`; - const previewerDom = $engine.$cherry.previewer.getDom(); - // 延迟到下一轮事件循环再执行 - setTimeout(() => { - const containers = previewerDom.querySelectorAll( - `div[data-sign="${sign}"][data-type="echarts"] .cherry-echarts-codeblock-wrapper`, - ); - if (containers.length <= 0 || !this.echartsRef) return; - const option = this.parseOption(src); - containers.forEach((container) => { - try { - // 判断是否已经初始化 - let chart = this.echartsRef.getInstanceByDom(container); - if (!chart) { - chart = this.echartsRef.init(container); - } - chart.setOption(option, true); // 增加 true 参数以强制覆盖旧配置 - } catch (error) { - if ($engine.$cherry.options.engine.global.flowSessionContext) { - container.innerHTML = `drawing...`; - } else { - container.innerHTML = `
Render Error: ${error.message}
`; + + // 生成唯一 ID,用于 pipeline 查找容器 + const containerId = generateContainerId('echarts-cb'); + + // 预解析 option(在 render 阶段,上下文完整) + let option = {}; + try { + option = this.parseOption(src); + } catch (e) { + const safeMsg = String(e.message).replace(/&/g, '&').replace(//g, '>'); + return `
Parse Error: ${safeMsg}
`; + } + + // 推入异步管线,闭包捕获 option + const CherryCtor = /** @type {typeof import('../../CherryStatic').CherryStatic} */ ($engine.$cherry.constructor); + const pipeline = CherryCtor.asyncRenderPipeline; + if (pipeline) { + const { echartsRef } = this; + const flowMode = !!$engine.$cherry.options?.engine?.global?.flowSessionContext; + pipeline.enqueue({ + containerId, + instanceId: $engine.$cherry.instanceId, + execute(container) { + if (!echartsRef) return; + try { + let chart = echartsRef.getInstanceByDom(container); + if (!chart) { + chart = echartsRef.init(container); + } + chart.setOption(option, true); + } catch (error) { + if (flowMode) { + container.innerHTML = 'drawing...'; + } else { + container.innerHTML = ''; + container.appendChild(createErrorElement(error.message)); + } } - } + }, + priority: 20, }); - }, 50); - return `
`; + } + + return `
`; } } diff --git a/packages/cherry-markdown/src/addons/advance/cherry-table-echarts-plugin.js b/packages/cherry-markdown/src/addons/advance/cherry-table-echarts-plugin.js index ebadde202..7bc869318 100644 --- a/packages/cherry-markdown/src/addons/advance/cherry-table-echarts-plugin.js +++ b/packages/cherry-markdown/src/addons/advance/cherry-table-echarts-plugin.js @@ -17,6 +17,7 @@ import mergeWith from 'lodash/mergeWith'; import Logger from '@/Logger'; import { getExternal } from '@/utils/external'; import { isBrowser } from '@/utils/env'; +import { generateContainerId, createErrorElement } from '@/utils/async-render-pipeline'; // 主题与常量集中管理 const THEME = { @@ -316,11 +317,10 @@ export default class EChartsTableEngine { */ createChart(container, option = {}, type) { if (!container) return null; - // 已存在实例直接返回,避免被观察器和延迟初始化同时触发导致重复初始化 + // 创建前清理失效实例,防止 instances Set 无限增长 + this.cleanupInvalidInstances(); + // 已存在实例直接复用,避免重复初始化 let chart = this.echartsRef.getInstanceByDom(container); - // if (existed && !existed.isDisposed()) return existed; - - // if (container.firstChild) container.innerHTML = ''; if (!chart) { chart = this.echartsRef.init(container, null, this.options); } @@ -544,11 +544,11 @@ export default class EChartsTableEngine { $enableLocaleObserver() { // 如果有Cherry实例,通过其事件系统监听语言变更事件 if (this.cherry && this.cherry.$event) { - const handler = (locale) => { - setTimeout(() => { + const handler = () => { + requestAnimationFrame(() => { const root = this.$getCherryRoot(); this.$rebuildAllCharts(root); - }, 0); + }); }; this.cherry.$event.on(this.cherry.$event.Events.afterChangeLocale, handler); } @@ -571,21 +571,19 @@ export default class EChartsTableEngine { } /** - * 渲染入口:将表格数据渲染为指定类型图表,并返回 HTML 容器片段 + * 渲染入口:预计算图表配置,推入异步管线,返回占位 HTML。 + * pipeline.flush() 在 Previewer.afterUpdate() 的 rAF 中执行。 */ render(type, options, tableObject, $cherry) { - // Logger.log('Rendering chart:', type, options, tableObject); + const chartId = generateContainerId('chart'); - // 生成唯一ID和简化的配置数据 - const chartId = `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - // 序列化数据用于存储 + // 序列化供 $rebuildAllCharts 等场景复用 const tableDataStr = JSON.stringify(tableObject); const chartOptionsStr = JSON.stringify(options); options.chartId = chartId; this.$buildEchartsThemeFromCss(); const chartOption = this.$generateChartOptions(type, tableObject, options); - // Logger.log('Chart options:', chartOption); // HTML样式配置 const styleConfig = { @@ -600,39 +598,41 @@ export default class EChartsTableEngine { .map(([key, value]) => `${key}: ${value};`) .join(' '); - // 创建一个包含所有必要信息的HTML结构 const htmlContent = [ `
`, `
`, ].join(''); - const previewDom = $cherry.previewer.getDom(); - - // 延迟到下一轮事件循环再执行;只重试一次 - setTimeout(() => { - const containers = previewDom.querySelectorAll(`#${chartId}`); - if (containers.length <= 0 || !this.echartsRef) return; - // if (this.echartsRef.getInstanceByDom(container)) return; - containers.forEach((container) => { - try { - this.createChart(container, chartOption, type); - } catch (error) { - if ($cherry.options.engine.syntax.global.flowSessionContext) { - container.innerHTML = 'drawing...'; - } else { - container.innerHTML = `
-
${this.cherry.locale.chartRenderError}
-
${error.message}
-
`; + + // 推入异步管线,闭包捕获 chartOption 避免序列化到 HTML + const CherryCtor = /** @type {typeof import('../../CherryStatic').CherryStatic} */ ($cherry.constructor); + const pipeline = CherryCtor.asyncRenderPipeline; + if (pipeline) { + const engine = this; + const flowMode = !!$cherry.options?.engine?.global?.flowSessionContext; + pipeline.enqueue({ + containerId: chartId, + instanceId: $cherry.instanceId, + execute(container) { + if (!engine.echartsRef) return; + try { + engine.createChart(container, chartOption, type); + } catch (error) { + if (flowMode) { + container.innerHTML = 'drawing...'; + } else { + container.innerHTML = ''; + container.appendChild(createErrorElement(error.message)); + } } - } + }, + priority: 10, }); - this.cleanupInvalidInstances(); - }, 50); + } return htmlContent; } @@ -641,15 +641,18 @@ export default class EChartsTableEngine { addClickHighlightEffect(chartInstance, chartType) { let selectedDataIndex = null; chartInstance.on('click', (params) => { - Logger.log('Chart clicked:', params); - // 如果点击的是同一个数据项,则取消高亮 if (selectedDataIndex === params.dataIndex) { + // 再次点击同一项:取消高亮 selectedDataIndex = null; this.clearHighlight(chartInstance, chartType); return; } - // 记录当前选中的数据项 + // 取消上一个高亮(如果有) + if (selectedDataIndex !== null) { + chartInstance.dispatchAction({ type: 'downplay', dataIndex: selectedDataIndex }); + } selectedDataIndex = params.dataIndex; + chartInstance.dispatchAction({ type: 'highlight', dataIndex: selectedDataIndex }); }); } // 清除高亮效果 @@ -1532,18 +1535,17 @@ const MapChartOptionsHandler = { } const url = paths[index]; - // console.log(`尝试加载地图数据: ${url}`); this.$fetchMapData(url) .then((geoJson) => { getExternal('echarts')?.registerMap?.(url, geoJson); - // console.log(`地图数据加载成功!来源: ${url}`); this.$refreshMapChart(options.chartId, url, options.engine); return geoJson; }) .catch((error) => { Logger.warn(`Map data loading failed (${url}):`, error.message); - this.$handleMapLoadFailure(options); + // 继续尝试下一条路径,而非直接报错 + this.$tryLoadMapDataFromPaths(paths, index + 1, options); }); }, $handleMapLoadFailure(options) { @@ -1583,30 +1585,31 @@ const MapChartOptionsHandler = { }, $refreshMapChart(chartId, url, engine) { const container = document.querySelector(`[id="${chartId}"][data-chart-type="map"]`); + if (!container || !engine.echartsRef) return; + const tableDataStr = container.getAttribute('data-table-data'); const chartOptionsStr = container.getAttribute('data-chart-options'); - if (tableDataStr && engine.echartsRef) { - try { - const tableData = JSON.parse(tableDataStr); - const chartOptions = chartOptionsStr ? JSON.parse(chartOptionsStr) : {}; - chartOptions.engine = engine; - deepMerge(chartOptions, { mapDataSource: url }); - - const chartOption = generateOptions(MapChartCompleteOptionsHandler, tableData, chartOptions); - const existingChart = engine.echartsRef.getInstanceByDom(container); - - if (existingChart) { - existingChart.clear(); - existingChart.setOption(chartOption, true); - // console.log('Map chart refreshed successfully:', chartId); - } else { - engine.createChart(container, chartOption, 'map'); - } - container.setAttribute('data-map-status', 'success'); // 成功加载数据状态 - } catch (error) { - // console.error('Failed to refresh map chart:', chartId, error); + if (!tableDataStr) return; + + try { + const tableData = JSON.parse(tableDataStr); + const chartOptions = chartOptionsStr ? JSON.parse(chartOptionsStr) : {}; + chartOptions.engine = engine; + deepMerge(chartOptions, { mapDataSource: url }); + + const chartOption = generateOptions(MapChartCompleteOptionsHandler, tableData, chartOptions); + const existingChart = engine.echartsRef.getInstanceByDom(container); + + if (existingChart) { + existingChart.clear(); + existingChart.setOption(chartOption, true); + } else { + engine.createChart(container, chartOption, 'map'); } + container.setAttribute('data-map-status', 'success'); + } catch (error) { + Logger.warn(`$refreshMapChart failed for #${chartId}:`, error); } }, }; diff --git a/packages/cherry-markdown/src/index.stream.js b/packages/cherry-markdown/src/index.stream.js index 6967ab71d..42f45a0f8 100644 --- a/packages/cherry-markdown/src/index.stream.js +++ b/packages/cherry-markdown/src/index.stream.js @@ -14,10 +14,13 @@ * limitations under the License. */ import CherryStream from './CherryStream'; +import EChartsTableEngine from '@/addons/advance/cherry-table-echarts-plugin'; import SyntaxHookBase from './core/SyntaxBase'; import { isBrowser } from './utils/env'; +CherryStream.usePlugin(EChartsTableEngine); + // in browser if (isBrowser()) { window.Cherry = CherryStream; diff --git a/packages/cherry-markdown/src/utils/async-render-pipeline.js b/packages/cherry-markdown/src/utils/async-render-pipeline.js new file mode 100644 index 000000000..ada35d805 --- /dev/null +++ b/packages/cherry-markdown/src/utils/async-render-pipeline.js @@ -0,0 +1,147 @@ +/** + * Copyright (C) 2021 Tencent. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Logger from '@/Logger'; + +/** + * @typedef {Object} RenderTask + * @property {string} containerId 容器元素的 DOM id + * @property {(container: Element) => void} execute 执行渲染的闭包,已捕获所有必要数据 + * @property {number} priority 优先级,数值越小越先执行,默认 100 + * @property {string} [instanceId] Cherry 实例 ID,用于多实例隔离 + */ + +/** 全局递增计数器,用于生成唯一容器 ID */ +let idCounter = 0; + +/** + * 生成唯一容器 ID(递增计数器 + 随机后缀,同毫秒内也不会冲突) + * 计数器达到 MAX_SAFE_INTEGER 后自动回绕,配合随机后缀仍可保证唯一性。 + * @param {string} prefix ID 前缀,如 'chart'、'echarts-cb'、'mermaid' + * @returns {string} 形如 `chart-1-a3b2c1` 的唯一 ID + */ +export function generateContainerId(prefix = 'async') { + idCounter = (idCounter % Number.MAX_SAFE_INTEGER) + 1; + return `${prefix}-${idCounter}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * 使用 DOM API 安全地构建错误提示元素(textContent 赋值,天然防 XSS) + * @param {string} message 错误消息 + * @returns {HTMLElement} + */ +export function createErrorElement(message) { + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'text-align:center;color:#ff4d4f;padding:20px;'; + + const titleEl = document.createElement('div'); + titleEl.style.cssText = 'font-size:16px;'; + titleEl.textContent = 'Render Error'; + + const msgEl = document.createElement('div'); + msgEl.style.cssText = 'font-size:12px;opacity:0.7;margin-top:4px;'; + msgEl.textContent = message; + + wrapper.appendChild(titleEl); + wrapper.appendChild(msgEl); + return wrapper; +} + +/** + * 异步渲染管线(两阶段:enqueue → flush) + * + * 插件在 render() 阶段将渲染闭包 enqueue(),返回占位 HTML; + * Previewer.afterUpdate() 在 rAF 中调用 flush(),按优先级依次执行。 + * 多 Cherry 实例通过 instanceId 隔离,互不干扰。 + */ +export default class AsyncRenderPipeline { + /** @type {RenderTask[]} */ + queue = []; + + /** + * 将渲染任务推入队列 + * @param {RenderTask} task + */ + enqueue(task) { + this.queue.push({ + containerId: task.containerId, + execute: task.execute, + priority: task.priority ?? 100, + instanceId: task.instanceId, + }); + } + + /** + * 按优先级执行所有排队的渲染任务 + * @param {Element} root 预览区根容器 + * @param {{ instanceId?: string }} [options] + */ + flush(root, options = {}) { + if (!root || !root.isConnected || this.queue.length === 0) return; + + const { instanceId } = options; + + // 按 instanceId 提取当前实例的任务,其余留在队列 + let tasks; + if (instanceId) { + tasks = []; + const remaining = []; + for (const task of this.queue) { + if (task.instanceId === instanceId) { + tasks.push(task); + } else { + remaining.push(task); + } + } + this.queue = remaining; + } else { + tasks = this.queue.splice(0); + } + + if (tasks.length === 0) return; + + tasks.sort((a, b) => a.priority - b.priority); + + for (const task of tasks) { + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(task.containerId) : task.containerId; + const container = root.querySelector(`#${escapedId}`); + if (!container) { + Logger.log(`[AsyncRenderPipeline] Container #${task.containerId} not found in DOM, task skipped.`); + continue; + } + + try { + task.execute(container); + } catch (error) { + Logger.warn(`[AsyncRenderPipeline] Render failed for #${task.containerId}:`, error); + container.innerHTML = ''; + container.appendChild(createErrorElement(error.message)); + } + } + } + + /** + * 清空指定实例的待执行队列,或清空全部 + * @param {string} [instanceId] + */ + reset(instanceId) { + if (instanceId) { + this.queue = this.queue.filter((task) => task.instanceId !== instanceId); + } else { + this.queue.length = 0; + } + } +} diff --git a/packages/cherry-markdown/vite.plugins.ts b/packages/cherry-markdown/vite.plugins.ts index 0e38f8502..910ff1c2d 100644 --- a/packages/cherry-markdown/vite.plugins.ts +++ b/packages/cherry-markdown/vite.plugins.ts @@ -103,6 +103,15 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug enforce: 'pre', configureServer(server) { + // 检查 dist/fonts/ 是否存在,开发时字体文件依赖此目录 + const fontsDir = path.join(cherryMarkdownDir, 'dist', 'fonts'); + if (!fs.existsSync(fontsDir) || fs.readdirSync(fontsDir).length === 0) { + console.warn( + '\n⚠️ Cherry Markdown: dist/fonts/ 目录不存在或为空。\n' + + ' 开发模式下字体文件从此目录代理。请先运行 `npm run build` 或 `npm run iconfont` 生成字体文件。\n', + ); + } + // 中间件:拦截请求并进行转发或代理 server.middlewares.use((req, res, next) => { const url = req.url || ''; @@ -179,7 +188,7 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug if (id === virtualCherryCssId) return resolvedVirtualCherryCssId; // 动态 addon 虚拟模块 - if (id.startsWith(VIRTUAL_PREFIX + 'addon-')) { + if (id.startsWith(`${VIRTUAL_PREFIX}addon-`)) { return `\0${id}`; } }, @@ -193,7 +202,11 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug import Cherry from '${srcDirNormalized}/index.js'; // 暴露到全局,兼容 examples 中的用法 -window.Cherry = Cherry; +// 注意:index.core.js 中已有 isBrowser() 守卫的 window.Cherry 赋值, +// 这里保持一致的守卫逻辑,与生产构建行为对齐 +if (typeof window === 'object') { + window.Cherry = Cherry; +} export default Cherry; export { Cherry }; @@ -206,7 +219,11 @@ export { Cherry }; import Cherry from '${srcDirNormalized}/index.core.js'; // 暴露到全局,兼容 examples 中的用法 -window.Cherry = Cherry; +// 注意:index.core.js 中已有 isBrowser() 守卫的 window.Cherry 赋值, +// 这里保持一致的守卫逻辑,与生产构建行为对齐 +if (typeof window === 'object') { + window.Cherry = Cherry; +} export default Cherry; export { Cherry }; @@ -219,14 +236,17 @@ export { Cherry }; } // 加载 addon 虚拟模块 - 从 src/addons/ 导入并暴露为 UMD 风格的全局变量 - if (id.startsWith(RESOLVED_PREFIX + 'addon-')) { - const fileName = id.replace(RESOLVED_PREFIX + 'addon-', ''); + if (id.startsWith(`${RESOLVED_PREFIX}addon-`)) { + const fileName = id.replace(`${RESOLVED_PREFIX}addon-`, ''); const globalName = addonFileNameToGlobalName(fileName); return ` import AddonModule from '${srcDirNormalized}/addons/${fileName}'; // 暴露到全局,兼容 dist/addons/ UMD 构建中的全局变量命名 -window.${globalName} = AddonModule; +// 与生产 UMD 行为对齐:仅在浏览器环境下挂载全局变量 +if (typeof window === 'object') { + window.${globalName} = AddonModule; +} export default AddonModule; `; diff --git a/packages/cherry-markdown/vitest.config.ts b/packages/cherry-markdown/vitest.config.ts index f5cb5791d..36400f915 100644 --- a/packages/cherry-markdown/vitest.config.ts +++ b/packages/cherry-markdown/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ }, test: { testTransformMode: { - web: ['\\.[jt]sx$'], + web: ['\\.[jt]sx?$'], }, globals: true, environment: 'jsdom', // Use jsdom for browser-like tests