@h-ai/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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.
@@ -21,8 +21,10 @@
21
21
  MarkdownColorFormatRequest,
22
22
  MarkdownInlineFormatKind,
23
23
  MarkdownRewriteAction,
24
+ MarkdownSelectionSnapshot,
24
25
  MarkdownTextAlignKind,
25
26
  } from './document-types.js'
27
+ import { tick } from 'svelte'
26
28
  import { uiM } from '../../../messages.js'
27
29
  import { cn } from '../../../utils.js'
28
30
  import AiDocumentDownloadMenu from './AiDocumentDownloadMenu.svelte'
@@ -33,7 +35,7 @@
33
35
  interface SelectionToolbarPosition {
34
36
  /** 选区工具条相对滚动容器的 top 坐标。 */
35
37
  top: number
36
- /** 选区工具条中心点相对滚动容器的 left 坐标。 */
38
+ /** 选区工具条左侧边缘相对滚动容器的 left 坐标。 */
37
39
  left: number
38
40
  /** 选区工具条应该出现在选区上方还是下方,避免遮挡正文。 */
39
41
  placement: 'top' | 'bottom'
@@ -84,6 +86,28 @@
84
86
  text: string
85
87
  }
86
88
 
89
+ interface ColorSwatchOption {
90
+ /** 颜色值;为空表示恢复默认或清除当前维度。 */
91
+ value: string | null
92
+ /** 展示标题,用于 hover / a11y。 */
93
+ titleKey: 'markdown_background_none'
94
+ }
95
+
96
+ interface HoveredLinkState {
97
+ /** 当前悬停的链接元素。 */
98
+ element: HTMLAnchorElement
99
+ /** 浏览器可直接打开的绝对链接地址。 */
100
+ href: string
101
+ /** 编辑时优先回填原始 href 属性,避免相对地址被强制绝对化。 */
102
+ rawHref: string
103
+ /** 悬停浮层相对滚动容器的 top 坐标。 */
104
+ top: number
105
+ /** 悬停浮层左侧边缘相对滚动容器的 left 坐标。 */
106
+ left: number
107
+ /** 浮层出现在链接上方还是下方。 */
108
+ placement: 'top' | 'bottom'
109
+ }
110
+
87
111
  type SelectionMenuKind = 'rewrite' | 'block' | 'align' | 'link' | 'color'
88
112
 
89
113
  const BLOCK_FORMAT_OPTIONS: Array<{
@@ -94,6 +118,8 @@
94
118
  | 'markdown_format_heading_2'
95
119
  | 'markdown_format_heading_3'
96
120
  | 'markdown_format_heading_4'
121
+ | 'markdown_format_ordered_list'
122
+ | 'markdown_format_bullet_list'
97
123
  shortLabel: string
98
124
  }> = [
99
125
  {
@@ -116,6 +142,16 @@
116
142
  labelKey: 'markdown_format_heading_3',
117
143
  shortLabel: 'H3',
118
144
  },
145
+ {
146
+ value: 'orderedList',
147
+ labelKey: 'markdown_format_ordered_list',
148
+ shortLabel: '1.',
149
+ },
150
+ {
151
+ value: 'bulletList',
152
+ labelKey: 'markdown_format_bullet_list',
153
+ shortLabel: '•',
154
+ },
119
155
  {
120
156
  value: 'heading4',
121
157
  labelKey: 'markdown_format_heading_4',
@@ -158,6 +194,13 @@
158
194
  '#f3f4f6',
159
195
  '#e2e8f0',
160
196
  ] as const
197
+ const BACKGROUND_COLOR_OPTIONS: readonly (typeof BACKGROUND_COLOR_PRESETS[number] | ColorSwatchOption)[] = [
198
+ { value: null, titleKey: 'markdown_background_none' },
199
+ ...BACKGROUND_COLOR_PRESETS,
200
+ ] as const
201
+ const FALLBACK_BLOCK_FORMAT_OPTION = BLOCK_FORMAT_OPTIONS.find(
202
+ option => option.value === 'paragraph',
203
+ ) ?? BLOCK_FORMAT_OPTIONS[0]
161
204
 
162
205
  const DEFAULT_SELECTION_FORMAT_STATE: SelectionFormatState = {
163
206
  blockFormat: 'paragraph',
@@ -176,7 +219,6 @@
176
219
  // 复制前后的图标以内联 SVG 缓存,避免每次点击都重新拼接按钮内容。
177
220
  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>`
178
221
  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>`
179
- const HTML_SNIPPET_REGEX = /<!doctype html>|<html[\s>]|<body[\s>]|<div[\s>]|<main[\s>]/i
180
222
 
181
223
  let {
182
224
  // 暴露给外层的滚动容器引用,用于同步滚动或定位选区工具条。
@@ -197,6 +239,10 @@
197
239
  showCopyButton = true,
198
240
  // 是否显示代码块运行按钮与预览占位。
199
241
  showRunButton = false,
242
+ // 是否在代码块头部显示“代码/预览”切换。
243
+ showCodePreviewToggle = false,
244
+ // 代码预览切换模式下展示在语言标签旁的提示文案。
245
+ codePreviewHint = '',
200
246
  // 是否启用语法高亮。
201
247
  enableHighlight = true,
202
248
  // 是否把换行渲染为 <br>。
@@ -286,6 +332,10 @@
286
332
  placement: 'top',
287
333
  alignment: 'center',
288
334
  })
335
+ // 窄容器下目录会改成悬浮覆盖模式,保存 DOM 引用后才能在定位工具条时主动避让。
336
+ let outlinePanelEl = $state<HTMLElement | null>(null)
337
+ // chrome 节点同时包住主工具条和下拉面板,方便判断焦点是否仍留在浮层内部。
338
+ let selectionChromeEl = $state<HTMLDivElement | null>(null)
289
339
  // 选区工具条自身 DOM,用于根据真实尺寸修正贴边位置。
290
340
  let selectionToolbarEl = $state<HTMLDivElement | null>(null)
291
341
  // 当前展开菜单对应的触发按钮,用来把面板锚定到具体按钮下方/上方。
@@ -306,24 +356,48 @@
306
356
  })
307
357
  // linkDraft 只在链接弹层里暂存输入值,避免每次打开都丢掉正在编辑的链接。
308
358
  let linkDraft = $state('')
359
+ // 链接输入框允许接管焦点,但格式动作仍需要恢复到正文的最后一个有效选区。
360
+ let linkInputEl = $state<HTMLInputElement | null>(null)
361
+ // 链接按钮 DOM 用来在“悬停编辑链接”场景下复用现有链接弹层定位。
362
+ let selectionLinkButtonEl = $state<HTMLButtonElement | null>(null)
363
+ // 原始 DOM Range 在结构没变时恢复最快,也能保留浏览器原生的精确边界。
364
+ let savedSelectionRange = $state<Range | null>(null)
365
+ // 纯文本快照用于 DOM 被局部重建后的兜底恢复,避免只靠 Range 引用失效。
366
+ let savedSelectionSnapshot = $state<MarkdownSelectionSnapshot | null>(null)
309
367
  // 真实尺寸修正只需要排队一帧,避免反复触发 handleSelectionChange 形成抖动。
310
368
  let selectionToolbarMeasurePending = false
311
369
  // 选区刷新统一合并到同一帧,避免 selectionchange / mouseup / blur 连续触发时抖动。
312
370
  let selectionRefreshFrame: number | undefined
371
+ // 同一帧内任一来源要求重新量尺时,都要把这次刷新升级成 remeasure 版本。
313
372
  let selectionRefreshNeedsRemeasure = false
314
373
  // 浏览器在滚动到底部或切换焦点时会短暂给出空选区,这里延迟一拍再关闭工具条。
315
374
  let selectionToolbarCloseTimer: number | undefined
375
+ // 悬停到已配置链接时显示快捷浮层,便于直接打开或编辑。
376
+ let hoveredLinkState = $state<HoveredLinkState | null>(null)
377
+ // 悬停浮层需要拿到真实尺寸后再二次校正位置。
378
+ let hoveredLinkPopoverEl = $state<HTMLDivElement | null>(null)
379
+ // 鼠标从正文移动到悬停浮层时给一小段缓冲,避免浮层闪烁。
380
+ let hoveredLinkHideTimer: number | undefined
316
381
 
317
382
  // code 类型产物通常只有裸代码文本,这里统一包成 fenced block 进入同一条渲染链路。
318
383
  const documentContent = $derived(
319
384
  resolveDocumentMarkdownContent(content, sourceKind, codeLanguage),
320
385
  )
321
386
  // 渲染结果同时提供 HTML、目录和代码块元数据,供顶部目录与代码预览共用。
387
+ const codePreviewToggleEnabled = $derived(
388
+ showCodePreviewToggle && sourceKind === 'code',
389
+ )
390
+ // hint 文案统一做 trim,避免只传空格时仍把占位区域撑开。
391
+ const normalizedCodePreviewHint = $derived(codePreviewHint.trim())
322
392
  const renderResult = $derived(
323
393
  renderMarkdownDocument(documentContent, {
324
394
  enableHighlight,
325
395
  showCopyButton,
326
396
  showRunButton,
397
+ showCodePreviewToggle: codePreviewToggleEnabled,
398
+ codeViewCodeLabel: uiM('markdown_format_code'),
399
+ codeViewPreviewLabel: uiM('file_list_preview'),
400
+ codePreviewHint: normalizedCodePreviewHint,
327
401
  breaks,
328
402
  }),
329
403
  )
@@ -337,6 +411,10 @@
337
411
  const resolvedRewriteActions = $derived(
338
412
  resolveRewriteActions(rewriteActions, onrewrite),
339
413
  )
414
+ // 只有一个改写动作时直接渲染成按钮,多动作才保留菜单,减少一次点击。
415
+ const directRewriteAction = $derived(
416
+ resolvedRewriteActions.length === 1 ? resolvedRewriteActions[0] : null,
417
+ )
340
418
  // 新版工具条支持 paragraph / heading1-4,优先走细粒度回调,避免把旧接口误当成新接口调用。
341
419
  const richBlockFormattingEnabled = $derived(Boolean(onapplyblockstyle))
342
420
  // 旧版 heading / bullet 回调仍然保留,用于兼容外部还没迁移的调用方。
@@ -364,13 +442,14 @@
364
442
  const activeBlockFormatOption = $derived(
365
443
  BLOCK_FORMAT_OPTIONS.find(
366
444
  option => option.value === selectionFormatState.blockFormat,
367
- ) ?? BLOCK_FORMAT_OPTIONS[0],
445
+ ) ?? FALLBACK_BLOCK_FORMAT_OPTION,
368
446
  )
369
447
  // readerDocumentClass 只负责正文文章区域,不和外层容器类名混用。
370
448
  const readerDocumentClass = $derived(
371
449
  cn(
372
450
  'hai-markdown',
373
451
  'hai-markdown-document',
452
+ sourceKind === 'code' ? 'hai-markdown-code-only' : '',
374
453
  editable ? 'hai-markdown-editable' : '',
375
454
  ),
376
455
  )
@@ -416,7 +495,9 @@
416
495
 
417
496
  $effect(() => {
418
497
  void documentContent
419
- closeSelectionToolbar()
498
+ codePreviews = {}
499
+ hoveredLinkState = null
500
+ queueSelectionRefresh(true)
420
501
  })
421
502
 
422
503
  $effect(() => {
@@ -441,6 +522,10 @@
441
522
  if (selectionToolbarCloseTimer) {
442
523
  window.clearTimeout(selectionToolbarCloseTimer)
443
524
  }
525
+
526
+ if (hoveredLinkHideTimer) {
527
+ window.clearTimeout(hoveredLinkHideTimer)
528
+ }
444
529
  }
445
530
  })
446
531
 
@@ -463,6 +548,64 @@
463
548
  }
464
549
  })
465
550
 
