@h-ai/ui 0.1.0-alpha.33 → 0.1.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +24 -5
  2. package/auto-import.js +2 -2
  3. package/dist/components/compounds/Combobox.svelte +12 -4
  4. package/dist/components/compounds/Combobox.svelte.d.ts +4 -1
  5. package/dist/components/compounds/DatePicker.svelte +19 -4
  6. package/dist/components/compounds/DatePicker.svelte.d.ts +4 -1
  7. package/dist/components/compounds/TagInput.svelte +36 -31
  8. package/dist/components/control-size.d.ts +18 -0
  9. package/dist/components/control-size.js +18 -0
  10. package/dist/components/primitives/Input.svelte +4 -25
  11. package/dist/components/primitives/Select.svelte +4 -10
  12. package/dist/components/scenes/ai/AiDocumentEditor.svelte +203 -22
  13. package/dist/components/scenes/ai/document-download.js +19 -2
  14. package/dist/components/scenes/ai/document-parse.d.ts +25 -2
  15. package/dist/components/scenes/ai/document-parse.js +137 -11
  16. package/dist/components/scenes/ai/index.d.ts +1 -1
  17. package/dist/components/scenes/ai/mermaid-host-sync.d.ts +34 -0
  18. package/dist/components/scenes/ai/mermaid-host-sync.js +35 -0
  19. package/dist/components/scenes/ai/mermaid-render.d.ts +5 -0
  20. package/dist/components/scenes/ai/mermaid-render.js +7 -0
  21. package/dist/components/scenes/charts/Chart.svelte +152 -0
  22. package/dist/components/scenes/charts/Chart.svelte.d.ts +5 -0
  23. package/dist/components/scenes/charts/chart-theme.d.ts +23 -0
  24. package/dist/components/scenes/charts/chart-theme.js +44 -0
  25. package/dist/components/scenes/charts/chart-types.d.ts +77 -0
  26. package/dist/components/scenes/charts/chart-types.js +7 -0
  27. package/dist/components/scenes/charts/index.d.ts +8 -0
  28. package/dist/components/scenes/charts/index.js +7 -0
  29. package/dist/components/scenes/charts/renderers/AreaChart.svelte +18 -0
  30. package/dist/components/scenes/charts/renderers/AreaChart.svelte.d.ts +5 -0
  31. package/dist/components/scenes/charts/renderers/BarChart.svelte +18 -0
  32. package/dist/components/scenes/charts/renderers/BarChart.svelte.d.ts +5 -0
  33. package/dist/components/scenes/charts/renderers/LineChart.svelte +571 -0
  34. package/dist/components/scenes/charts/renderers/LineChart.svelte.d.ts +19 -0
  35. package/dist/components/scenes/charts/renderers/PieChart.svelte +18 -0
  36. package/dist/components/scenes/charts/renderers/PieChart.svelte.d.ts +5 -0
  37. package/dist/components/scenes/charts/renderers/ScatterChart.svelte +18 -0
  38. package/dist/components/scenes/charts/renderers/ScatterChart.svelte.d.ts +5 -0
  39. package/dist/components/scenes/iam/PasswordInput.svelte +4 -15
  40. package/dist/components/scenes/index.d.ts +1 -0
  41. package/dist/components/scenes/index.js +2 -0
  42. package/dist/messages/en-US.json +2 -0
  43. package/dist/messages/zh-CN.json +2 -0
  44. package/dist/types.d.ts +1 -0
  45. package/package.json +3 -2
@@ -26,6 +26,7 @@
26
26
  MarkdownTextAlignKind,
27
27
  } from './document-types.js'
28
28
  import { tick } from 'svelte'
29
+ import { SvelteMap } from 'svelte/reactivity'
29
30
  import { writeTextToClipboard } from '../../../internal/browser-safety.js'
30
31
  import { uiM } from '../../../messages.js'
31
32
  import { cn, getDataAttributes } from '../../../utils.js'
@@ -37,7 +38,12 @@
37
38
  import { resolveDocumentMarkdownContent } from './document-download.js'
38
39
  import { renderMarkdownDocument } from './document-parse.js'
39
40
  import { parseMarkdown } from './markdown-parse.js'
40
- import { renderMermaidDiagram } from './mermaid-render.js'
41
+ import {
42
+ createMermaidSourceSignature,
43
+ getMermaidHostRenderAction,
44
+ isCurrentMermaidRenderToken,
45
+ } from './mermaid-host-sync.js'
46
+ import { renderMermaidDiagram, stripMermaidSvgStyleElements } from './mermaid-render.js'
41
47
 
