@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.
- package/README.md +24 -5
- package/auto-import.js +2 -2
- package/dist/components/compounds/Combobox.svelte +12 -4
- package/dist/components/compounds/Combobox.svelte.d.ts +4 -1
- package/dist/components/compounds/DatePicker.svelte +19 -4
- package/dist/components/compounds/DatePicker.svelte.d.ts +4 -1
- package/dist/components/compounds/TagInput.svelte +36 -31
- package/dist/components/control-size.d.ts +18 -0
- package/dist/components/control-size.js +18 -0
- package/dist/components/primitives/Input.svelte +4 -25
- package/dist/components/primitives/Select.svelte +4 -10
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +203 -22
- package/dist/components/scenes/ai/document-download.js +19 -2
- package/dist/components/scenes/ai/document-parse.d.ts +25 -2
- package/dist/components/scenes/ai/document-parse.js +137 -11
- package/dist/components/scenes/ai/index.d.ts +1 -1
- package/dist/components/scenes/ai/mermaid-host-sync.d.ts +34 -0
- package/dist/components/scenes/ai/mermaid-host-sync.js +35 -0
- package/dist/components/scenes/ai/mermaid-render.d.ts +5 -0
- package/dist/components/scenes/ai/mermaid-render.js +7 -0
- package/dist/components/scenes/charts/Chart.svelte +152 -0
- package/dist/components/scenes/charts/Chart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/chart-theme.d.ts +23 -0
- package/dist/components/scenes/charts/chart-theme.js +44 -0
- package/dist/components/scenes/charts/chart-types.d.ts +77 -0
- package/dist/components/scenes/charts/chart-types.js +7 -0
- package/dist/components/scenes/charts/index.d.ts +8 -0
- package/dist/components/scenes/charts/index.js +7 -0
- package/dist/components/scenes/charts/renderers/AreaChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/AreaChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/BarChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/BarChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/LineChart.svelte +571 -0
- package/dist/components/scenes/charts/renderers/LineChart.svelte.d.ts +19 -0
- package/dist/components/scenes/charts/renderers/PieChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/PieChart.svelte.d.ts +5 -0
- package/dist/components/scenes/charts/renderers/ScatterChart.svelte +18 -0
- package/dist/components/scenes/charts/renderers/ScatterChart.svelte.d.ts +5 -0
- package/dist/components/scenes/iam/PasswordInput.svelte +4 -15
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +2 -0
- package/dist/messages/en-US.json +2 -0
- package/dist/messages/zh-CN.json +2 -0
- package/dist/types.d.ts +1 -0
- 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 {
|
|
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
|
-
//
|
|
423
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
3176
|
-
|
|
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, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, ''');
|
|
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
|
|
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),
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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;
|