551
+ $effect(() => {
552
+ if (
553
+ typeof document === 'undefined'
554
+ || !selectionToolbarVisible
555
+ ) {
556
+ return
557
+ }
558
+
559
+ const handleDocumentPointerDown = (event: MouseEvent): void => {
560
+ const target = event.target
561
+ if (!(target instanceof Node)) {
562
+ return
563
+ }
564
+
565
+ if (selectionChromeEl?.contains(target)) {
566
+ return
567
+ }
568
+
569
+ closeSelectionToolbar()
570
+ }
571
+
572
+ document.addEventListener('mousedown', handleDocumentPointerDown, true)
573
+
574
+ return () => {
575
+ document.removeEventListener('mousedown', handleDocumentPointerDown, true)
576
+ }
577
+ })
578
+
579
+ $effect(() => {
580
+ if (typeof window === 'undefined' || activeSelectionMenu !== 'link' || !linkInputEl) {
581
+ return
582
+ }
583
+
584
+ const nextLinkInput = linkInputEl
585
+ const focusFrame = window.requestAnimationFrame(() => {
586
+ nextLinkInput.focus()
587
+ nextLinkInput.select()
588
+ })
589
+
590
+ return () => {
591
+ window.cancelAnimationFrame(focusFrame)
592
+ }
593
+ })
594
+
595
+ $effect(() => {
596
+ if (typeof window === 'undefined' || !hoveredLinkState || !hoveredLinkPopoverEl) {
597
+ return
598
+ }
599
+
600
+ const measureFrame = window.requestAnimationFrame(() => {
601
+ syncHoveredLinkPosition()
602
+ })
603
+
604
+ return () => {
605
+ window.cancelAnimationFrame(measureFrame)
606
+ }
607
+ })
608
+
466
609
  function resolveRewriteActions(
467
610
  actions: MarkdownRewriteAction[],
468
611
  handler?: AiDocumentEditorProps['onrewrite'],
@@ -483,6 +626,8 @@
483
626
  ]
484
627
  }
485
628
 
629
+ const HTML_DOCUMENT_PATTERN = /<!doctype html>|<html[\s>]|<body[\s>]|<div[\s>]|<main[\s>]/i
630
+
486
631
  async function copyRawContent(): Promise<boolean> {
487
632
  try {
488
633
  await navigator.clipboard.writeText(content)
@@ -549,9 +694,7 @@
549
694
  }
550
695
 
551
696
  function looksLikeHtml(code: string): boolean {
552
- return HTML_SNIPPET_REGEX.test(
553
- code,
554
- )
697
+ return HTML_DOCUMENT_PATTERN.test(code)
555
698
  }
556
699
 
557
700
  function buildJavaScriptPreview(code: string): string {
@@ -668,6 +811,38 @@ ${safeCode}
668
811
  return undefined
669
812
  }
670
813
 
814
+ function formatPreviewLanguageLabel(language: string | undefined): string | null {
815
+ const normalized = language?.trim().toLocaleLowerCase()
816
+ if (!normalized) {
817
+ return null
818
+ }
819
+
820
+ if (
821
+ normalized === 'javascript'
822
+ || normalized === 'js'
823
+ || normalized === 'mjs'
824
+ ) {
825
+ return 'JavaScript'
826
+ }
827
+
828
+ if (normalized === 'markdown' || normalized === 'md') {
829
+ return 'Markdown'
830
+ }
831
+
832
+ return normalized.toUpperCase()
833
+ }
834
+
835
+ function resolvePreviewUnavailableMessage(language: string | undefined): string {
836
+ const languageLabel = formatPreviewLanguageLabel(language)
837
+ if (!languageLabel) {
838
+ return uiM('markdown_run_unavailable')
839
+ }
840
+
841
+ return uiM('markdown_run_unavailable_language', {
842
+ language: languageLabel,
843
+ })
844
+ }
845
+
671
846
  async function runCodeBlock(blockId: string): Promise<void> {
672
847
  const codeBlock = lookupCodeBlock(blockId)
673
848
  if (!codeBlock) {
@@ -698,7 +873,7 @@ ${safeCode}
698
873
  ...codePreviews,
699
874
  [blockId]: {
700
875
  status: 'error',
701
- error: uiM('markdown_run_unavailable'),
876
+ error: resolvePreviewUnavailableMessage(request.language),
702
877
  },
703
878
  }
704
879
  return
@@ -724,6 +899,24 @@ ${safeCode}
724
899
  }
725
900
  }
726
901
 
902
+ function setCodeBlockView(
903
+ codeBlock: HTMLElement,
904
+ view: 'code' | 'preview',
905
+ ): void {
906
+ codeBlock.dataset.codeView = view
907
+ const toggleButtons = codeBlock.querySelectorAll<HTMLButtonElement>(
908
+ '[data-code-view-toggle]',
909
+ )
910
+ for (const button of toggleButtons) {
911
+ const isActive = button.dataset.codeView === view
912
+ button.setAttribute('aria-pressed', isActive ? 'true' : 'false')
913
+ }
914
+ }
915
+
916
+ /**
917
+ * 统一承接正文里通过 `{@html}` 注入的交互节点点击。
918
+ * 这些按钮和链接不受 Svelte 逐个绑定管理,因此在容器级别做代理更稳妥。
919
+ */
727
920
  async function handleClick(event: MouseEvent): Promise<void> {
728
921
  const target = event.target as HTMLElement
729
922
  const copyButton = target.closest(
@@ -734,12 +927,48 @@ ${safeCode}
734
927
  return
735
928
  }
736
929
 
930
+ const viewButton = target.closest(
931
+ '[data-code-view-toggle]',
932
+ ) as HTMLButtonElement | null
933
+ const viewCodeBlock = viewButton?.closest<HTMLElement>('.hai-md-code-block')
934
+ const viewCodeBlockId = viewButton?.dataset.codeBlockId
935
+ const nextView = viewButton?.dataset.codeView === 'preview'
936
+ ? 'preview'
937
+ : 'code'
938
+ if (viewButton && viewCodeBlock) {
939
+ setCodeBlockView(viewCodeBlock, nextView)
940
+ if (
941
+ nextView === 'preview'
942
+ && viewCodeBlockId
943
+ && codePreviews[viewCodeBlockId]?.status !== 'ready'
944
+ ) {
945
+ await runCodeBlock(viewCodeBlockId)
946
+ }
947
+ return
948
+ }
949
+
737
950
  const runButton = target.closest(
738
951
  '[data-run-code]',
739
952
  ) as HTMLButtonElement | null
740
953
  const codeBlockId = runButton?.dataset.codeBlockId
741
954
  if (runButton && codeBlockId) {
742
955
  await runCodeBlock(codeBlockId)
956
+ return
957
+ }
958
+
959
+ const link = target.closest('a[href]') as HTMLAnchorElement | null
960
+ if (editable && link && previewHost?.contains(link)) {
961
+ const href = link.href.trim()
962
+ if (!href) {
963
+ return
964
+ }
965
+
966
+ event.preventDefault()
967
+ window.open(
968
+ href,
969
+ link.target || '_blank',
970
+ 'noopener,noreferrer',
971
+ )
743
972
  }
744
973
  }
745
974
 
@@ -775,12 +1004,12 @@ ${safeCode}
775
1004
  }
776
1005
 
777
1006
  if (preview.status === 'running') {
778
- host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-head">${escapePreviewText(uiM('markdown_running'))}</div><div class="hai-md-preview-loading"></div></div>`
1007
+ host.innerHTML = `<div class="hai-md-preview-card"><div class="hai-md-preview-empty"><div class="hai-md-preview-empty-title">${escapePreviewText(uiM('markdown_running'))}</div><div class="hai-md-preview-loading"></div></div></div>`
779
1008
  return
780
1009
  }
781
1010
 
782
1011
  if (preview.status === 'error') {
783
- host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-error"><div class="hai-md-preview-head">${escapePreviewText(uiM('markdown_run_failed'))}</div><pre>${escapePreviewText(preview.error ?? uiM('markdown_run_failed'))}</pre></div>`
1012
+ host.innerHTML = `<div class="hai-md-preview-card hai-md-preview-error"><div class="hai-md-preview-empty"><div class="hai-md-preview-empty-title">${escapePreviewText(uiM('markdown_run_failed'))}</div><p class="hai-md-preview-empty-text">${escapePreviewText(preview.error ?? uiM('markdown_run_failed'))}</p></div></div>`
784
1013
  return
785
1014
  }
786
1015
 
@@ -881,7 +1110,18 @@ ${safeCode}
881
1110
  }
882
1111
 
883
1112
  function readBlockFormat(element: HTMLElement | null): MarkdownBlockStyleKind {
884
- const tagName = getClosestBlockElement(element)?.tagName.toLowerCase()
1113
+ const block = getClosestBlockElement(element)
1114
+ const tagName = block?.tagName.toLowerCase()
1115
+
1116
+ if (tagName === 'li') {
1117
+ const listTag = block?.parentElement?.tagName.toLowerCase()
1118
+ if (listTag === 'ol') {
1119
+ return 'orderedList'
1120
+ }
1121
+ if (listTag === 'ul') {
1122
+ return 'bulletList'
1123
+ }
1124
+ }
885
1125
 
886
1126
  if (tagName === 'h1') {
887
1127
  return 'heading1'
@@ -1008,6 +1248,370 @@ ${safeCode}
1008
1248
  }
1009
1249
  }
1010
1250
 