42
48
  interface SelectionToolbarPosition {
43
49
  /** 选区工具条相对滚动容器的 top 坐标。 */
@@ -59,6 +65,17 @@
59
65
  error?: string
60
66
  }
61
67
 
68
+ interface MermaidRenderCacheEntry {
69
+ /** 当前 Mermaid 源码签名。 */
70
+ signature: string
71
+ /** 当前 Mermaid 渲染状态。 */
72
+ status: 'rendering' | 'ready' | 'error'
73
+ /** 渲染成功后的 SVG 内容。 */
74
+ html?: string
75
+ /** 渲染失败时展示的错误摘要。 */
76
+ error?: string
77
+ }
78
+
62
79
  interface SelectionFormatState {
63
80
  /** 当前选区所在段落样式,用于标题/正文下拉默认值。 */
64
81
  blockFormat: MarkdownBlockStyleKind
@@ -226,6 +243,7 @@
226
243
  // 复制前后的图标以内联 SVG 缓存,避免每次点击都重新拼接按钮内容。
227
244
  const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
228
245
  const CHECK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
246
+ const MERMAID_SYNC_IDLE_DELAY_MS = 220
229
247
 
230
248
  let {
231
249
  // 暴露给外层的滚动容器引用,用于同步滚动或定位选区工具条。
@@ -367,6 +385,12 @@
367
385
  let copyFeedbackTimer: number | undefined = $state()
368
386
  // 每个代码块的运行状态和预览结果,key 为 codeBlockId。
369
387
  let codePreviews = $state<Record<string, CodePreviewState>>({})
388
+ // Mermaid host 会随 {@html} 重建,渲染缓存必须独立于 DOM 节点保存。
389
+ const mermaidRenderCache = new SvelteMap<string, MermaidRenderCacheEntry>()
390
+ // 流式输出会高频重建 {@html},Mermaid 同步必须合并到内容静默后执行。
391
+ let mermaidSyncTimer: number | undefined
392
+ let mermaidSyncFrame: number | undefined
393
+ let scheduledMermaidRenderSignature = ''
370
394
  // selectionFormatState 跟随真实 DOM 选区,负责驱动按钮按下态与下拉默认值。
371
395
  let selectionFormatState = $state<SelectionFormatState>({
372
396
  ...DEFAULT_SELECTION_FORMAT_STATE,
@@ -419,8 +443,13 @@
419
443
  allowHtmlTags,
420
444
  }),
421
445
  )
422
- // html 是最终注入正文的内容。
423
- const html = $derived(renderResult.html)
446
+ // Mermaid 占位 HTML 在流式阶段可能不变,必须额外订阅源码签名变化。
447
+ const mermaidRenderSignature = $derived(
448
+ renderResult.blocks
449
+ .filter(block => block.kind === 'mermaid')
450
+ .map(block => block.signature)
451
+ .join('\n'),
452
+ )
424
453
  // outline 是左侧目录的原始数据源。
425
454
  const outline = $derived(renderResult.outline)
426
455
  // outlineHasContent 用来区分“目录被收起”和“正文确实没有标题”。
@@ -492,14 +521,26 @@
492
521
  }
493
522
 