1251
+ /**
1252
+ * 下面这组 helper 在 contenteditable DOM 与“正文纯文本偏移”之间来回映射。
1253
+ * 选区工具条一旦失焦或内容局部重渲染,就靠它们把原选区重新定位回来。
1254
+ */
1255
+ function getNodeTextLength(node: Node | null | undefined): number {
1256
+ if (!node) {
1257
+ return 0
1258
+ }
1259
+
1260
+ if (node.nodeType === Node.TEXT_NODE) {
1261
+ return node.textContent?.length ?? 0
1262
+ }
1263
+
1264
+ let length = 0
1265
+ for (const childNode of Array.from(node.childNodes)) {
1266
+ length += getNodeTextLength(childNode)
1267
+ }
1268
+
1269
+ return length
1270
+ }
1271
+
1272
+ function getTextOffsetWithinNode(node: Node, offset: number): number {
1273
+ if (node.nodeType === Node.TEXT_NODE) {
1274
+ return Math.max(0, Math.min(offset, node.textContent?.length ?? 0))
1275
+ }
1276
+
1277
+ const childNodes = Array.from(node.childNodes)
1278
+ const nextOffset = Math.max(0, Math.min(offset, childNodes.length))
1279
+ let length = 0
1280
+
1281
+ for (const childNode of childNodes.slice(0, nextOffset)) {
1282
+ length += getNodeTextLength(childNode)
1283
+ }
1284
+
1285
+ return length
1286
+ }
1287
+
1288
+ function getTextOffsetBeforeNode(root: Node, target: Node): number | null {
1289
+ let length = 0
1290
+
1291
+ const visit = (node: Node): boolean => {
1292
+ if (node === target) {
1293
+ return true
1294
+ }
1295
+
1296
+ if (node.nodeType === Node.TEXT_NODE) {
1297
+ length += node.textContent?.length ?? 0
1298
+ return false
1299
+ }
1300
+
1301
+ for (const childNode of Array.from(node.childNodes)) {
1302
+ if (visit(childNode)) {
1303
+ return true
1304
+ }
1305
+ }
1306
+
1307
+ return false
1308
+ }
1309
+
1310
+ return visit(root) ? length : null
1311
+ }
1312
+
1313
+ function getTextOffsetInPreview(node: Node, offset: number): number | null {
1314
+ if (!previewHost || (node !== previewHost && !previewHost.contains(node))) {
1315
+ return null
1316
+ }
1317
+
1318
+ const prefixLength = getTextOffsetBeforeNode(previewHost, node)
1319
+ if (prefixLength === null) {
1320
+ return null
1321
+ }
1322
+
1323
+ return prefixLength + getTextOffsetWithinNode(node, offset)
1324
+ }
1325
+
1326
+ function resolveTextPositionInPreview(
1327
+ offset: number,
1328
+ ): { node: Node, offset: number } | null {
1329
+ if (!previewHost) {
1330
+ return null
1331
+ }
1332
+
1333
+ const targetOffset = Math.max(0, offset)
1334
+ const walker = document.createTreeWalker(previewHost, NodeFilter.SHOW_TEXT)
1335
+ let current = walker.nextNode()
1336
+ let accumulatedLength = 0
1337
+ let lastTextNode: Node | null = null
1338
+
1339
+ while (current) {
1340
+ const textLength = current.textContent?.length ?? 0
1341
+ if (targetOffset <= accumulatedLength + textLength) {
1342
+ return {
1343
+ node: current,
1344
+ offset: Math.max(0, targetOffset - accumulatedLength),
1345
+ }
1346
+ }
1347
+
1348
+ accumulatedLength += textLength
1349
+ lastTextNode = current
1350
+ current = walker.nextNode()
1351
+ }
1352
+
1353
+ if (lastTextNode) {
1354
+ return {
1355
+ node: lastTextNode,
1356
+ offset: lastTextNode.textContent?.length ?? 0,
1357
+ }
1358
+ }
1359
+
1360
+ return {
1361
+ node: previewHost,
1362
+ offset: Math.min(targetOffset, previewHost.childNodes.length),
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * 把当前 Range 压缩成纯文本偏移快照。
1368
+ * 只记录非空选区,避免工具条动作把折叠光标也当成可恢复选区。
1369
+ */
1370
+ function capturePreviewSelectionSnapshot(
1371
+ range: Range,
1372
+ ): MarkdownSelectionSnapshot | null {
1373
+ if (!previewHost) {
1374
+ return null
1375
+ }
1376
+
1377
+ const start = getTextOffsetInPreview(range.startContainer, range.startOffset)
1378
+ const end = getTextOffsetInPreview(range.endContainer, range.endOffset)
1379
+ const text = range.toString()
1380
+
1381
+ if (start === null || end === null || end <= start || !text.trim()) {
1382
+ return null
1383
+ }
1384
+
1385
+ return { start, end, text }
1386
+ }
1387
+
1388
+ /**
1389
+ * 用快照重新读取一遍当前正文中的文本,用来判断 DOM 变化后原 offsets 是否仍然可信。
1390
+ */
1391
+ function readTextFromSnapshot(
1392
+ snapshot: MarkdownSelectionSnapshot,
1393
+ ): string | null {
1394
+ const startPosition = resolveTextPositionInPreview(snapshot.start)
1395
+ const endPosition = resolveTextPositionInPreview(snapshot.end)
1396
+ if (!startPosition || !endPosition) {
1397
+ return null
1398
+ }
1399
+
1400
+ try {
1401
+ const range = document.createRange()
1402
+ range.setStart(startPosition.node, startPosition.offset)
1403
+ range.setEnd(endPosition.node, endPosition.offset)
1404
+ return range.toString()
1405
+ }
1406
+ catch {
1407
+ return null
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * 当原 offsets 对应的文本已经变了时,按“离旧起点最近”的同文本片段回退。
1413
+ * 这样比只取第一个命中更接近用户刚才真实操作的位置。
1414
+ */
1415
+ function findClosestSelectionOffsets(
1416
+ snapshot: MarkdownSelectionSnapshot,
1417
+ ): { start: number, end: number } | null {
1418
+ if (!previewHost || !snapshot.text) {
1419
+ return null
1420
+ }
1421
+
1422
+ const textContent = previewHost.textContent ?? ''
1423
+ let bestMatchStart = -1
1424
+ let smallestDistance = Number.POSITIVE_INFINITY
1425
+ let searchIndex = textContent.indexOf(snapshot.text)
1426
+
1427
+ while (searchIndex >= 0) {
1428
+ const distance = Math.abs(searchIndex - snapshot.start)
1429
+ if (distance < smallestDistance) {
1430
+ smallestDistance = distance
1431
+ bestMatchStart = searchIndex
1432
+ }
1433
+
1434
+ searchIndex = textContent.indexOf(snapshot.text, searchIndex + 1)
1435
+ }
1436
+
1437
+ if (bestMatchStart < 0) {
1438
+ return null
1439
+ }
1440
+
1441
+ return {
1442
+ start: bestMatchStart,
1443
+ end: bestMatchStart + snapshot.text.length,
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * 优先按纯文本偏移恢复选区;如果 DOM 结构变动导致 offsets 漂移,再退回最近文本片段。
1449
+ * 这一步会主动把焦点拉回正文,保证后续格式化命令仍作用于内容区。
1450
+ */
1451
+ function restoreSelectionFromSnapshot(
1452
+ snapshot: MarkdownSelectionSnapshot,
1453
+ ): boolean {
1454
+ if (typeof window === 'undefined' || !previewHost) {
1455
+ return false
1456
+ }
1457
+
1458
+ const selection = window.getSelection()
1459
+ if (!selection || snapshot.end <= snapshot.start) {
1460
+ return false
1461
+ }
1462
+
1463
+ let nextOffsets = {
1464
+ start: snapshot.start,
1465
+ end: snapshot.end,
1466
+ }
1467
+ const currentText = readTextFromSnapshot(snapshot)
1468
+ if (snapshot.text && currentText !== snapshot.text) {
1469
+ const matchedOffsets = findClosestSelectionOffsets(snapshot)
1470
+ if (!matchedOffsets) {
1471
+ return false
1472
+ }
1473
+
1474
+ nextOffsets = matchedOffsets
1475
+ }
1476
+
1477
+ const startPosition = resolveTextPositionInPreview(nextOffsets.start)
1478
+ const endPosition = resolveTextPositionInPreview(nextOffsets.end)
1479
+ if (!startPosition || !endPosition) {
1480
+ return false
1481
+ }
1482
+
1483
+ try {
1484
+ const range = document.createRange()
1485
+ range.setStart(startPosition.node, startPosition.offset)
1486
+ range.setEnd(endPosition.node, endPosition.offset)
1487
+ previewHost.focus()
1488
+ selection.removeAllRanges()
1489
+ selection.addRange(range)
1490
+ return true
1491
+ }
1492
+ catch {
1493
+ return false
1494
+ }
1495
+ }
1496
+
1497
+ /**
1498
+ * 纯文本匹配是最后一道兜底,只在 Range 和偏移快照都失效时使用。
1499
+ * 它无法区分完全相同的多个片段,所以只适合作为尽力恢复方案。
1500
+ */
1501
+ function restoreSelectionByTextMatch(text: string): boolean {
1502
+ if (typeof window === 'undefined' || !previewHost || !text.trim()) {
1503
+ return false
1504
+ }
1505
+
1506
+ const selection = window.getSelection()
1507
+ if (!selection) {
1508
+ return false
1509
+ }
1510
+
1511
+ const walker = document.createTreeWalker(previewHost, NodeFilter.SHOW_TEXT)
1512
+ let current = walker.nextNode()
1513
+
1514
+ while (current) {
1515
+ const content = current.textContent ?? ''
1516
+ const start = content.indexOf(text)
1517
+ if (start >= 0) {
1518
+ try {
1519
+ const range = document.createRange()
1520
+ range.setStart(current, start)
1521
+ range.setEnd(current, start + text.length)
1522
+ previewHost.focus()
1523
+ selection.removeAllRanges()
1524
+ selection.addRange(range)
1525
+ return true
1526
+ }
1527
+ catch {
1528
+ return false
1529
+ }
1530
+ }
1531
+
1532
+ current = walker.nextNode()
1533
+ }
1534
+
1535
+ return false
1536
+ }
1537
+
1538
+ /**
1539
+ * 工具条包含输入框和下拉面板,焦点落在内部时不应把“正文失焦”误判成关闭信号。
1540
+ */
1541
+ function isSelectionChromeFocused(): boolean {
1542
+ if (typeof document === 'undefined' || !selectionChromeEl) {
1543
+ return false
1544
+ }
1545
+
1546
+ const activeElement = document.activeElement
1547
+ return activeElement instanceof HTMLElement && selectionChromeEl.contains(activeElement)
1548
+ }
1549
+
1550
+ /**
1551
+ * 同时保存浏览器原生 Range 和纯文本快照。
1552
+ * 前者恢复精度更高,后者能跨 DOM 重建,两者组合能覆盖大多数编辑场景。
1553
+ */
1554
+ function rememberPreviewSelection(range: Range): void {
1555
+ savedSelectionRange = range.cloneRange()
1556
+ savedSelectionSnapshot = capturePreviewSelectionSnapshot(range)
1557
+ }
1558
+
1559
+ /**
1560
+ * 选区恢复按“快照 -> 原始 Range -> 文本兜底”三层顺序尝试。
1561
+ * 这样既优先命中最精确的位置,也能在内容轻微重排后保留工具条操作连续性。
1562
+ */
1563
+ function restoreSavedPreviewSelection(): boolean {
1564
+ if (savedSelectionSnapshot && restoreSelectionFromSnapshot(savedSelectionSnapshot)) {
1565
+ return true
1566
+ }
1567
+
1568
+ if (typeof window === 'undefined' || !previewHost || !savedSelectionRange) {
1569
+ const fallbackText = savedSelectionSnapshot?.text || selectedText
1570
+ return restoreSelectionByTextMatch(fallbackText)
1571
+ }
1572
+
1573
+ const selection = window.getSelection()
1574
+ if (!selection) {
1575
+ return false
1576
+ }
1577
+
1578
+ try {
1579
+ const restoredRange = savedSelectionRange.cloneRange()
1580
+ if (
1581
+ !previewHost.contains(restoredRange.startContainer)
1582
+ || !previewHost.contains(restoredRange.endContainer)
1583
+ ) {
1584
+ return false
1585
+ }
1586
+
1587
+ previewHost.focus()
1588
+ selection.removeAllRanges()
1589
+ selection.addRange(restoredRange)
1590
+ return true
1591
+ }
1592
+ catch {
1593
+ const fallbackText = savedSelectionSnapshot?.text || selectedText
1594
+ return restoreSelectionByTextMatch(fallbackText)
1595
+ }
1596
+ }
1597
+
1598
+ /**
1599
+ * 工具条动作触发前先同步一次真实选区,避免点击按钮后浏览器把正文选区清掉。
1600
+ */
1601
+ function syncSelectionForToolbarAction(): boolean {
1602
+ const previewSelection = readPreviewSelectionState()
1603
+ if (previewSelection) {
1604
+ rememberPreviewSelection(previewSelection.range)
1605
+ selectedText = previewSelection.text
1606
+ return true
1607
+ }
1608
+
1609
+ return restoreSavedPreviewSelection()
1610
+ }
1611
+
1612
+ /**
1613
+ * 所有关闭动作都走同一个 timer,方便菜单 hover、输入框 focus 等场景随时撤销关闭。
1614
+ */
1011
1615
  function clearPendingSelectionToolbarClose(): void {
1012
1616
  if (typeof window === 'undefined' || !selectionToolbarCloseTimer) {
1013
1617
  return
@@ -1017,6 +1621,10 @@ ${safeCode}
1017
1621
  selectionToolbarCloseTimer = undefined
1018
1622
  }
1019
1623
 
1624
+ /**
1625
+ * 关闭工具条刻意延后一小拍,给菜单 hover、输入框 focus 和选区回流留出时间。
1626
+ * 只要子菜单还开着,或焦点仍在 chrome 里,就继续保留当前工具条。
1627
+ */
1020
1628
  function scheduleSelectionToolbarClose(): void {
1021
1629
  if (typeof window === 'undefined') {
1022
1630
  closeSelectionToolbar()
@@ -1028,15 +1636,26 @@ ${safeCode}
1028
1636
  return
1029
1637
  }
1030
1638
 
1639
+ if (activeSelectionMenu) {
1640
+ return
1641
+ }
1642
+
1031
1643
  clearPendingSelectionToolbarClose()
1032
1644
  selectionToolbarCloseTimer = window.setTimeout(() => {
1033
1645
  selectionToolbarCloseTimer = undefined
1646
+ if (activeSelectionMenu || isSelectionChromeFocused()) {
1647
+ return
1648
+ }
1649
+
1034
1650
  if (!readPreviewSelectionState()) {
1035
1651
  closeSelectionToolbar()
1036
1652
  }
1037
- }, 120)
1653
+ }, 600)
1038
1654
  }
1039
1655
 
1656
+ /**
1657
+ * 选区变化可能在一帧内收到多次事件,这里统一合并,避免定位和状态读取来回抖动。
1658
+ */
1040
1659
  function queueSelectionRefresh(remeasured = false): void {
1041
1660
  if (typeof window === 'undefined') {
1042
1661
  return
@@ -1055,6 +1674,9 @@ ${safeCode}
1055
1674
  })
1056
1675
  }
1057
1676
 
1677
+ /**
1678
+ * 子菜单首次打开前拿不到真实宽度,先给一个稳定的经验值参与贴边计算。
1679
+ */
1058
1680
  function estimateSelectionMenuWidth(menu: SelectionMenuKind): number {
1059
1681
  if (menu === 'color' || menu === 'link') {
1060
1682
  return 256
@@ -1067,6 +1689,9 @@ ${safeCode}
1067
1689
  return 288
1068
1690
  }
1069
1691
 
1692
+ /**
1693
+ * 下拉面板以触发按钮为锚点,但最终位置要限制在工具条宽度内,避免菜单越界裁切。
1694
+ */
1070
1695
  function updateSelectionMenuAnchor(menu: SelectionMenuKind): void {
1071
1696
  if (!selectionToolbarEl || !activeSelectionMenuTrigger) {
1072
1697
  selectionMenuLeft = 0
@@ -1104,6 +1729,9 @@ ${safeCode}
1104
1729
  selectionMenuAlignment = 'center'
1105
1730
  }
1106
1731
 
1732
+ /**
1733
+ * 工具条初次出现时尺寸还没稳定,下一帧再量一次可以避免首帧贴边位置偏差。
1734
+ */
1107
1735
  function scheduleSelectionToolbarMeasurement(): void {
1108
1736
  if (
1109
1737
  typeof window === 'undefined'
@@ -1122,7 +1750,11 @@ ${safeCode}
1122
1750
  })
1123
1751
  }
1124
1752
 
1753
+ /**
1754
+ * 子菜单统一从这里开关,顺手记录触发按钮和链接初始值,保证定位与默认值一致。
1755
+ */
1125
1756
  function toggleSelectionMenu(menu: SelectionMenuKind, event?: MouseEvent): void {
1757
+ clearPendingSelectionToolbarClose()
1126
1758
  const opening = activeSelectionMenu !== menu
1127
1759
  activeSelectionMenu = opening ? menu : null
1128
1760
  activeSelectionMenuTrigger = opening && event?.currentTarget instanceof HTMLElement
@@ -1136,6 +1768,9 @@ ${safeCode}
1136
1768
  }
1137
1769
  }
1138
1770
 
1771
+ /**
1772
+ * 关闭时把选区相关缓存一并清干净,避免下一次打开还误用上一次的链接和格式状态。
1773
+ */
1139
1774
  function closeSelectionToolbar(): void {
1140
1775
  clearPendingSelectionToolbarClose()
1141
1776
  selectionToolbarVisible = false
@@ -1145,9 +1780,249 @@ ${safeCode}
1145
1780
  selectionMenuAlignment = 'center'
1146
1781
  selectedText = ''
1147
1782
  linkDraft = ''
1783
+ savedSelectionRange = null
1784
+ savedSelectionSnapshot = null
1148
1785
  selectionFormatState = { ...DEFAULT_SELECTION_FORMAT_STATE }
1149
1786
  }
1150
1787
 
1788
+ /**
1789
+ * 面板上的按钮点击不应让浏览器先把正文选区清掉。
1790
+ * 但输入框要保留原生 focus 行为,方便直接编辑链接。
1791
+ */
1792
+ function handleSelectionPanelMouseDown(event: MouseEvent): void {
1793
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1794
+ return
1795
+ }
1796
+
1797
+ event.preventDefault()
1798
+ }
1799
+
1800
+ function clearPendingHoveredLinkHide(): void {
1801
+ if (typeof window === 'undefined' || !hoveredLinkHideTimer) {
1802
+ return
1803
+ }
1804
+
1805
+ window.clearTimeout(hoveredLinkHideTimer)
1806
+ hoveredLinkHideTimer = undefined
1807
+ }
1808
+
1809
+ function closeHoveredLink(): void {
1810
+ clearPendingHoveredLinkHide()
1811
+ hoveredLinkState = null
1812
+ }
1813
+
1814
+ function scheduleHoveredLinkHide(): void {
1815
+ if (typeof window === 'undefined') {
1816
+ closeHoveredLink()
1817
+ return
1818
+ }
1819
+
1820
+ clearPendingHoveredLinkHide()
1821
+ hoveredLinkHideTimer = window.setTimeout(() => {
1822
+ hoveredLinkHideTimer = undefined
1823
+ hoveredLinkState = null
1824
+ }, 140)
1825
+ }
1826
+
1827
+ function resolveHoveredLinkTargets(
1828
+ link: HTMLAnchorElement,
1829
+ ): { href: string, rawHref: string } | null {
1830
+ const rawHref = link.getAttribute('href')?.trim() ?? ''
1831
+ const href = link.href.trim() || rawHref
1832
+ if (!href) {
1833
+ return null
1834
+ }
1835
+
1836
+ return {
1837
+ href,
1838
+ rawHref: rawHref || href,
1839
+ }
1840
+ }
1841
+
1842
+ function measureHoveredLinkPopover(): { width: number, height: number } {
1843
+ const width = hoveredLinkPopoverEl
1844
+ ? Math.max(
1845
+ hoveredLinkPopoverEl.offsetWidth,
1846
+ hoveredLinkPopoverEl.clientWidth,
1847
+ )
1848
+ : 92
1849
+ const height = hoveredLinkPopoverEl
1850
+ ? Math.max(
1851
+ hoveredLinkPopoverEl.offsetHeight,
1852
+ hoveredLinkPopoverEl.clientHeight,
1853
+ )
1854
+ : 40
1855
+
1856
+ return { width, height }
1857
+ }
1858
+
1859
+ function buildHoveredLinkState(
1860
+ link: HTMLAnchorElement,
1861
+ ): HoveredLinkState | null {
1862
+ if (!editorScrollHost || !previewHost || !previewHost.contains(link)) {
1863
+ return null
1864
+ }
1865
+
1866
+ const targets = resolveHoveredLinkTargets(link)
1867
+ if (!targets) {
1868
+ return null
1869
+ }
1870
+
1871
+ const hostRect = editorScrollHost.getBoundingClientRect()
1872
+ const linkRect = link.getBoundingClientRect()
1873
+ const { width, height } = measureHoveredLinkPopover()
1874
+ const offset = 8
1875
+ const horizontalPadding = 16
1876
+ const topSpace = linkRect.top - hostRect.top
1877
+ const bottomSpace = hostRect.bottom - linkRect.bottom
1878
+ const placement: HoveredLinkState['placement']
1879
+ = topSpace >= height + offset || bottomSpace < height + offset
1880
+ ? 'top'
1881
+ : 'bottom'
1882
+ const center = linkRect.left - hostRect.left + linkRect.width / 2
1883
+ const maxLeft = Math.max(
1884
+ horizontalPadding,
1885
+ editorScrollHost.clientWidth - horizontalPadding - width,
1886
+ )
1887
+ const left = Math.min(
1888
+ Math.max(center - width / 2, horizontalPadding),
1889
+ maxLeft,
1890
+ )
1891
+ const top = placement === 'top'
1892
+ ? editorScrollHost.scrollTop + linkRect.top - hostRect.top - height - offset
1893
+ : editorScrollHost.scrollTop + linkRect.bottom - hostRect.top + offset
1894
+
1895
+ return {
1896
+ element: link,
1897
+ href: targets.href,
1898
+ rawHref: targets.rawHref,
1899
+ top: Math.max(8, top),
1900
+ left,
1901
+ placement,
1902
+ }
1903
+ }
1904
+
1905
+ function syncHoveredLinkPosition(): void {
1906
+ if (!hoveredLinkState) {
1907
+ return
1908
+ }
1909
+
1910
+ if (!hoveredLinkState.element.isConnected) {
1911
+ closeHoveredLink()
1912
+ return
1913
+ }
1914
+
1915
+ const nextState = buildHoveredLinkState(hoveredLinkState.element)
1916
+ if (!nextState) {
1917
+ closeHoveredLink()
1918
+ return
1919
+ }
1920
+
1921
+ hoveredLinkState = nextState
1922
+ }
1923
+
1924
+ function resolveColorOptionValue(colorOption: string | ColorSwatchOption): string | null {
1925
+ return typeof colorOption === 'string' ? colorOption : colorOption.value
1926
+ }
1927
+
1928
+ function updateHoveredLink(link: HTMLAnchorElement | null): void {
1929
+ if (!editable || !onapplylink || selectionToolbarVisible) {
1930
+ closeHoveredLink()
1931
+ return
1932
+ }
1933
+
1934
+ if (!link) {
1935
+ scheduleHoveredLinkHide()
1936
+ return
1937
+ }
1938
+
1939
+ const nextState = buildHoveredLinkState(link)
1940
+ if (!nextState) {
1941
+ scheduleHoveredLinkHide()
1942
+ return
1943
+ }
1944
+
1945
+ clearPendingHoveredLinkHide()
1946
+ hoveredLinkState = nextState
1947
+ }
1948
+
1949
+ function openHoveredLink(): void {
1950
+ if (typeof window === 'undefined' || !hoveredLinkState?.href) {
1951
+ return
1952
+ }
1953
+
1954
+ window.open(
1955
+ hoveredLinkState.href,
1956
+ hoveredLinkState.element.target || '_blank',
1957
+ 'noopener,noreferrer',
1958
+ )
1959
+ }
1960
+
1961
+ function createRangeForLinkText(link: HTMLAnchorElement): Range {
1962
+ const range = document.createRange()
1963
+ const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT)
1964
+ let current = walker.nextNode()
1965
+ let firstTextNode: Node | null = null
1966
+ let lastTextNode: Node | null = null
1967
+
1968
+ while (current) {
1969
+ if ((current.textContent ?? '').length > 0) {
1970
+ firstTextNode ??= current
1971
+ lastTextNode = current
1972
+ }
1973
+
1974
+ current = walker.nextNode()
1975
+ }
1976
+
1977
+ if (!firstTextNode || !lastTextNode) {
1978
+ range.selectNodeContents(link)
1979
+ return range
1980
+ }
1981
+
1982
+ range.setStart(firstTextNode, 0)
1983
+ range.setEnd(lastTextNode, lastTextNode.textContent?.length ?? 0)
1984
+ return range
1985
+ }
1986
+
1987
+ async function editHoveredLink(): Promise<void> {
1988
+ if (
1989
+ typeof window === 'undefined'
1990
+ || !previewHost
1991
+ || !onapplylink
1992
+ || !hoveredLinkState
1993
+ ) {
1994
+ return
1995
+ }
1996
+
1997
+ const link = hoveredLinkState.element
1998
+ const linkHref = hoveredLinkState.rawHref
1999
+ if (!previewHost.contains(link)) {
2000
+ closeHoveredLink()
2001
+ return
2002
+ }
2003
+
2004
+ const selection = window.getSelection()
2005
+ if (!selection) {
2006
+ return
2007
+ }
2008
+
2009
+ const range = createRangeForLinkText(link)
2010
+ previewHost.focus()
2011
+ selection.removeAllRanges()
2012
+ selection.addRange(range)
2013
+ rememberPreviewSelection(range)
2014
+ selectedText = range.toString().trim()
2015
+ closeHoveredLink()
2016
+ handleSelectionChange(true)
2017
+ await tick()
2018
+ linkDraft = linkHref || 'https://'
2019
+ activeSelectionMenu = 'link'
2020
+ activeSelectionMenuTrigger = selectionLinkButtonEl
2021
+ if (selectionLinkButtonEl) {
2022
+ updateSelectionMenuAnchor('link')
2023
+ }
2024
+ }
2025
+
1151
2026
  /**
1152
2027
  * 目录高亮不依赖 IntersectionObserver,而是按滚动容器内标题的相对位置推断。
1153
2028
  * 这样正文持续流式刷新时不需要反复重建 observer,也更容易和目录点击滚动保持一致。
@@ -1211,6 +2086,7 @@ ${safeCode}
1211
2086
  function handleDocumentScroll(event: Event): void {
1212
2087
  syncActiveHeadingFromScroll()
1213
2088
  syncPinnedTitleVisibility()
2089
+ syncHoveredLinkPosition()
1214
2090
  if (selectionToolbarVisible) {
1215
2091
  queueSelectionRefresh(true)
1216
2092
  }
@@ -1250,11 +2126,16 @@ ${safeCode}
1250
2126
 
1251
2127
  const previewSelection = readPreviewSelectionState()
1252
2128
  if (!previewSelection) {
2129
+ if (savedSelectionRange && isSelectionChromeFocused()) {
2130
+ return
2131
+ }
2132
+
1253
2133
  scheduleSelectionToolbarClose()
1254
2134
  return
1255
2135
  }
1256
2136
 
1257
2137
  const { selection, range, text } = previewSelection
2138
+ hoveredLinkState = null
1258
2139
  const rect = range.getBoundingClientRect()
1259
2140
  const hostRect = editorScrollHost.getBoundingClientRect()
1260
2141
  // 浮层尺寸在首次渲染前拿不到精确高度,这里用稳定的经验值决定上下避让方向,避免直接盖住选中文本。
@@ -1269,31 +2150,47 @@ ${safeCode}
1269
2150
  : 'bottom'
1270
2151
  const center = rect.left - hostRect.left + rect.width / 2
1271
2152
  const horizontalPadding = 16
1272
- const measuredToolbarHalfWidth = selectionToolbarEl
1273
- ? selectionToolbarEl.offsetWidth / 2
1274
- : 320
1275
- const estimatedToolbarHalfWidth = Math.min(
1276
- Math.max(132, measuredToolbarHalfWidth),
1277
- Math.max(132, (editorScrollHost.clientWidth - horizontalPadding * 2) / 2),
2153
+ const outlineOverlap = outlinePanelEl
2154
+ ? Math.max(
2155
+ 0,
2156
+ Math.min(
2157
+ outlinePanelEl.getBoundingClientRect().right,
2158
+ hostRect.right,
2159
+ ) - hostRect.left,
2160
+ )
2161
+ : 0
2162
+ const minLeft = Math.max(
2163
+ horizontalPadding,
2164
+ outlineOverlap > 24 ? outlineOverlap + 12 : horizontalPadding,
2165
+ )
2166
+ const measuredToolbarWidth = selectionChromeEl
2167
+ ? Math.max(selectionChromeEl.offsetWidth, selectionChromeEl.clientWidth)
2168
+ : selectionToolbarEl
2169
+ ? Math.max(selectionToolbarEl.offsetWidth, selectionToolbarEl.clientWidth)
2170
+ : 360
2171
+ const maxToolbarWidth = Math.max(
2172
+ 220,
2173
+ editorScrollHost.clientWidth - minLeft - horizontalPadding,
1278
2174
  )
1279
- const minCenter = horizontalPadding + estimatedToolbarHalfWidth
1280
- const maxCenter = editorScrollHost.clientWidth - minCenter
2175
+ const toolbarWidth = Math.min(measuredToolbarWidth, maxToolbarWidth)
2176
+ const maxLeft = Math.max(
2177
+ minLeft,
2178
+ editorScrollHost.clientWidth - horizontalPadding - toolbarWidth,
2179
+ )
2180
+ const unclampedLeft = center - toolbarWidth / 2
2181
+ const toolbarLeft = Math.min(Math.max(unclampedLeft, minLeft), maxLeft)
1281
2182
  const alignment: SelectionToolbarPosition['alignment']
1282
- = center <= minCenter
2183
+ = toolbarLeft <= minLeft + 0.5
1283
2184
  ? 'left'
1284
- : center >= maxCenter
2185
+ : toolbarLeft >= maxLeft - 0.5
1285
2186
  ? 'right'
1286
2187
  : 'center'
1287
- const toolbarLeft = alignment === 'left'
1288
- ? horizontalPadding
1289
- : alignment === 'right'
1290
- ? editorScrollHost.clientWidth - horizontalPadding
1291
- : center
1292
2188
  const toolbarTop = placement === 'top'
1293
2189
  ? editorScrollHost.scrollTop + rect.top - hostRect.top - estimatedToolbarHeight - offset
1294
2190
  : editorScrollHost.scrollTop + rect.bottom - hostRect.top + offset
1295
2191
 
1296
2192
  selectedText = text
2193
+ rememberPreviewSelection(range)
1297
2194
  selectionToolbarVisible = true
1298
2195
  selectionFormatState = readSelectionFormatState(selection)
1299
2196
  toolbarPosition = {
@@ -1328,6 +2225,10 @@ ${safeCode}
1328
2225
  }
1329
2226
 
1330
2227
  function applyBlockFormat(kind: MarkdownBlockFormatKind): void {
2228
+ if (!syncSelectionForToolbarAction()) {
2229
+ return
2230
+ }
2231
+
1331
2232
  onapplyblockformat?.(kind)
1332
2233
  activeSelectionMenu = null
1333
2234
  activeSelectionMenuTrigger = null
@@ -1335,66 +2236,160 @@ ${safeCode}
1335
2236
  }
1336
2237
 
1337
2238
  function applyBlockStyle(kind: MarkdownBlockStyleKind): void {
2239
+ if (!syncSelectionForToolbarAction()) {
2240
+ return
2241
+ }
2242
+
1338
2243
  onapplyblockstyle?.(kind)
1339
2244
  activeSelectionMenu = null
1340
2245
  activeSelectionMenuTrigger = null
1341
2246
  queueSelectionRefresh()
1342
2247
  }
1343
2248
 
2249
+ /**
2250
+ * 行内样式按钮允许连续点按,因此这里不主动关掉工具条,只刷新状态。
2251
+ */
1344
2252
  function applyInlineFormat(kind: MarkdownInlineFormatKind): void {
2253
+ if (!syncSelectionForToolbarAction()) {
2254
+ return
2255
+ }
2256
+
2257
+ clearPendingSelectionToolbarClose()
1345
2258
  onapplyinlineformat?.(kind)
1346
2259
  queueSelectionRefresh()
1347
2260
  }
1348
2261
 
1349
2262
  function applyAlignment(kind: MarkdownTextAlignKind): void {
2263
+ if (!syncSelectionForToolbarAction()) {
2264
+ return
2265
+ }
2266
+
1350
2267
  onapplyalignment?.(kind)
1351
2268
  activeSelectionMenu = null
1352
2269
  activeSelectionMenuTrigger = null
1353
2270
  queueSelectionRefresh()
1354
2271
  }
1355
2272
 
2273
+ /**
2274
+ * 链接菜单提交时总是以恢复后的正文选区为准,避免输入框焦点把目标文本丢掉。
2275
+ */
1356
2276
  function applyLink(): void {
1357
- onapplylink?.(linkDraft.trim() || null)
2277
+ if (!syncSelectionForToolbarAction()) {
2278
+ return
2279
+ }
2280
+
2281
+ onapplylink?.(
2282
+ linkDraft.trim() || null,
2283
+ savedSelectionSnapshot
2284
+ ? {
2285
+ ...savedSelectionSnapshot,
2286
+ }
2287
+ : undefined,
2288
+ selectedText.trim() || undefined,
2289
+ )
1358
2290
  activeSelectionMenu = null
1359
2291
  activeSelectionMenuTrigger = null
1360
2292
  queueSelectionRefresh()
1361
2293
  }
1362
2294
 
1363
2295
  function removeLink(): void {
1364
- onapplylink?.(null)
2296
+ if (!syncSelectionForToolbarAction()) {
2297
+ return
2298
+ }
2299
+
2300
+ onapplylink?.(
2301
+ null,
2302
+ savedSelectionSnapshot
2303
+ ? {
2304
+ ...savedSelectionSnapshot,
2305
+ }
2306
+ : undefined,
2307
+ selectedText.trim() || undefined,
2308
+ )
1365
2309
  activeSelectionMenu = null
1366
2310
  activeSelectionMenuTrigger = null
1367
2311
  queueSelectionRefresh()
1368
2312
  }
1369
2313
 
2314
+ /**
2315
+ * 颜色动作除了颜色值,还会把当前选中文本一起带给外层,供宿主侧做兜底选区恢复。
2316
+ */
1370
2317
  function applyColor(request: MarkdownColorFormatRequest): void {
1371
- onapplycolor?.(request)
2318
+ if (!syncSelectionForToolbarAction()) {
2319
+ return
2320
+ }
2321
+
2322
+ onapplycolor?.({
2323
+ ...request,
2324
+ selectedText: selectedText.trim() || undefined,
2325
+ selectionSnapshot: savedSelectionSnapshot
2326
+ ? {
2327
+ ...savedSelectionSnapshot,
2328
+ }
2329
+ : undefined,
2330
+ })
1372
2331
  queueSelectionRefresh()
1373
2332
  }
1374
2333
 
1375
2334
  async function copySelection(): Promise<void> {
2335
+ if (!syncSelectionForToolbarAction()) {
2336
+ return
2337
+ }
2338
+
1376
2339
  await oncopyselection?.()
1377
2340
  activeSelectionMenu = null
1378
2341
  activeSelectionMenuTrigger = null
1379
2342
  }
1380
2343
 
1381
2344
  async function annotateSelection(): Promise<void> {
2345
+ if (!syncSelectionForToolbarAction()) {
2346
+ return
2347
+ }
2348
+
1382
2349
  await onannotation?.()
1383
2350
  activeSelectionMenu = null
1384
2351
  activeSelectionMenuTrigger = null
1385
2352
  }
1386
2353
 
2354
+ function handlePreviewMouseMove(event: MouseEvent): void {
2355
+ const target = event.target
2356
+ const link = target instanceof Element
2357
+ ? target.closest<HTMLAnchorElement>('a[href]')
2358
+ : null
2359
+
2360
+ updateHoveredLink(link && previewHost?.contains(link) ? link : null)
2361
+ }
2362
+
2363
+ function handlePreviewMouseLeave(): void {
2364
+ scheduleHoveredLinkHide()
2365
+ }
2366
+
2367
+ /**
2368
+ * mouseup 比 selectionchange 更接近用户“完成一次选中”的时机,适合触发工具条刷新。
2369
+ */
1387
2370
  function handlePreviewMouseUp(): void {
1388
2371
  queueSelectionRefresh()
1389
2372
  onpreviewmouseup?.()
1390
2373
  }
1391
2374
 
2375
+ /**
2376
+ * 输入事件本身只透传给宿主;选区状态交给 selectionchange/mouseup 统一管理。
2377
+ */
1392
2378
  function handlePreviewInput(event: Event): void {
1393
2379
  onpreviewinput?.(event)
1394
2380
  }
1395
2381
 
1396
- function handlePreviewBlur(): void {
2382
+ /**
2383
+ * 正文失焦时先观察焦点是否转移到工具条内部,避免点击链接输入框时误触发提交或收起。
2384
+ */
2385
+ function handlePreviewBlur(event: FocusEvent): void {
1397
2386
  queueSelectionRefresh(true)
2387
+
2388
+ const nextTarget = event.relatedTarget
2389
+ if (nextTarget instanceof Node && selectionChromeEl?.contains(nextTarget)) {
2390
+ return
2391
+ }
2392
+
1398
2393
  onpreviewblur?.()
1399
2394
  }
1400
2395
  </script>
@@ -1539,11 +2534,11 @@ ${safeCode}
1539
2534
  {/if}
1540
2535
 
1541
2536
  <div
1542
- class:hai-ai-doc-layout-collapsed={outlineCollapsed}
2537
+ class:hai-ai-doc-layout-collapsed={!showOutline || outlineCollapsed}
1543
2538
  class='hai-ai-doc-layout'
1544
2539
  >
1545
2540
  {#if showOutline && !outlineCollapsed}
1546
- <aside class='hai-ai-doc-outline'>
2541
+ <aside bind:this={outlinePanelEl} class='hai-ai-doc-outline'>
1547
2542
  <div class='hai-ai-doc-outline-head'>
1548
2543
  <strong>{uiM('markdown_outline')}</strong>
1549
2544
 
@@ -1599,7 +2594,10 @@ ${safeCode}
1599
2594
  style={`top:${toolbarPosition.top}px; left:${toolbarPosition.left}px;`}
1600
2595
  >
1601
2596
  <div
2597
+ bind:this={selectionChromeEl}
1602
2598
  class='hai-ai-doc-selection-chrome'
2599
+ onmouseenter={clearPendingSelectionToolbarClose}
2600
+ onmouseleave={() => scheduleSelectionToolbarClose()}
1603
2601
  >
1604
2602
  <div
1605
2603
  bind:this={selectionToolbarEl}
@@ -1613,12 +2611,19 @@ ${safeCode}
1613
2611
  type='button'
1614
2612
  class={cn(
1615
2613
  'hai-ai-doc-selection-chip',
1616
- activeSelectionMenu === 'rewrite'
2614
+ (!directRewriteAction && activeSelectionMenu === 'rewrite')
1617
2615
  ? 'hai-ai-doc-selection-chip--active'
1618
2616
  : '',
1619
2617
  )}
1620
2618
  disabled={rewritePending}
1621
- onclick={event => toggleSelectionMenu('rewrite', event)}
2619
+ onclick={(event) => {
2620
+ if (directRewriteAction) {
2621
+ void applyRewrite(directRewriteAction.id)
2622
+ return
2623
+ }
2624
+
2625
+ toggleSelectionMenu('rewrite', event)
2626
+ }}
1622
2627
  >
1623
2628
  <svg viewBox='0 0 24 24' aria-hidden='true'>
1624
2629
  <path
@@ -1756,7 +2761,7 @@ ${safeCode}
1756
2761
  title={uiM('markdown_format_strike')}
1757
2762
  onclick={() => applyInlineFormat('strike')}
1758
2763
  >
1759
- S
2764
+ <s>S</s>
1760
2765
  </button>
1761
2766
  <button
1762
2767
  type='button'
@@ -1782,12 +2787,13 @@ ${safeCode}
1782
2787
  title={uiM('markdown_format_underline')}
1783
2788
  onclick={() => applyInlineFormat('underline')}
1784
2789
  >
1785
- U
2790
+ <u>U</u>
1786
2791
  </button>
1787
2792
  {/if}
1788
2793
 
1789
2794
  {#if onapplylink || onapplyinlineformat}
1790
2795
  <button
2796
+ bind:this={selectionLinkButtonEl}
1791
2797
  type='button'
1792
2798
  class={cn(
1793
2799
  'hai-ai-doc-selection-btn',
@@ -1804,10 +2810,18 @@ ${safeCode}
1804
2810
  ? toggleSelectionMenu('link', event)
1805
2811
  : applyInlineFormat('link')}
1806
2812
  >
1807
- <svg viewBox='0 0 24 24' aria-hidden='true'>
1808
- <path
1809
- d='M7.78 15.72a3.75 3.75 0 0 1 0-5.3l2.47-2.47a3.75 3.75 0 0 1 5.3 5.3l-.97.98a.75.75 0 0 1-1.06-1.06l.98-.97a2.25 2.25 0 0 0-3.19-3.19l-2.47 2.47a2.25 2.25 0 1 0 3.18 3.18l.49-.49a.75.75 0 0 1 1.06 1.06l-.49.49a3.75 3.75 0 0 1-5.3 0Zm8.44-7.44a.75.75 0 0 1 0 1.06l-8 8a.75.75 0 0 1-1.06-1.06l8-8a.75.75 0 0 1 1.06 0Z'
1810
- ></path>
2813
+ <svg
2814
+ viewBox='0 0 24 24'
2815
+ aria-hidden='true'
2816
+ fill='none'
2817
+ stroke='currentColor'
2818
+ stroke-width='2'
2819
+ stroke-linecap='round'
2820
+ stroke-linejoin='round'
2821
+ >
2822
+ <path d='M9.5 14.5 14.5 9.5'></path>
2823
+ <path d='M7.6 16.4 6.3 17.7a3 3 0 1 1-4.24-4.24l3-3a3 3 0 0 1 4.24 0'></path>
2824
+ <path d='M16.4 7.6 17.7 6.3a3 3 0 1 1 4.24 4.24l-3 3a3 3 0 0 1-4.24 0'></path>
1811
2825
  </svg>
1812
2826
  </button>
1813
2827
  {/if}
@@ -1826,19 +2840,6 @@ ${safeCode}
1826
2840
  >
1827
2841
  &lt;/&gt;
1828
2842
  </button>
1829
- <button
1830
- type='button'
1831
- class={cn(
1832
- 'hai-ai-doc-selection-btn',
1833
- selectionFormatState.highlight
1834
- ? 'hai-ai-doc-selection-btn--active'
1835
- : '',
1836
- )}
1837
- title={uiM('markdown_format_highlight')}
1838
- onclick={() => applyInlineFormat('highlight')}
1839
- >
1840
- <span class='hai-ai-doc-selection-highlight-icon'>A</span>
1841
- </button>
1842
2843
  {/if}
1843
2844
 
1844
2845
  {#if onapplycolor}
@@ -1905,14 +2906,14 @@ ${safeCode}
1905
2906
  {/if}
1906
2907
  </div>
1907
2908
 
1908
- {#if activeSelectionMenu === 'rewrite' && resolvedRewriteActions.length > 0}
2909
+ {#if activeSelectionMenu === 'rewrite' && resolvedRewriteActions.length > 1}
1909
2910
  <div
1910
2911
  class='hai-ai-doc-rewrite-menu'
1911
2912
  data-menu-alignment={selectionMenuAlignment}
1912
2913
  role='menu'
1913
2914
  tabindex='-1'
1914
2915
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
1915
- onmousedown={event => event.preventDefault()}
2916
+ onmousedown={handleSelectionPanelMouseDown}
1916
2917
  >
1917
2918
  {#each resolvedRewriteActions as action}
1918
2919
  <button
@@ -1932,7 +2933,7 @@ ${safeCode}
1932
2933
  role='menu'
1933
2934
  tabindex='-1'
1934
2935
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
1935
- onmousedown={event => event.preventDefault()}
2936
+ onmousedown={handleSelectionPanelMouseDown}
1936
2937
  >
1937
2938
  {#each BLOCK_FORMAT_OPTIONS as option}
1938
2939
  <button
@@ -1964,7 +2965,7 @@ ${safeCode}
1964
2965
  role='menu'
1965
2966
  tabindex='-1'
1966
2967
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
1967
- onmousedown={event => event.preventDefault()}
2968
+ onmousedown={handleSelectionPanelMouseDown}
1968
2969
  >
1969
2970
  {#each ALIGN_OPTIONS as option}
1970
2971
  <button
@@ -1992,9 +2993,10 @@ ${safeCode}
1992
2993
  aria-label={uiM('markdown_link_dialog_label')}
1993
2994
  tabindex='-1'
1994
2995
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
1995
- onmousedown={event => event.preventDefault()}
2996
+ onmousedown={handleSelectionPanelMouseDown}
1996
2997
  >
1997
2998
  <input
2999
+ bind:this={linkInputEl}
1998
3000
  class='hai-ai-doc-selection-input'
1999
3001
  bind:value={linkDraft}
2000
3002
  aria-label={uiM('markdown_link_input_label')}
@@ -2032,7 +3034,7 @@ ${safeCode}
2032
3034
  aria-label={uiM('markdown_color_dialog_label')}
2033
3035
  tabindex='-1'
2034
3036
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2035
- onmousedown={event => event.preventDefault()}
3037
+ onmousedown={handleSelectionPanelMouseDown}
2036
3038
  >
2037
3039
  <div class='hai-ai-doc-color-group'>
2038
3040
  <div class='hai-ai-doc-color-group-title'>
@@ -2062,23 +3064,34 @@ ${safeCode}
2062
3064
  {uiM('markdown_background_color')}
2063
3065
  </div>
2064
3066
  <div class='hai-ai-doc-color-grid'>
2065
- {#each BACKGROUND_COLOR_PRESETS as color}
3067
+ {#each BACKGROUND_COLOR_OPTIONS as colorOption}
2066
3068
  <button
2067
3069
  type='button'
2068
3070
  class={cn(
2069
3071
  'hai-ai-doc-color-swatch',
2070
- selectionFormatState.backgroundColor === color
3072
+ selectionFormatState.backgroundColor === resolveColorOptionValue(colorOption)
2071
3073
  ? 'hai-ai-doc-color-swatch--active'
2072
3074
  : '',
2073
3075
  )}
2074
- title={color}
3076
+ data-empty={typeof colorOption === 'string'
3077
+ ? undefined
3078
+ : 'true'}
3079
+ title={typeof colorOption === 'string'
3080
+ ? colorOption
3081
+ : uiM(colorOption.titleKey)}
2075
3082
  onclick={() =>
2076
3083
  applyColor({
2077
3084
  target: 'background',
2078
- value: color,
3085
+ value: typeof colorOption === 'string'
3086
+ ? colorOption
3087
+ : colorOption.value,
2079
3088
  })}
2080
3089
  >
2081
- <span style={`background:${color};`}></span>
3090
+ <span
3091
+ style={typeof colorOption === 'string'
3092
+ ? `background:${colorOption};`
3093
+ : undefined}
3094
+ ></span>
2082
3095
  </button>
2083
3096
  {/each}
2084
3097
  </div>
@@ -2099,11 +3112,87 @@ ${safeCode}
2099
3112
  </div>
2100
3113
  {/if}
2101
3114
 
3115
+ {#if hoveredLinkState}
3116
+ <div
3117
+ bind:this={hoveredLinkPopoverEl}
3118
+ class='hai-ai-doc-link-hover'
3119
+ data-placement={hoveredLinkState.placement}
3120
+ style={`top:${hoveredLinkState.top}px; left:${hoveredLinkState.left}px;`}
3121
+ onmouseenter={clearPendingHoveredLinkHide}
3122
+ onmouseleave={handlePreviewMouseLeave}
3123
+ >
3124
+ <button
3125
+ type='button'
3126
+ class='hai-ai-doc-link-hover-btn'
3127
+ title={uiM('markdown_link_open')}
3128
+ aria-label={uiM('markdown_link_open')}
3129
+ onmousedown={event => event.preventDefault()}
3130
+ onclick={openHoveredLink}
3131
+ >
3132
+ <svg viewBox='0 0 24 24' aria-hidden='true'>
3133
+ <path
3134
+ d='M13.75 5.75h4.5v4.5'
3135
+ fill='none'
3136
+ stroke='currentColor'
3137
+ stroke-width='1.9'
3138
+ stroke-linecap='round'
3139
+ stroke-linejoin='round'
3140
+ ></path>
3141
+ <path
3142
+ d='M18 6 11 13'
3143
+ fill='none'
3144
+ stroke='currentColor'
3145
+ stroke-width='1.9'
3146
+ stroke-linecap='round'
3147
+ stroke-linejoin='round'
3148
+ ></path>
3149
+ <path
3150
+ d='M10.25 7.75h-2.5A2.5 2.5 0 0 0 5.25 10.25v6A2.5 2.5 0 0 0 7.75 18.75h6a2.5 2.5 0 0 0 2.5-2.5v-2.5'
3151
+ fill='none'
3152
+ stroke='currentColor'
3153
+ stroke-width='1.9'
3154
+ stroke-linecap='round'
3155
+ stroke-linejoin='round'
3156
+ ></path>
3157
+ </svg>
3158
+ </button>
3159
+ <button
3160
+ type='button'
3161
+ class='hai-ai-doc-link-hover-btn'
3162
+ title={uiM('markdown_link_edit')}
3163
+ aria-label={uiM('markdown_link_edit')}
3164
+ onmousedown={event => event.preventDefault()}
3165
+ onclick={() => void editHoveredLink()}
3166
+ >
3167
+ <svg viewBox='0 0 24 24' aria-hidden='true'>
3168
+ <path
3169
+ d='M4.75 16.88V19.25h2.37l9.96-9.96-2.37-2.37-9.96 9.96Z'
3170
+ fill='none'
3171
+ stroke='currentColor'
3172
+ stroke-width='1.9'
3173
+ stroke-linecap='round'
3174
+ stroke-linejoin='round'
3175
+ ></path>
3176
+ <path
3177
+ d='M13.96 7.92 16.33 5.55a1.68 1.68 0 0 1 2.37 2.37l-2.37 2.37'
3178
+ fill='none'
3179
+ stroke='currentColor'
3180
+ stroke-width='1.9'
3181
+ stroke-linecap='round'
3182
+ stroke-linejoin='round'
3183
+ ></path>
3184
+ </svg>
3185
+ </button>
3186
+ </div>
3187
+ {/if}
3188
+
2102
3189
  <article
2103
3190
  bind:this={previewHost}
2104
3191
  class={readerDocumentClass}
2105
3192
  contenteditable={editable}
2106
3193
  role='document'
3194
+ onmousemove={handlePreviewMouseMove}
3195
+ onmouseleave={handlePreviewMouseLeave}
2107
3196
  onmouseup={handlePreviewMouseUp}
2108
3197
  oninput={handlePreviewInput}
2109
3198
  onblur={handlePreviewBlur}
@@ -2126,8 +3215,12 @@ ${safeCode}
2126
3215
  );
2127
3216
  --hai-ai-doc-selection-fg: oklch(var(--bc, 0.22 0 0));
2128
3217
  display: flex;
3218
+ width: 100%;
3219
+ min-width: 0;
2129
3220
  min-height: 0;
2130
3221
  height: 100%;
3222
+ container-type: inline-size;
3223
+ container-name: ai-doc-editor;
2131
3224
  flex-direction: column;
2132
3225
  border: 1px solid oklch(var(--bc) / 0.08);
2133
3226
  border-radius: 1.5rem;
@@ -2141,8 +3234,10 @@ ${safeCode}
2141
3234
  }
2142
3235
 
2143
3236
  .hai-ai-doc-shell {
2144
- display: grid;
2145
- grid-template-rows: auto auto 1fr;
3237
+ display: flex;
3238
+ flex-direction: column;
3239
+ width: 100%;
3240
+ min-width: 0;
2146
3241
  min-height: 0;
2147
3242
  height: 100%;
2148
3243
  }
@@ -2336,18 +3431,23 @@ ${safeCode}
2336
3431
  }
2337
3432
 
2338
3433
  .hai-ai-doc-layout {
3434
+ width: 100%;
2339
3435
  min-height: 0;
2340
- display: grid;
2341
- grid-template-columns: 220px minmax(0, 1fr);
3436
+ min-width: 0;
3437
+ flex: 1 1 auto;
3438
+ display: flex;
3439
+ align-items: stretch;
2342
3440
  position: relative;
2343
3441
  overflow: hidden;
2344
3442
  }
2345
3443
 
2346
3444
  .hai-ai-doc-layout.hai-ai-doc-layout-collapsed {
2347
- grid-template-columns: minmax(0, 1fr);
3445
+ display: flex;
2348
3446
  }
2349
3447
 
2350
3448
  .hai-ai-doc-outline {
3449
+ width: 220px;
3450
+ flex: 0 0 220px;
2351
3451
  min-height: 0;
2352
3452
  display: grid;
2353
3453
  grid-template-rows: auto 1fr;
@@ -2446,18 +3546,28 @@ ${safeCode}
2446
3546
  }
2447
3547
 
2448
3548
  .hai-ai-doc-reader {
3549
+ width: 100%;
3550
+ flex: 1 1 auto;
2449
3551
  min-width: 0;
2450
3552
  min-height: 0;
2451
3553
  position: relative;
2452
3554
  z-index: 2;
2453
3555
  isolation: isolate;
3556
+ display: flex;
3557
+ flex-direction: column;
2454
3558
  }
2455
3559
 
2456
3560
  .hai-ai-doc-scroll {
2457
3561
  position: relative;
3562
+ width: 100%;
3563
+ min-width: 0;
2458
3564
  height: 100%;
2459
3565
  overflow: auto;
2460
3566
  padding: 0.5rem 0 1.75rem;
3567
+ display: flex;
3568
+ flex-direction: column;
3569
+ flex: 1 1 auto;
3570
+ min-height: 0;
2461
3571
  }
2462
3572
 
2463
3573
  .hai-ai-doc-selection-layer {
@@ -2466,21 +3576,17 @@ ${safeCode}
2466
3576
  display: block;
2467
3577
  width: fit-content;
2468
3578
  max-width: calc(100% - 2rem);
2469
- transform: translateX(-50%);
2470
3579
  transition:
2471
3580
  top 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
2472
- left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
2473
- transform 0.14s cubic-bezier(0.2, 0.85, 0.24, 1);
3581
+ left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1);
2474
3582
  pointer-events: none;
2475
3583
  }
2476
3584
 
2477
3585
  .hai-ai-doc-selection-layer[data-alignment='left'] {
2478
- transform: translateX(0);
2479
3586
  justify-items: start;
2480
3587
  }
2481
3588
 
2482
3589
  .hai-ai-doc-selection-layer[data-alignment='right'] {
2483
- transform: translateX(-100%);
2484
3590
  justify-items: end;
2485
3591
  }
2486
3592
 
@@ -2495,6 +3601,55 @@ ${safeCode}
2495
3601
  max-width: min(100%, calc(100vw - 2rem));
2496
3602
  }
2497
3603
 
3604
+ .hai-ai-doc-link-hover {
3605
+ position: absolute;
3606
+ z-index: 58;
3607
+ display: inline-flex;
3608
+ align-items: center;
3609
+ gap: 0.18rem;
3610
+ padding: 0.22rem;
3611
+ border-radius: 9999px;
3612
+ background: color-mix(in srgb, oklch(var(--b1, 1 0 0)) 94%, white 6%);
3613
+ border: 1px solid color-mix(in srgb, oklch(var(--bc, 0.22 0 0)) 12%, white 88%);
3614
+ box-shadow:
3615
+ 0 20px 36px -26px rgb(15 23 42 / 0.26),
3616
+ 0 10px 18px -16px rgb(15 23 42 / 0.18);
3617
+ pointer-events: auto;
3618
+ transition:
3619
+ top 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
3620
+ left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1);
3621
+ }
3622
+
3623
+ .hai-ai-doc-link-hover-btn {
3624
+ width: 2rem;
3625
+ height: 2rem;
3626
+ display: inline-flex;
3627
+ align-items: center;
3628
+ justify-content: center;
3629
+ border: none;
3630
+ border-radius: 9999px;
3631
+ background: transparent;
3632
+ color: oklch(var(--bc) / 0.76);
3633
+ cursor: pointer;
3634
+ transition:
3635
+ background-color 0.14s ease,
3636
+ color 0.14s ease,
3637
+ transform 0.14s ease;
3638
+ }
3639
+
3640
+ .hai-ai-doc-link-hover-btn:hover {
3641
+ background: oklch(var(--bc) / 0.06);
3642
+ color: oklch(var(--bc));
3643
+ transform: translateY(-1px);
3644
+ }
3645
+
3646
+ .hai-ai-doc-link-hover-btn svg {
3647
+ width: 1rem;
3648
+ height: 1rem;
3649
+ fill: none;
3650
+ stroke: currentColor;
3651
+ }
3652
+
2498
3653
  .hai-ai-doc-selection-toolbar {
2499
3654
  display: flex;
2500
3655
  align-items: center;
@@ -2863,6 +4018,20 @@ ${safeCode}
2863
4018
  border: 1px solid rgb(15 23 42 / 0.08);
2864
4019
  }
2865
4020
 
4021
+ .hai-ai-doc-color-swatch[data-empty='true'] span {
4022
+ position: relative;
4023
+ border-color: oklch(var(--bc) / 0.18);
4024
+ background:
4025
+ linear-gradient(
4026
+ 135deg,
4027
+ transparent calc(50% - 0.75px),
4028
+ oklch(var(--bc) / 0.34) calc(50% - 0.75px),
4029
+ oklch(var(--bc) / 0.34) calc(50% + 0.75px),
4030
+ transparent calc(50% + 0.75px)
4031
+ ),
4032
+ color-mix(in srgb, oklch(var(--b1)) 96%, white 4%);
4033
+ }
4034
+
2866
4035
  .hai-ai-doc-rewrite-menu-btn {
2867
4036
  justify-content: center;
2868
4037
  }
@@ -2875,12 +4044,28 @@ ${safeCode}
2875
4044
  }
2876
4045
 
2877
4046
  .hai-markdown-document {
4047
+ width: 100%;
4048
+ min-width: 0;
2878
4049
  max-width: 52.5rem;
2879
4050
  margin: 0 auto;
2880
4051
  padding: 1.25rem 2rem 6rem;
2881
4052
  outline: none;
2882
4053
  }
2883
4054
 
4055
+ .hai-markdown-document.hai-markdown-code-only {
4056
+ max-width: none !important;
4057
+ width: 100% !important;
4058
+ min-width: 0;
4059
+ margin: 0 !important;
4060
+ height: 100%;
4061
+ min-height: 100%;
4062
+ padding: 0.5rem 0.75rem 0.75rem;
4063
+ display: flex;
4064
+ flex-direction: column;
4065
+ flex: 1 1 auto;
4066
+ box-sizing: border-box;
4067
+ }
4068
+
2884
4069
  :global(.hai-markdown-document ::selection) {
2885
4070
  background: var(--hai-ai-doc-selection-bg, rgb(59 130 246 / 0.22));
2886
4071
  color: var(--hai-ai-doc-selection-fg, inherit);
@@ -2956,6 +4141,10 @@ ${safeCode}
2956
4141
  font-weight: 500;
2957
4142
  }
2958
4143
 
4144
+ .hai-markdown-editable :global(a[href]) {
4145
+ cursor: pointer;
4146
+ }
4147
+
2959
4148
  .hai-markdown :global(a:hover) {
2960
4149
  text-decoration: underline;
2961
4150
  }
@@ -3019,6 +4208,7 @@ ${safeCode}
3019
4208
  gap: 0.75rem;
3020
4209
  padding: 0.65rem 1rem;
3021
4210
  min-height: 2.5rem;
4211
+ line-height: 1.2;
3022
4212
  background: oklch(var(--n) / 0.94);
3023
4213
  border-bottom: 1px solid oklch(var(--nc) / 0.1);
3024
4214
  }
@@ -3027,6 +4217,7 @@ ${safeCode}
3027
4217
  min-width: 0;
3028
4218
  display: flex;
3029
4219
  align-items: center;
4220
+ gap: 0.55rem;
3030
4221
  }
3031
4222
 
3032
4223
  .hai-markdown :global(.hai-md-code-actions) {
@@ -3038,12 +4229,54 @@ ${safeCode}
3038
4229
  .hai-markdown :global(.hai-md-code-lang) {
3039
4230
  font-size: 0.75rem;
3040
4231
  font-weight: 600;
4232
+ line-height: 1;
3041
4233
  text-transform: uppercase;
3042
4234
  letter-spacing: 0.05em;
3043
4235
  color: oklch(var(--nc) / 0.6);
3044
4236
  user-select: none;
3045
4237
  }
3046
4238
 
4239
+ .hai-markdown :global(.hai-md-code-view-switch) {
4240
+ display: inline-flex;
4241
+ align-items: center;
4242
+ gap: 0.16rem;
4243
+ padding: 0.15rem;
4244
+ border-radius: 9999px;
4245
+ border: 1px solid oklch(var(--nc) / 0.16);
4246
+ background: oklch(var(--nc) / 0.08);
4247
+ flex-shrink: 0;
4248
+ }
4249
+
4250
+ .hai-markdown :global(.hai-md-code-view-btn) {
4251
+ min-height: 1.7rem;
4252
+ padding: 0 0.72rem;
4253
+ border: none;
4254
+ border-radius: 9999px;
4255
+ background: transparent;
4256
+ color: oklch(var(--nc) / 0.72);
4257
+ font-size: 0.86rem;
4258
+ font-weight: 600;
4259
+ line-height: 1;
4260
+ cursor: pointer;
4261
+ transition:
4262
+ background-color 0.15s ease,
4263
+ color 0.15s ease;
4264
+ }
4265
+
4266
+ .hai-markdown :global(.hai-md-code-view-btn[aria-pressed='true']) {
4267
+ background: oklch(var(--nc) / 0.2);
4268
+ color: oklch(var(--nc));
4269
+ }
4270
+
4271
+ .hai-markdown :global(.hai-md-code-preview-hint) {
4272
+ font-size: 0.75rem;
4273
+ line-height: 1;
4274
+ color: oklch(var(--nc) / 0.48);
4275
+ white-space: nowrap;
4276
+ overflow: hidden;
4277
+ text-overflow: ellipsis;
4278
+ }
4279
+
3047
4280
  .hai-markdown :global(.hai-md-code-lang-empty) {
3048
4281
  opacity: 0.7;
3049
4282
  }
@@ -3083,8 +4316,21 @@ ${safeCode}
3083
4316
  ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
3084
4317
  'Liberation Mono', monospace;
3085
4318
  font-size: 0.875rem;
3086
- line-height: 1.6;
4319
+ line-height: 1.45;
3087
4320
  tab-size: 2;
4321
+
4322
+ /* Shiki CSS Variables 主题(由 highlight.ts 的 createCssVariablesTheme 引用) */
4323
+ --hai-hl-foreground: oklch(var(--nc));
4324
+ --hai-hl-background: transparent;
4325
+ --hai-hl-token-keyword: oklch(0.7 0.15 280);
4326
+ --hai-hl-token-string: oklch(0.75 0.12 150);
4327
+ --hai-hl-token-string-expression: oklch(0.75 0.12 150);
4328
+ --hai-hl-token-constant: oklch(0.78 0.12 70);
4329
+ --hai-hl-token-comment: oklch(var(--nc) / 0.45);
4330
+ --hai-hl-token-function: oklch(0.72 0.14 220);
4331
+ --hai-hl-token-parameter: oklch(0.72 0.12 35);
4332
+ --hai-hl-token-punctuation: oklch(var(--nc) / 0.7);
4333
+ --hai-hl-token-link: oklch(0.7 0.15 280);
3088
4334
  }
3089
4335
 
3090
4336
  .hai-markdown :global(.hai-md-code-preview-slot) {
@@ -3092,6 +4338,76 @@ ${safeCode}
3092
4338
  background: oklch(var(--b1));
3093
4339
  }
3094
4340
 
4341
+ .hai-markdown :global(.hai-md-code-block[data-code-view='code'] .hai-md-code-preview-slot) {
4342
+ display: none;
4343
+ }
4344
+
4345
+ .hai-markdown :global(.hai-md-code-block[data-code-view='preview'] pre) {
4346
+ display: none;
4347
+ }
4348
+
4349
+ .hai-markdown :global(.hai-md-code-block[data-code-view='preview'] .hai-md-code-preview-slot) {
4350
+ display: block;
4351
+ }
4352
+
4353
+ .hai-markdown-code-only :global(.hai-md-code-block) {
4354
+ width: 100% !important;
4355
+ max-width: none !important;
4356
+ min-width: 0;
4357
+ margin: 0;
4358
+ height: auto;
4359
+ min-height: 0;
4360
+ flex: 1 1 auto;
4361
+ display: flex !important;
4362
+ align-self: stretch;
4363
+ align-items: stretch;
4364
+ flex-direction: column;
4365
+ overflow: hidden;
4366
+ }
4367
+
4368
+ .hai-markdown-code-only :global(.hai-md-code-header),
4369
+ .hai-markdown-code-only :global(.hai-md-code-block pre),
4370
+ .hai-markdown-code-only :global(.hai-md-code-preview-slot) {
4371
+ width: 100%;
4372
+ min-width: 0;
4373
+ min-height: 0;
4374
+ box-sizing: border-box;
4375
+ }
4376
+
4377
+ /* 代码/预览切换时统一固定 header 高度,避免视觉抖动。 */
4378
+ .hai-markdown-code-only :global(.hai-md-code-header) {
4379
+ flex: 0 0 3.1rem;
4380
+ height: 3.1rem;
4381
+ min-height: 3.1rem;
4382
+ padding: 0 1rem;
4383
+ align-items: center;
4384
+ }
4385
+
4386
+ .hai-markdown-code-only :global(.hai-md-code-block pre),
4387
+ .hai-markdown-code-only :global(.hai-md-code-preview-slot) {
4388
+ flex: 1 1 auto;
4389
+ height: auto;
4390
+ min-height: 0;
4391
+ }
4392
+
4393
+ .hai-markdown-code-only :global(.hai-md-code-block pre) {
4394
+ overflow: auto;
4395
+ }
4396
+
4397
+ .hai-markdown-code-only :global(.hai-md-code-preview-slot) {
4398
+ background: oklch(var(--n));
4399
+ color: oklch(var(--nc));
4400
+ }
4401
+
4402
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview']) {
4403
+ flex: 1 1 auto;
4404
+ }
4405
+
4406
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-code-preview-slot) {
4407
+ flex: 1 1 auto;
4408
+ display: flex;
4409
+ }
4410
+
3095
4411
  .hai-markdown :global(blockquote) {
3096
4412
  margin: 1em 0;
3097
4413
  padding: 0.65em 1.1em;
@@ -3209,14 +4525,29 @@ ${safeCode}
3209
4525
  box-shadow: inset 0 -1px 0 oklch(var(--bc) / 0.1);
3210
4526
  }
3211
4527
 
3212
- .hai-md-preview-card {
4528
+ :global(.hai-md-preview-card) {
3213
4529
  margin: 0;
3214
4530
  padding: 1rem;
3215
4531
  background: oklch(var(--b1));
3216
4532
  color: oklch(var(--bc));
3217
4533
  }
3218
4534
 
3219
- .hai-md-preview-head {
4535
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-preview-card) {
4536
+ display: flex;
4537
+ flex-direction: column;
4538
+ flex: 1 1 auto;
4539
+ min-height: 0;
4540
+ padding: 0.95rem 1rem;
4541
+ background: oklch(var(--n));
4542
+ color: oklch(var(--nc));
4543
+ }
4544
+
4545
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-preview-head),
4546
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-preview-desc) {
4547
+ display: none;
4548
+ }
4549
+
4550
+ :global(.hai-md-preview-head) {
3220
4551
  font-size: 0.8rem;
3221
4552
  font-weight: 700;
3222
4553
  letter-spacing: 0.04em;
@@ -3224,12 +4555,43 @@ ${safeCode}
3224
4555
  color: oklch(var(--bc) / 0.7);
3225
4556
  }
3226
4557
 
3227
- .hai-md-preview-desc {
4558
+ :global(.hai-md-preview-desc) {
3228
4559
  margin: 0.5rem 0 0;
3229
4560
  font-size: 0.875rem;
3230
4561
  color: oklch(var(--bc) / 0.7);
3231
4562
  }
3232
4563
 
4564
+ :global(.hai-md-preview-empty) {
4565
+ min-height: 100%;
4566
+ display: flex;
4567
+ flex-direction: column;
4568
+ gap: 0.62rem;
4569
+ }
4570
+
4571
+ :global(.hai-md-preview-empty-title) {
4572
+ margin: 0;
4573
+ font-size: 0.9rem;
4574
+ font-weight: 700;
4575
+ letter-spacing: 0.01em;
4576
+ color: oklch(var(--bc) / 0.85);
4577
+ }
4578
+
4579
+ :global(.hai-md-preview-empty-text) {
4580
+ margin: 0;
4581
+ font-size: 0.84rem;
4582
+ line-height: 1.58;
4583
+ white-space: pre-wrap;
4584
+ color: oklch(var(--bc) / 0.74);
4585
+ }
4586
+
4587
+ .hai-markdown-code-only :global(.hai-md-preview-empty-title) {
4588
+ color: oklch(var(--nc) / 0.88);
4589
+ }
4590
+
4591
+ .hai-markdown-code-only :global(.hai-md-preview-empty-text) {
4592
+ color: oklch(var(--nc) / 0.72);
4593
+ }
4594
+
3233
4595
  :global(.hai-md-preview-card pre) {
3234
4596
  margin: 0.85rem 0 0;
3235
4597
  padding: 0.85rem 1rem;
@@ -3243,7 +4605,7 @@ ${safeCode}
3243
4605
  color: oklch(var(--er));
3244
4606
  }
3245
4607
 
3246
- .hai-md-preview-frame {
4608
+ :global(.hai-md-preview-frame) {
3247
4609
  display: block;
3248
4610
  width: 100%;
3249
4611
  min-height: 280px;
@@ -3253,10 +4615,19 @@ ${safeCode}
3253
4615
  background: white;
3254
4616
  }
3255
4617
 
3256
- .hai-md-preview-loading {
4618
+ .hai-markdown-code-only :global(.hai-md-code-block[data-code-view='preview'] .hai-md-preview-frame) {
4619
+ flex: 1 1 auto;
4620
+ height: 100%;
4621
+ min-height: 0;
4622
+ margin-top: 0;
4623
+ border: none;
4624
+ border-radius: 0;
4625
+ }
4626
+
4627
+ :global(.hai-md-preview-loading) {
3257
4628
  width: 100%;
3258
4629
  height: 4px;
3259
- margin-top: 0.85rem;
4630
+ margin-top: 0.5rem;
3260
4631
  border-radius: 9999px;
3261
4632
  background: linear-gradient(
3262
4633
  90deg,
@@ -3268,27 +4639,10 @@ ${safeCode}
3268
4639
  animation: hai-md-loading 1.2s linear infinite;
3269
4640
  }
3270
4641
 
3271
- .hai-md-preview-rendered :global(:first-child) {
4642
+ :global(.hai-md-preview-rendered > :first-child) {
3272
4643
  margin-top: 0.85rem;
3273
4644
  }
3274
4645
 
3275
- /* Shiki CSS Variables 主题 — 代码高亮 token 颜色 */
3276
- .hai-markdown :global(.hai-md-code-block code) {
3277
- --hai-hl-fg: oklch(var(--nc));
3278
- --hai-hl-bg: transparent;
3279
-
3280
- /* token 颜色 */
3281
- --hai-hl-token-keyword: oklch(0.7 0.15 280);
3282
- --hai-hl-token-string: oklch(0.75 0.12 150);
3283
- --hai-hl-token-string-expression: oklch(0.75 0.12 150);
3284
- --hai-hl-token-constant: oklch(0.78 0.12 70);
3285
- --hai-hl-token-comment: oklch(var(--nc) / 0.45);
3286
- --hai-hl-token-function: oklch(0.72 0.14 220);
3287
- --hai-hl-token-parameter: oklch(0.72 0.12 35);
3288
- --hai-hl-token-punctuation: oklch(var(--nc) / 0.7);
3289
- --hai-hl-token-link: oklch(0.7 0.15 280);
3290
- }
3291
-
3292
4646
  @keyframes hai-md-loading {
3293
4647
  from {
3294
4648
  background-position: 220px 0;
@@ -3299,16 +4653,31 @@ ${safeCode}
3299
4653
  }
3300
4654
  }
3301
4655
 
3302
- @media (max-width: 960px) {
3303
- .hai-ai-doc-layout,
3304
- .hai-ai-doc-layout.hai-ai-doc-layout-collapsed {
3305
- grid-template-columns: minmax(0, 1fr);
4656
+ @container ai-doc-editor (max-width: 800px) {
4657
+ .hai-ai-doc-layout {
4658
+ display: flex;
4659
+ min-height: 0;
3306
4660
  }
3307
4661
 
3308
4662
  .hai-ai-doc-outline {
3309
- display: none;
4663
+ position: absolute;
4664
+ top: 0.65rem;
4665
+ left: 0.65rem;
4666
+ bottom: 0.85rem;
4667
+ width: min(220px, calc(100% - 1.3rem));
4668
+ max-width: 220px;
4669
+ z-index: 14;
4670
+ padding: 0.5rem 0.625rem 0.9rem 0.75rem;
4671
+ border: 1px solid oklch(var(--bc) / 0.12);
4672
+ border-radius: 0.95rem;
4673
+ background:
4674
+ linear-gradient(180deg, oklch(var(--b2) / 0.84), oklch(var(--b1) / 0.92));
4675
+ box-shadow: 0 20px 40px -28px oklch(var(--bc) / 0.35);
4676
+ backdrop-filter: blur(8px);
3310
4677
  }
4678
+ }
3311
4679
 
4680
+ @media (max-width: 960px) {
3312
4681
  .hai-ai-doc-topbar {
3313
4682
  padding-left: 1rem;
3314
4683
  padding-right: 1rem;