494
523
  $effect(() => {
495
- void html
524
+ void renderResult.blocks
525
+ void mermaidRenderSignature
496
526
  if (typeof window === 'undefined' || !previewHost) {
527
+ cancelMermaidHostsSync()
497
528
  return
498
529
  }
499
530
 
531
+ if (!mermaidRenderSignature) {
532
+ scheduledMermaidRenderSignature = ''
533
+ cancelMermaidHostsSync()
534
+ }
535
+ else if (
536
+ mermaidRenderSignature !== scheduledMermaidRenderSignature
537
+ ) {
538
+ scheduledMermaidRenderSignature = mermaidRenderSignature
539
+ scheduleMermaidHostsSync()
540
+ }
541
+
500
542
  requestAnimationFrame(() => {
501
543
  syncCodePreviewHosts()
502
- syncMermaidHosts()
503
544
  syncActiveHeadingFromScroll()
504
545
  syncPinnedTitleVisibility()
505
546
  })
@@ -939,10 +980,16 @@
939
980
  : ''
940
981
 
941
982
  if (result.kind === 'mermaid') {
942
- host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-mermaid"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-mermaid hai-md-mermaid-preview"></div></div>`
983
+ host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-mermaid"><div class="hai-md-preview-head">${previewTitle}</div>${previewDesc}<div class="hai-md-mermaid hai-md-mermaid-preview" contenteditable="false"></div></div>`
943
984
  const mermaidEl = host.querySelector<HTMLElement>('.hai-md-mermaid-preview')
944
985
  if (mermaidEl) {
945
- void renderMermaidHost(mermaidEl, result.content)
986
+ const signature = createMermaidSourceSignature('preview', result.content)
987
+ void renderMermaidHost(
988
+ mermaidEl,
989
+ result.content,
990
+ signature,
991
+ `preview:${signature}`,
992
+ )
946
993
  }
947
994
  return
948
995
  }
@@ -974,6 +1021,43 @@
974
1021
  * mermaid 图表位于 `{@html}` 注入的 DOM 内部,渲染是异步的。
975
1022
  * 阅读态的图表块和 code 模式预览槽都复用这里把源码渲染为 SVG。
976
1023
  */
1024
+ function scheduleMermaidHostsSync(): void {
1025
+ if (typeof window === 'undefined') {
1026
+ return
1027
+ }
1028
+
1029
+ if (mermaidSyncTimer !== undefined) {
1030
+ window.clearTimeout(mermaidSyncTimer)
1031
+ }
1032
+ if (mermaidSyncFrame !== undefined) {
1033
+ window.cancelAnimationFrame(mermaidSyncFrame)
1034
+ mermaidSyncFrame = undefined
1035
+ }
1036
+
1037
+ mermaidSyncTimer = window.setTimeout(() => {
1038
+ mermaidSyncTimer = undefined
1039
+ mermaidSyncFrame = window.requestAnimationFrame(() => {
1040
+ mermaidSyncFrame = undefined
1041
+ syncMermaidHosts()
1042
+ })
1043
+ }, MERMAID_SYNC_IDLE_DELAY_MS)
1044
+ }
1045
+
1046
+ function cancelMermaidHostsSync(): void {
1047
+ if (typeof window === 'undefined') {
1048
+ return
1049
+ }
1050
+
1051
+ if (mermaidSyncTimer !== undefined) {
1052
+ window.clearTimeout(mermaidSyncTimer)
1053
+ mermaidSyncTimer = undefined
1054
+ }
1055
+ if (mermaidSyncFrame !== undefined) {
1056
+ window.cancelAnimationFrame(mermaidSyncFrame)
1057
+ mermaidSyncFrame = undefined
1058
+ }
1059
+ }
1060
+
977
1061
  function syncMermaidHosts(): void {
978
1062
  if (!previewHost) {
979
1063
  return
@@ -981,13 +1065,6 @@
981
1065
 
982
1066
  const hosts = previewHost.querySelectorAll<HTMLElement>('[data-mermaid-host]')
983
1067
  for (const host of hosts) {
984
- if (
985
- host.dataset.mermaidStatus === 'ready'
986
- || host.dataset.mermaidStatus === 'rendering'
987
- ) {
988
- continue
989
- }
990
-
991
1068
  const blockId = host.dataset.mermaidHost
992
1069
  if (!blockId) {
993
1070
  continue
@@ -998,7 +1075,19 @@
998
1075
  continue
999
1076
  }
1000
1077
 
1001
- void renderMermaidHost(host, block.code)
1078
+ const nextSignature = createMermaidSourceSignature(block.id, block.code)
1079
+ const cached = mermaidRenderCache.get(blockId)
1080
+ const action = getMermaidHostRenderAction({
1081
+ status: cached?.status ?? host.dataset.mermaidStatus,
1082
+ renderedSignature: cached?.signature ?? host.dataset.mermaidSourceSignature,
1083
+ nextSignature,
1084
+ })
1085
+ if (!action.shouldRender) {
1086
+ applyCachedMermaidRender(host, cached)
1087
+ continue
1088
+ }
1089
+
1090
+ void renderMermaidHost(host, block.code, action.token, blockId)
1002
1091
  }
1003
1092
  }
1004
1093
 
@@ -1006,16 +1095,98 @@
1006
1095
  * 把单个 mermaid 源码渲染进目标节点;语法错误时展示本地化错误提示。
1007
1096
  * `mermaidStatus` 既避免同一帧重复渲染,也兜住多次 effect 触发。
1008
1097
  */
1009
- async function renderMermaidHost(host: HTMLElement, code: string): Promise<void> {
1098
+ async function renderMermaidHost(
1099
+ host: HTMLElement,
1100
+ code: string,
1101
+ renderToken: string,
1102
+ cacheKey: string,
1103
+ ): Promise<void> {
1104
+ const cached = mermaidRenderCache.get(cacheKey)
1105
+ if (
1106
+ cached
1107
+ && (cached.status === 'ready' || cached.status === 'rendering' || cached.status === 'error')
1108
+ && cached.signature === renderToken
1109
+ ) {
1110
+ applyCachedMermaidRender(host, cached)
1111
+ return
1112
+ }
1113
+
1010
1114
  host.dataset.mermaidStatus = 'rendering'
1115
+ host.dataset.mermaidSourceSignature = renderToken
1116
+ mermaidRenderCache.set(cacheKey, {
1117
+ signature: renderToken,
1118
+ status: 'rendering',
1119
+ })
1011
1120
  try {
1012
1121
  const svg = await renderMermaidDiagram(code)
1013
- host.innerHTML = svg
1014
- host.dataset.mermaidStatus = 'ready'
1122
+ if (!isCurrentMermaidRenderToken(renderToken, mermaidRenderCache.get(cacheKey)?.signature)) {
1123
+ return
1124
+ }
1125
+
1126
+ const entry: MermaidRenderCacheEntry = {
1127
+ signature: renderToken,
1128
+ status: 'ready',
1129
+ html: stripMermaidSvgStyleElements(svg),
1130
+ }
1131
+ mermaidRenderCache.set(cacheKey, entry)
1132
+ applyCachedMermaidRender(host, entry)
1133
+ applyCachedMermaidRenderToCurrentHosts(cacheKey, entry)
1015
1134
  }
1016
1135
  catch (error) {
1017
- host.dataset.mermaidStatus = 'error'
1018
- host.innerHTML = `<div class="hai-md-mermaid-error">${escapePreviewText(error instanceof Error ? error.message : uiM('markdown_mermaid_failed'))}</div>`
1136
+ if (!isCurrentMermaidRenderToken(renderToken, mermaidRenderCache.get(cacheKey)?.signature)) {
1137
+ return
1138
+ }
1139
+
1140
+ const message = error instanceof Error ? error.message : uiM('markdown_mermaid_failed')
1141
+ const entry: MermaidRenderCacheEntry = {
1142
+ signature: renderToken,
1143
+ status: 'error',
1144
+ error: message,
1145
+ }
1146
+ mermaidRenderCache.set(cacheKey, entry)
1147
+ applyCachedMermaidRender(host, entry)
1148
+ applyCachedMermaidRenderToCurrentHosts(cacheKey, entry)
1149
+ }
1150
+ }
1151
+
1152
+ function applyCachedMermaidRender(
1153
+ host: HTMLElement,
1154
+ entry: MermaidRenderCacheEntry | undefined,
1155
+ ): void {
1156
+ if (!entry) {
1157
+ return
1158
+ }
1159
+
1160
+ host.dataset.mermaidStatus = entry.status
1161
+ host.dataset.mermaidSourceSignature = entry.signature
1162
+ if (host.dataset.mermaidAppliedSignature === entry.signature) {
1163
+ return
1164
+ }
1165
+
1166
+ if (entry.status === 'ready' && entry.html !== undefined) {
1167
+ host.innerHTML = entry.html
1168
+ host.dataset.mermaidAppliedSignature = entry.signature
1169
+ return
1170
+ }
1171
+
1172
+ if (entry.status === 'error') {
1173
+ host.innerHTML = `<div class="hai-md-mermaid-error">${escapePreviewText(entry.error ?? uiM('markdown_mermaid_failed'))}</div>`
1174
+ host.dataset.mermaidAppliedSignature = entry.signature
1175
+ }
1176
+ }
1177
+
1178
+ function applyCachedMermaidRenderToCurrentHosts(
1179
+ cacheKey: string,
1180
+ entry: MermaidRenderCacheEntry,
1181
+ ): void {
1182
+ if (!previewHost) {
1183
+ return
1184
+ }
1185
+
1186
+ for (const host of previewHost.querySelectorAll<HTMLElement>('[data-mermaid-host]')) {
1187
+ if (host.dataset.mermaidHost === cacheKey) {
1188
+ applyCachedMermaidRender(host, entry)
1189
+ }
1019
1190
  }
1020
1191
  }
1021
1192
 
@@ -3172,8 +3343,18 @@
3172
3343
  oninput={handlePreviewInput}
3173
3344
  onblur={handlePreviewBlur}
3174
3345
  >
3175
- <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 渲染 -->
3176
- {@html html}
3346
+ {#each renderResult.blocks as block (block.id)}
3347
+ {#if block.kind === 'html'}
3348
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown HTML 块渲染 -->
3349
+ {@html block.html}
3350
+ {:else}
3351
+ <div
3352
+ class='hai-md-mermaid'
3353
+ contenteditable='false'
3354
+ data-mermaid-host={block.codeBlockId}
3355
+ ></div>
3356
+ {/if}
3357
+ {/each}
3177
3358
  </article>
3178
3359
  </div>
3179
3360
  </section>
@@ -13,6 +13,22 @@ const BROWSER_PDF_PAGE_BREAK_SAMPLE_STEP_PX = 4;
13
13
  const BROWSER_PDF_PAGE_BREAK_WHITE_THRESHOLD = 246;
14
14
  const BROWSER_PDF_PAGE_BREAK_ALPHA_THRESHOLD = 12;
15
15
  const BROWSER_PDF_PAGE_BREAK_EMPTY_ROW_MAX_INK = 1;
16
+ function escapeExportHtml(value) {
17
+ return value
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&#39;');
23
+ }
24
+ function renderDocumentBlocksForExport(blocks) {
25
+ return blocks.map((block) => {
26
+ if (block.kind === 'html') {
27
+ return block.html;
28
+ }
29
+ return `<pre><code class="language-${escapeExportHtml(block.language)}">${escapeExportHtml(block.code)}</code></pre>`;
30
+ }).join('\n');
31
+ }
16
32
  /**
17
33
  * code 类型产物通常只有裸代码正文。
18
34
  * 下载和预览都通过同一份规范化逻辑包装成 fenced markdown,避免两边导出的结构不一致。
@@ -75,14 +91,15 @@ export async function downloadAiDocument(request) {
75
91
  showRunButton: false,
76
92
  breaks: true,
77
93
  });
78
- const exportHtml = buildDocumentExportHtml(exportTitle, rendered.html);
94
+ const renderedHtml = renderDocumentBlocksForExport(rendered.blocks);
95
+ const exportHtml = buildDocumentExportHtml(exportTitle, renderedHtml);
79
96
  if (builtIn.id === 'word') {
80
97
  downloadBlob(new Blob([`\uFEFF${exportHtml}`], {
81
98
  type: 'application/msword;charset=utf-8',
82
99
  }), `${baseName}.doc`);
83
100
  return;
84
101
  }
85
- await downloadDocumentAsPdf(htmlToPdfFilename(exportTitle), rendered.html);
102
+ await downloadDocumentAsPdf(htmlToPdfFilename(exportTitle), renderedHtml);
86
103
  }
87
104
  function createBuiltInDownloadAction(format) {
88
105
  switch (format) {
@@ -25,12 +25,35 @@ export interface MarkdownDocumentParseOptions {
25
25
  allowHtmlTags?: boolean;
26
26
  }
27
27
  export interface MarkdownRenderResult {
28
- /** Rendered HTML output. */
29
- html: string;
30
28
  /** Extracted outline items for navigation. */
31
29
  outline: MarkdownOutlineItem[];
32
30
  /** Extracted code blocks for run/copy features. */
33
31
  codeBlocks: MarkdownCodeBlockItem[];
32
+ /** 阅读态使用的稳定块列表,避免流式更新销毁重型块 DOM。 */
33
+ blocks: MarkdownDocumentBlock[];
34
+ }
35
+ export type MarkdownDocumentBlock = MarkdownDocumentHtmlBlock | MarkdownDocumentMermaidBlock;
36
+ export interface MarkdownDocumentHtmlBlock {
37
+ /** 普通 Markdown HTML 块。 */
38
+ kind: 'html';
39
+ /** 稳定块 id,用于 Svelte keyed each。 */
40
+ id: string;
41
+ /** 已按现有安全策略渲染后的 HTML。 */
42
+ html: string;
43
+ }
44
+ export interface MarkdownDocumentMermaidBlock {
45
+ /** 已闭合 Mermaid fence 块。 */
46
+ kind: 'mermaid';
47
+ /** 稳定块 id,用于 Svelte keyed each。 */
48
+ id: string;
49
+ /** 对应 codeBlocks 中的代码块 id。 */
50
+ codeBlockId: string;
51
+ /** Mermaid 源码。 */
52
+ code: string;
53
+ /** 原始 fence info string。 */
54
+ language: string;
55
+ /** Mermaid 源码签名,源码变化时才需要重渲染。 */
56
+ signature: string;
34
57
  }
35
58
  /**
36
59
  * Render markdown into HTML and extract outline + code block metadata.
@@ -28,6 +28,41 @@ function readHtmlTokenSource(token) {
28
28
  ? token.text
29
29
  : '';
30
30
  }
31
+ /**
32
+ * 判断 fenced code block 是否已经输出闭合 fence。
33
+ *
34
+ * marked 在流式半截内容里也会把未闭合 fence 识别成 code token;如果此时提前
35
+ * 调用 Mermaid,会把临时语法错误渲染成错误 SVG 并残留在界面上。
36
+ */
37
+ function isClosedFencedCodeBlock(token) {
38
+ const firstLineEnd = token.raw.indexOf('\n');
39
+ const firstLine = firstLineEnd === -1
40
+ ? token.raw
41
+ : token.raw.slice(0, firstLineEnd);
42
+ const openingStart = firstLine.search(/\S/);
43
+ if (openingStart === -1 || openingStart > 3) {
44
+ return true;
45
+ }
46
+ const markerChar = firstLine[openingStart];
47
+ if (markerChar !== '`' && markerChar !== '~') {
48
+ return true;
49
+ }
50
+ let markerLength = 0;
51
+ for (let index = openingStart; firstLine[index] === markerChar; index += 1) {
52
+ markerLength += 1;
53
+ }
54
+ if (markerLength < 3) {
55
+ return true;
56
+ }
57
+ const trimmedRaw = token.raw.trimEnd();
58
+ const lastLineStart = trimmedRaw.lastIndexOf('\n');
59
+ if (lastLineStart === -1) {
60
+ return false;
61
+ }
62
+ const closingFence = trimmedRaw.slice(lastLineStart + 1).trim();
63
+ return closingFence.length >= markerLength
64
+ && [...closingFence].every(char => char === markerChar);
65
+ }
31
66
  /**
32
67
  * Create a URL-safe heading slug while keeping letters and digits.
33
68
  */
@@ -98,18 +133,21 @@ function createRendererObject(options, state) {
98
133
  const headingHtml = this.parser.parseInline(token.tokens);
99
134
  return `<h${depth} id="${escapeHtml(id)}" data-heading-id="${escapeHtml(id)}">${headingHtml}</h${depth}>`;
100
135
  },
101
- code({ text, lang }) {
136
+ code(token) {
137
+ const { text, lang } = token;
102
138
  // rawLanguage is the original info string from the fence.
103
139
  const rawLanguage = lang?.trim() || '';
104
140
  // mermaid 块在阅读态自动渲染为图表;只有 code 模式的代码/预览切换才保留源码视图。
105
- if (isMermaidLanguage(rawLanguage) && !options.showCodePreviewToggle) {
141
+ if (isMermaidLanguage(rawLanguage)
142
+ && !options.showCodePreviewToggle
143
+ && isClosedFencedCodeBlock(token)) {
106
144
  const mermaidBlockId = `hai-md-code-${state.codeBlocks.length + 1}`;
107
145
  state.codeBlocks.push({
108
146
  id: mermaidBlockId,
109
147
  code: text,
110
148
  language: rawLanguage,
111
149
  });
112
- return `<div class="hai-md-mermaid" data-mermaid-host="${escapeHtml(mermaidBlockId)}"></div>`;
150
+ return `<div class="hai-md-mermaid" contenteditable="false" data-mermaid-host="${escapeHtml(mermaidBlockId)}"></div>`;
113
151
  }
114
152
  // highlightLanguage is validated against supported languages.
115
153
  const highlightLanguage = rawLanguage && isLanguageSupported(rawLanguage)
@@ -236,16 +274,105 @@ function createMarkedInstance(options, state) {
236
274
  breaks: options.breaks,
237
275
  });
238
276
  }
277
+ function createMermaidSourceSignature(blockId, code) {
278
+ return JSON.stringify([blockId, code]);
279
+ }
280
+ function createEmptyRenderResult() {
281
+ return {
282
+ outline: [],
283
+ codeBlocks: [],
284
+ blocks: [],
285
+ };
286
+ }
287
+ function createHtmlBlock(id, html) {
288
+ return html
289
+ ? {
290
+ kind: 'html',
291
+ id,
292
+ html,
293
+ }
294
+ : null;
295
+ }
296
+ function renderMarkdownDocumentHtmlChunk(content, options, state) {
297
+ const marked = createMarkedInstance(options, state);
298
+ const result = marked.parse(content);
299
+ return {
300
+ html: typeof result === 'string' ? result : '',
301
+ codeBlocks: state.codeBlocks,
302
+ };
303
+ }
304
+ function isClosedMermaidToken(token, options) {
305
+ if (token.type !== 'code'
306
+ || !('text' in token)
307
+ || typeof token.text !== 'string') {
308
+ return false;
309
+ }
310
+ const codeToken = token;
311
+ const rawLanguage = codeToken.lang?.trim() || '';
312
+ return isMermaidLanguage(rawLanguage)
313
+ && !options.showCodePreviewToggle
314
+ && isClosedFencedCodeBlock(codeToken);
315
+ }
316
+ function renderMarkdownDocumentBlocks(content, options) {
317
+ const marked = new Marked({
318
+ extensions: createEditorMarkdownExtensions(),
319
+ gfm: true,
320
+ breaks: options.breaks,
321
+ });
322
+ const tokens = marked.lexer(content);
323
+ const blocks = [];
324
+ let htmlTokens = [];
325
+ const state = {
326
+ outline: [],
327
+ codeBlocks: [],
328
+ headingIds: new Map(),
329
+ };
330
+ let nextBlockIndex = 1;
331
+ function flushHtmlTokens() {
332
+ if (htmlTokens.length === 0) {
333
+ return;
334
+ }
335
+ const chunk = htmlTokens.map(token => token.raw).join('');
336
+ const result = renderMarkdownDocumentHtmlChunk(chunk, options, state);
337
+ const block = createHtmlBlock(`hai-md-block-${nextBlockIndex}`, result.html);
338
+ if (block) {
339
+ blocks.push(block);
340
+ nextBlockIndex += 1;
341
+ }
342
+ htmlTokens = [];
343
+ }
344
+ for (const token of tokens) {
345
+ if (!isClosedMermaidToken(token, options)) {
346
+ htmlTokens.push(token);
347
+ continue;
348
+ }
349
+ flushHtmlTokens();
350
+ const codeBlockId = `hai-md-code-${state.codeBlocks.length + 1}`;
351
+ const rawLanguage = token.lang?.trim() || '';
352
+ state.codeBlocks.push({
353
+ id: codeBlockId,
354
+ code: token.text,
355
+ language: rawLanguage,
356
+ });
357
+ blocks.push({
358
+ kind: 'mermaid',
359
+ id: `hai-md-block-${nextBlockIndex}`,
360
+ codeBlockId,
361
+ code: token.text,
362
+ language: rawLanguage,
363
+ signature: createMermaidSourceSignature(codeBlockId, token.text),
364
+ });
365
+ nextBlockIndex += 1;
366
+ }
367
+ flushHtmlTokens();
368
+ return blocks;
369
+ }
239
370
  /**
240
371
  * Render markdown into HTML and extract outline + code block metadata.
241
372
  */
242
373
  export function renderMarkdownDocument(content, options) {
243
374
  if (!content) {
244
- return {
245
- html: '',
246
- outline: [],
247
- codeBlocks: [],
248
- };
375
+ return createEmptyRenderResult();
249
376
  }
250
377
  // mergedOptions ensures defaults are always applied.
251
378
  const mergedOptions = {
@@ -260,11 +387,10 @@ export function renderMarkdownDocument(content, options) {
260
387
  };
261
388
  // marked instance is scoped to this render call.
262
389
  const marked = createMarkedInstance(mergedOptions, state);
263
- // result can be sync string or async promise; only sync string is used.
264
- const result = marked.parse(content);
390
+ marked.parse(content);
265
391
  return {
266
- html: typeof result === 'string' ? result : '',
267
392
  outline: createNumberedOutline(state.outline),
268
393
  codeBlocks: state.codeBlocks,
394
+ blocks: renderMarkdownDocumentBlocks(content, mergedOptions),
269
395
  };
270
396
  }
@@ -10,7 +10,7 @@ export { default as AiDocumentEditor } from './AiDocumentEditor.svelte';
10
10
  export { downloadAiDocument, resolveDocumentDownloadActions, resolveDocumentMarkdownContent, } from './document-download.js';
11
11
  export type { AiDocumentDownloadFormat, AiDocumentDownloadRequest, AiDocumentResolvedDownloadAction, } from './document-download.js';
12
12
  export { renderMarkdownDocument } from './document-parse.js';
13
- export type { MarkdownDocumentParseOptions, MarkdownRenderResult, } from './document-parse.js';
13
+ export type { MarkdownDocumentBlock, MarkdownDocumentHtmlBlock, MarkdownDocumentMermaidBlock, MarkdownDocumentParseOptions, MarkdownRenderResult, } from './document-parse.js';
14
14
  export type { AiDocumentEditorProps, MarkdownBlockFormatKind, MarkdownBlockStyleKind, MarkdownCodeBlockItem, MarkdownCodeRunRequest, MarkdownCodeRunResult, MarkdownColorFormatRequest, MarkdownInlineFormatKind, MarkdownOutlineItem, MarkdownRewriteAction, MarkdownRewriteRequest, MarkdownTextAlignKind, MarkdownToolbarDownloadAction, } from './document-types.js';
15
15
  export { parseMarkdown } from './markdown-parse.js';
16
16
  export type { MarkdownParseOptions } from './markdown-parse.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Mermaid host 同步状态工具。
3
+ *
4
+ * Mermaid 图表通过 {@html} 插入占位节点后异步渲染。流式输出时,HTML 占位可能不变,
5
+ * 但源码正文会持续变化,因此必须用源码签名判断是否需要重渲染。
6
+ */
7
+ export interface MermaidHostRenderActionInput {
8
+ /** 当前 host 上记录的渲染状态。 */
9
+ status?: string;
10
+ /** 当前 host 上一次渲染使用的源码签名。 */
11
+ renderedSignature?: string;
12
+ /** 本次 Markdown 解析得到的最新源码签名。 */
13
+ nextSignature: string;
14
+ }
15
+ export type MermaidHostRenderAction = {
16
+ shouldRender: false;
17
+ } | {
18
+ shouldRender: true;
19
+ token: string;
20
+ };
21
+ /**
22
+ * 为 Mermaid block 生成稳定签名。
23
+ *
24
+ * 签名同时包含 block id 与源码正文,避免流式阶段占位 HTML 未变化时漏掉源码更新。
25
+ */
26
+ export declare function createMermaidSourceSignature(blockId: string, code: string): string;
27
+ /**
28
+ * 判断 Mermaid host 是否需要发起一次新的异步渲染。
29
+ */
30
+ export declare function getMermaidHostRenderAction(input: MermaidHostRenderActionInput): MermaidHostRenderAction;
31
+ /**
32
+ * 判断异步 Mermaid 渲染结果是否仍对应当前 host 源码。
33
+ */
34
+ export declare function isCurrentMermaidRenderToken(renderToken: string, currentSignature: string | undefined): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Mermaid host 同步状态工具。
3
+ *
4
+ * Mermaid 图表通过 {@html} 插入占位节点后异步渲染。流式输出时,HTML 占位可能不变,
5
+ * 但源码正文会持续变化,因此必须用源码签名判断是否需要重渲染。
6
+ */
7
+ /**
8
+ * 为 Mermaid block 生成稳定签名。
9
+ *
10
+ * 签名同时包含 block id 与源码正文,避免流式阶段占位 HTML 未变化时漏掉源码更新。
11
+ */
12
+ export function createMermaidSourceSignature(blockId, code) {
13
+ return JSON.stringify([blockId, code]);
14
+ }
15
+ /**
16
+ * 判断 Mermaid host 是否需要发起一次新的异步渲染。
17
+ */
18
+ export function getMermaidHostRenderAction(input) {
19
+ const status = input.status?.trim();
20
+ const renderedSignature = input.renderedSignature?.trim();
21
+ if ((status === 'ready' || status === 'rendering' || status === 'error')
22
+ && renderedSignature === input.nextSignature) {
23
+ return { shouldRender: false };
24
+ }
25
+ return {
26
+ shouldRender: true,
27
+ token: input.nextSignature,
28
+ };
29
+ }
30
+ /**
31
+ * 判断异步 Mermaid 渲染结果是否仍对应当前 host 源码。
32
+ */
33
+ export function isCurrentMermaidRenderToken(renderToken, currentSignature) {
34
+ return renderToken === currentSignature;
35
+ }
@@ -12,3 +12,8 @@ export declare function isMermaidLanguage(language: string | undefined | null):
12
12
  * 把 mermaid 源码渲染为 SVG 字符串;语法错误时抛出由调用方兜底展示。
13
13
  */
14
14
  export declare function renderMermaidDiagram(code: string): Promise<string>;
15
+ /**
16
+ * Mermaid 11.x 会在 SVG 内写入很长的 style 标签内容;在 contenteditable 预览区里,
17
+ * 部分浏览器会把这些 CSS 当成正文显示。这里保留 SVG 图形,剥离内联样式文本。
18
+ */
19
+ export declare function stripMermaidSvgStyleElements(svg: string): string;