@h-ai/ui 0.1.0-alpha.12 → 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',
@@ -289,6 +332,10 @@
289
332
  placement: 'top',
290
333
  alignment: 'center',
291
334
  })
335
+ // 窄容器下目录会改成悬浮覆盖模式,保存 DOM 引用后才能在定位工具条时主动避让。
336
+ let outlinePanelEl = $state<HTMLElement | null>(null)
337
+ // chrome 节点同时包住主工具条和下拉面板,方便判断焦点是否仍留在浮层内部。
338
+ let selectionChromeEl = $state<HTMLDivElement | null>(null)
292
339
  // 选区工具条自身 DOM,用于根据真实尺寸修正贴边位置。
293
340
  let selectionToolbarEl = $state<HTMLDivElement | null>(null)
294
341
  // 当前展开菜单对应的触发按钮,用来把面板锚定到具体按钮下方/上方。
@@ -309,13 +356,28 @@
309
356
  })
310
357
  // linkDraft 只在链接弹层里暂存输入值,避免每次打开都丢掉正在编辑的链接。
311
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)
312
367
  // 真实尺寸修正只需要排队一帧,避免反复触发 handleSelectionChange 形成抖动。
313
368
  let selectionToolbarMeasurePending = false
314
369
  // 选区刷新统一合并到同一帧,避免 selectionchange / mouseup / blur 连续触发时抖动。
315
370
  let selectionRefreshFrame: number | undefined
371
+ // 同一帧内任一来源要求重新量尺时,都要把这次刷新升级成 remeasure 版本。
316
372
  let selectionRefreshNeedsRemeasure = false
317
373
  // 浏览器在滚动到底部或切换焦点时会短暂给出空选区,这里延迟一拍再关闭工具条。
318
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
319
381
 
320
382
  // code 类型产物通常只有裸代码文本,这里统一包成 fenced block 进入同一条渲染链路。
321
383
  const documentContent = $derived(
@@ -325,6 +387,7 @@
325
387
  const codePreviewToggleEnabled = $derived(
326
388
  showCodePreviewToggle && sourceKind === 'code',
327
389
  )
390
+ // hint 文案统一做 trim,避免只传空格时仍把占位区域撑开。
328
391
  const normalizedCodePreviewHint = $derived(codePreviewHint.trim())
329
392
  const renderResult = $derived(
330
393
  renderMarkdownDocument(documentContent, {
@@ -348,6 +411,7 @@
348
411
  const resolvedRewriteActions = $derived(
349
412
  resolveRewriteActions(rewriteActions, onrewrite),
350
413
  )
414
+ // 只有一个改写动作时直接渲染成按钮,多动作才保留菜单,减少一次点击。
351
415
  const directRewriteAction = $derived(
352
416
  resolvedRewriteActions.length === 1 ? resolvedRewriteActions[0] : null,
353
417
  )
@@ -378,7 +442,7 @@
378
442
  const activeBlockFormatOption = $derived(
379
443
  BLOCK_FORMAT_OPTIONS.find(
380
444
  option => option.value === selectionFormatState.blockFormat,
381
- ) ?? BLOCK_FORMAT_OPTIONS[0],
445
+ ) ?? FALLBACK_BLOCK_FORMAT_OPTION,
382
446
  )
383
447
  // readerDocumentClass 只负责正文文章区域,不和外层容器类名混用。
384
448
  const readerDocumentClass = $derived(
@@ -432,7 +496,8 @@
432
496
  $effect(() => {
433
497
  void documentContent
434
498
  codePreviews = {}
435
- closeSelectionToolbar()
499
+ hoveredLinkState = null
500
+ queueSelectionRefresh(true)
436
501
  })
437
502
 
438
503
  $effect(() => {
@@ -457,6 +522,10 @@
457
522
  if (selectionToolbarCloseTimer) {
458
523
  window.clearTimeout(selectionToolbarCloseTimer)
459
524
  }
525
+
526
+ if (hoveredLinkHideTimer) {
527
+ window.clearTimeout(hoveredLinkHideTimer)
528
+ }
460
529
  }
461
530
  })
462
531
 
@@ -479,6 +548,64 @@
479
548
  }
480
549
  })
481
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
+
482
609
  function resolveRewriteActions(
483
610
  actions: MarkdownRewriteAction[],
484
611
  handler?: AiDocumentEditorProps['onrewrite'],
@@ -786,6 +913,10 @@ ${safeCode}
786
913
  }
787
914
  }
788
915
 
916
+ /**
917
+ * 统一承接正文里通过 `{@html}` 注入的交互节点点击。
918
+ * 这些按钮和链接不受 Svelte 逐个绑定管理,因此在容器级别做代理更稳妥。
919
+ */
789
920
  async function handleClick(event: MouseEvent): Promise<void> {
790
921
  const target = event.target as HTMLElement
791
922
  const copyButton = target.closest(
@@ -822,6 +953,22 @@ ${safeCode}
822
953
  const codeBlockId = runButton?.dataset.codeBlockId
823
954
  if (runButton && codeBlockId) {
824
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
+ )
825
972
  }
826
973
  }
827
974
 
@@ -963,7 +1110,18 @@ ${safeCode}
963
1110
  }
964
1111
 
965
1112
  function readBlockFormat(element: HTMLElement | null): MarkdownBlockStyleKind {
966
- 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
+ }
967
1125
 
968
1126
  if (tagName === 'h1') {
969
1127
  return 'heading1'
@@ -1090,6 +1248,370 @@ ${safeCode}
1090
1248
  }
1091
1249
  }
1092
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
+ */
1093
1615
  function clearPendingSelectionToolbarClose(): void {
1094
1616
  if (typeof window === 'undefined' || !selectionToolbarCloseTimer) {
1095
1617
  return
@@ -1099,135 +1621,406 @@ ${safeCode}
1099
1621
  selectionToolbarCloseTimer = undefined
1100
1622
  }
1101
1623
 
1624
+ /**
1625
+ * 关闭工具条刻意延后一小拍,给菜单 hover、输入框 focus 和选区回流留出时间。
1626
+ * 只要子菜单还开着,或焦点仍在 chrome 里,就继续保留当前工具条。
1627
+ */
1102
1628
  function scheduleSelectionToolbarClose(): void {
1103
1629
  if (typeof window === 'undefined') {
1104
1630
  closeSelectionToolbar()
1105
1631
  return
1106
1632
  }
1107
1633
 
1108
- if (!selectionToolbarVisible) {
1109
- closeSelectionToolbar()
1634
+ if (!selectionToolbarVisible) {
1635
+ closeSelectionToolbar()
1636
+ return
1637
+ }
1638
+
1639
+ if (activeSelectionMenu) {
1640
+ return
1641
+ }
1642
+
1643
+ clearPendingSelectionToolbarClose()
1644
+ selectionToolbarCloseTimer = window.setTimeout(() => {
1645
+ selectionToolbarCloseTimer = undefined
1646
+ if (activeSelectionMenu || isSelectionChromeFocused()) {
1647
+ return
1648
+ }
1649
+
1650
+ if (!readPreviewSelectionState()) {
1651
+ closeSelectionToolbar()
1652
+ }
1653
+ }, 600)
1654
+ }
1655
+
1656
+ /**
1657
+ * 选区变化可能在一帧内收到多次事件,这里统一合并,避免定位和状态读取来回抖动。
1658
+ */
1659
+ function queueSelectionRefresh(remeasured = false): void {
1660
+ if (typeof window === 'undefined') {
1661
+ return
1662
+ }
1663
+
1664
+ selectionRefreshNeedsRemeasure ||= remeasured
1665
+ if (selectionRefreshFrame) {
1666
+ return
1667
+ }
1668
+
1669
+ selectionRefreshFrame = window.requestAnimationFrame(() => {
1670
+ const nextRemeasured = selectionRefreshNeedsRemeasure
1671
+ selectionRefreshFrame = undefined
1672
+ selectionRefreshNeedsRemeasure = false
1673
+ handleSelectionChange(nextRemeasured)
1674
+ })
1675
+ }
1676
+
1677
+ /**
1678
+ * 子菜单首次打开前拿不到真实宽度,先给一个稳定的经验值参与贴边计算。
1679
+ */
1680
+ function estimateSelectionMenuWidth(menu: SelectionMenuKind): number {
1681
+ if (menu === 'color' || menu === 'link') {
1682
+ return 256
1683
+ }
1684
+
1685
+ if (menu === 'rewrite') {
1686
+ return 320
1687
+ }
1688
+
1689
+ return 288
1690
+ }
1691
+
1692
+ /**
1693
+ * 下拉面板以触发按钮为锚点,但最终位置要限制在工具条宽度内,避免菜单越界裁切。
1694
+ */
1695
+ function updateSelectionMenuAnchor(menu: SelectionMenuKind): void {
1696
+ if (!selectionToolbarEl || !activeSelectionMenuTrigger) {
1697
+ selectionMenuLeft = 0
1698
+ selectionMenuAlignment = 'center'
1699
+ return
1700
+ }
1701
+
1702
+ const toolbarWidth = Math.max(
1703
+ selectionToolbarEl.offsetWidth,
1704
+ selectionToolbarEl.clientWidth,
1705
+ )
1706
+ const triggerCenter
1707
+ = activeSelectionMenuTrigger.offsetLeft
1708
+ + activeSelectionMenuTrigger.offsetWidth / 2
1709
+ const estimatedHalfWidth = Math.min(
1710
+ estimateSelectionMenuWidth(menu) / 2,
1711
+ Math.max(112, (toolbarWidth - 16) / 2),
1712
+ )
1713
+ const minCenter = 8 + estimatedHalfWidth
1714
+ const maxCenter = Math.max(minCenter, toolbarWidth - 8 - estimatedHalfWidth)
1715
+
1716
+ if (triggerCenter <= minCenter) {
1717
+ selectionMenuLeft = 0
1718
+ selectionMenuAlignment = 'left'
1719
+ return
1720
+ }
1721
+
1722
+ if (triggerCenter >= maxCenter) {
1723
+ selectionMenuLeft = toolbarWidth
1724
+ selectionMenuAlignment = 'right'
1725
+ return
1726
+ }
1727
+
1728
+ selectionMenuLeft = triggerCenter
1729
+ selectionMenuAlignment = 'center'
1730
+ }
1731
+
1732
+ /**
1733
+ * 工具条初次出现时尺寸还没稳定,下一帧再量一次可以避免首帧贴边位置偏差。
1734
+ */
1735
+ function scheduleSelectionToolbarMeasurement(): void {
1736
+ if (
1737
+ typeof window === 'undefined'
1738
+ || selectionToolbarMeasurePending
1739
+ || !selectionToolbarVisible
1740
+ ) {
1741
+ return
1742
+ }
1743
+
1744
+ selectionToolbarMeasurePending = true
1745
+ window.requestAnimationFrame(() => {
1746
+ selectionToolbarMeasurePending = false
1747
+ if (selectionToolbarVisible) {
1748
+ handleSelectionChange(true)
1749
+ }
1750
+ })
1751
+ }
1752
+
1753
+ /**
1754
+ * 子菜单统一从这里开关,顺手记录触发按钮和链接初始值,保证定位与默认值一致。
1755
+ */
1756
+ function toggleSelectionMenu(menu: SelectionMenuKind, event?: MouseEvent): void {
1757
+ clearPendingSelectionToolbarClose()
1758
+ const opening = activeSelectionMenu !== menu
1759
+ activeSelectionMenu = opening ? menu : null
1760
+ activeSelectionMenuTrigger = opening && event?.currentTarget instanceof HTMLElement
1761
+ ? event.currentTarget
1762
+ : null
1763
+ if (opening) {
1764
+ updateSelectionMenuAnchor(menu)
1765
+ }
1766
+ if (opening && menu === 'link') {
1767
+ linkDraft = selectionFormatState.linkHref || 'https://'
1768
+ }
1769
+ }
1770
+
1771
+ /**
1772
+ * 关闭时把选区相关缓存一并清干净,避免下一次打开还误用上一次的链接和格式状态。
1773
+ */
1774
+ function closeSelectionToolbar(): void {
1775
+ clearPendingSelectionToolbarClose()
1776
+ selectionToolbarVisible = false
1777
+ activeSelectionMenu = null
1778
+ activeSelectionMenuTrigger = null
1779
+ selectionMenuLeft = 0
1780
+ selectionMenuAlignment = 'center'
1781
+ selectedText = ''
1782
+ linkDraft = ''
1783
+ savedSelectionRange = null
1784
+ savedSelectionSnapshot = null
1785
+ selectionFormatState = { ...DEFAULT_SELECTION_FORMAT_STATE }
1786
+ }
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) {
1110
1907
  return
1111
1908
  }
1112
1909
 
1113
- clearPendingSelectionToolbarClose()
1114
- selectionToolbarCloseTimer = window.setTimeout(() => {
1115
- selectionToolbarCloseTimer = undefined
1116
- if (!readPreviewSelectionState()) {
1117
- closeSelectionToolbar()
1118
- }
1119
- }, 120)
1120
- }
1121
-
1122
- function queueSelectionRefresh(remeasured = false): void {
1123
- if (typeof window === 'undefined') {
1910
+ if (!hoveredLinkState.element.isConnected) {
1911
+ closeHoveredLink()
1124
1912
  return
1125
1913
  }
1126
1914
 
1127
- selectionRefreshNeedsRemeasure ||= remeasured
1128
- if (selectionRefreshFrame) {
1915
+ const nextState = buildHoveredLinkState(hoveredLinkState.element)
1916
+ if (!nextState) {
1917
+ closeHoveredLink()
1129
1918
  return
1130
1919
  }
1131
1920
 
1132
- selectionRefreshFrame = window.requestAnimationFrame(() => {
1133
- const nextRemeasured = selectionRefreshNeedsRemeasure
1134
- selectionRefreshFrame = undefined
1135
- selectionRefreshNeedsRemeasure = false
1136
- handleSelectionChange(nextRemeasured)
1137
- })
1921
+ hoveredLinkState = nextState
1138
1922
  }
1139
1923
 
1140
- function estimateSelectionMenuWidth(menu: SelectionMenuKind): number {
1141
- if (menu === 'color' || menu === 'link') {
1142
- return 256
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
1143
1932
  }
1144
1933
 
1145
- if (menu === 'rewrite') {
1146
- return 320
1934
+ if (!link) {
1935
+ scheduleHoveredLinkHide()
1936
+ return
1147
1937
  }
1148
1938
 
1149
- return 288
1939
+ const nextState = buildHoveredLinkState(link)
1940
+ if (!nextState) {
1941
+ scheduleHoveredLinkHide()
1942
+ return
1943
+ }
1944
+
1945
+ clearPendingHoveredLinkHide()
1946
+ hoveredLinkState = nextState
1150
1947
  }
1151
1948
 
1152
- function updateSelectionMenuAnchor(menu: SelectionMenuKind): void {
1153
- if (!selectionToolbarEl || !activeSelectionMenuTrigger) {
1154
- selectionMenuLeft = 0
1155
- selectionMenuAlignment = 'center'
1949
+ function openHoveredLink(): void {
1950
+ if (typeof window === 'undefined' || !hoveredLinkState?.href) {
1156
1951
  return
1157
1952
  }
1158
1953
 
1159
- const toolbarWidth = Math.max(
1160
- selectionToolbarEl.offsetWidth,
1161
- selectionToolbarEl.clientWidth,
1162
- )
1163
- const triggerCenter
1164
- = activeSelectionMenuTrigger.offsetLeft
1165
- + activeSelectionMenuTrigger.offsetWidth / 2
1166
- const estimatedHalfWidth = Math.min(
1167
- estimateSelectionMenuWidth(menu) / 2,
1168
- Math.max(112, (toolbarWidth - 16) / 2),
1954
+ window.open(
1955
+ hoveredLinkState.href,
1956
+ hoveredLinkState.element.target || '_blank',
1957
+ 'noopener,noreferrer',
1169
1958
  )
1170
- const minCenter = 8 + estimatedHalfWidth
1171
- const maxCenter = Math.max(minCenter, toolbarWidth - 8 - estimatedHalfWidth)
1959
+ }
1172
1960
 
1173
- if (triggerCenter <= minCenter) {
1174
- selectionMenuLeft = 0
1175
- selectionMenuAlignment = 'left'
1176
- return
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()
1177
1975
  }
1178
1976
 
1179
- if (triggerCenter >= maxCenter) {
1180
- selectionMenuLeft = toolbarWidth
1181
- selectionMenuAlignment = 'right'
1182
- return
1977
+ if (!firstTextNode || !lastTextNode) {
1978
+ range.selectNodeContents(link)
1979
+ return range
1183
1980
  }
1184
1981
 
1185
- selectionMenuLeft = triggerCenter
1186
- selectionMenuAlignment = 'center'
1982
+ range.setStart(firstTextNode, 0)
1983
+ range.setEnd(lastTextNode, lastTextNode.textContent?.length ?? 0)
1984
+ return range
1187
1985
  }
1188
1986
 
1189
- function scheduleSelectionToolbarMeasurement(): void {
1987
+ async function editHoveredLink(): Promise<void> {
1190
1988
  if (
1191
1989
  typeof window === 'undefined'
1192
- || selectionToolbarMeasurePending
1193
- || !selectionToolbarVisible
1990
+ || !previewHost
1991
+ || !onapplylink
1992
+ || !hoveredLinkState
1194
1993
  ) {
1195
1994
  return
1196
1995
  }
1197
1996
 
1198
- selectionToolbarMeasurePending = true
1199
- window.requestAnimationFrame(() => {
1200
- selectionToolbarMeasurePending = false
1201
- if (selectionToolbarVisible) {
1202
- handleSelectionChange(true)
1203
- }
1204
- })
1205
- }
1206
-
1207
- function toggleSelectionMenu(menu: SelectionMenuKind, event?: MouseEvent): void {
1208
- const opening = activeSelectionMenu !== menu
1209
- activeSelectionMenu = opening ? menu : null
1210
- activeSelectionMenuTrigger = opening && event?.currentTarget instanceof HTMLElement
1211
- ? event.currentTarget
1212
- : null
1213
- if (opening) {
1214
- updateSelectionMenuAnchor(menu)
1997
+ const link = hoveredLinkState.element
1998
+ const linkHref = hoveredLinkState.rawHref
1999
+ if (!previewHost.contains(link)) {
2000
+ closeHoveredLink()
2001
+ return
1215
2002
  }
1216
- if (opening && menu === 'link') {
1217
- linkDraft = selectionFormatState.linkHref || 'https://'
2003
+
2004
+ const selection = window.getSelection()
2005
+ if (!selection) {
2006
+ return
1218
2007
  }
1219
- }
1220
2008
 
1221
- function closeSelectionToolbar(): void {
1222
- clearPendingSelectionToolbarClose()
1223
- selectionToolbarVisible = false
1224
- activeSelectionMenu = null
1225
- activeSelectionMenuTrigger = null
1226
- selectionMenuLeft = 0
1227
- selectionMenuAlignment = 'center'
1228
- selectedText = ''
1229
- linkDraft = ''
1230
- selectionFormatState = { ...DEFAULT_SELECTION_FORMAT_STATE }
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
+ }
1231
2024
  }
1232
2025
 
1233
2026
  /**
@@ -1293,6 +2086,7 @@ ${safeCode}
1293
2086
  function handleDocumentScroll(event: Event): void {
1294
2087
  syncActiveHeadingFromScroll()
1295
2088
  syncPinnedTitleVisibility()
2089
+ syncHoveredLinkPosition()
1296
2090
  if (selectionToolbarVisible) {
1297
2091
  queueSelectionRefresh(true)
1298
2092
  }
@@ -1332,11 +2126,16 @@ ${safeCode}
1332
2126
 
1333
2127
  const previewSelection = readPreviewSelectionState()
1334
2128
  if (!previewSelection) {
2129
+ if (savedSelectionRange && isSelectionChromeFocused()) {
2130
+ return
2131
+ }
2132
+
1335
2133
  scheduleSelectionToolbarClose()
1336
2134
  return
1337
2135
  }
1338
2136
 
1339
2137
  const { selection, range, text } = previewSelection
2138
+ hoveredLinkState = null
1340
2139
  const rect = range.getBoundingClientRect()
1341
2140
  const hostRect = editorScrollHost.getBoundingClientRect()
1342
2141
  // 浮层尺寸在首次渲染前拿不到精确高度,这里用稳定的经验值决定上下避让方向,避免直接盖住选中文本。
@@ -1351,31 +2150,47 @@ ${safeCode}
1351
2150
  : 'bottom'
1352
2151
  const center = rect.left - hostRect.left + rect.width / 2
1353
2152
  const horizontalPadding = 16
1354
- const measuredToolbarHalfWidth = selectionToolbarEl
1355
- ? selectionToolbarEl.offsetWidth / 2
1356
- : 320
1357
- const estimatedToolbarHalfWidth = Math.min(
1358
- Math.max(132, measuredToolbarHalfWidth),
1359
- 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,
1360
2165
  )
1361
- const minCenter = horizontalPadding + estimatedToolbarHalfWidth
1362
- const maxCenter = editorScrollHost.clientWidth - minCenter
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,
2174
+ )
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)
1363
2182
  const alignment: SelectionToolbarPosition['alignment']
1364
- = center <= minCenter
2183
+ = toolbarLeft <= minLeft + 0.5
1365
2184
  ? 'left'
1366
- : center >= maxCenter
2185
+ : toolbarLeft >= maxLeft - 0.5
1367
2186
  ? 'right'
1368
2187
  : 'center'
1369
- const toolbarLeft = alignment === 'left'
1370
- ? horizontalPadding
1371
- : alignment === 'right'
1372
- ? editorScrollHost.clientWidth - horizontalPadding
1373
- : center
1374
2188
  const toolbarTop = placement === 'top'
1375
2189
  ? editorScrollHost.scrollTop + rect.top - hostRect.top - estimatedToolbarHeight - offset
1376
2190
  : editorScrollHost.scrollTop + rect.bottom - hostRect.top + offset
1377
2191
 
1378
2192
  selectedText = text
2193
+ rememberPreviewSelection(range)
1379
2194
  selectionToolbarVisible = true
1380
2195
  selectionFormatState = readSelectionFormatState(selection)
1381
2196
  toolbarPosition = {
@@ -1410,6 +2225,10 @@ ${safeCode}
1410
2225
  }
1411
2226
 
1412
2227
  function applyBlockFormat(kind: MarkdownBlockFormatKind): void {
2228
+ if (!syncSelectionForToolbarAction()) {
2229
+ return
2230
+ }
2231
+
1413
2232
  onapplyblockformat?.(kind)
1414
2233
  activeSelectionMenu = null
1415
2234
  activeSelectionMenuTrigger = null
@@ -1417,66 +2236,160 @@ ${safeCode}
1417
2236
  }
1418
2237
 
1419
2238
  function applyBlockStyle(kind: MarkdownBlockStyleKind): void {
2239
+ if (!syncSelectionForToolbarAction()) {
2240
+ return
2241
+ }
2242
+
1420
2243
  onapplyblockstyle?.(kind)
1421
2244
  activeSelectionMenu = null
1422
2245
  activeSelectionMenuTrigger = null
1423
2246
  queueSelectionRefresh()
1424
2247
  }
1425
2248
 
2249
+ /**
2250
+ * 行内样式按钮允许连续点按,因此这里不主动关掉工具条,只刷新状态。
2251
+ */
1426
2252
  function applyInlineFormat(kind: MarkdownInlineFormatKind): void {
2253
+ if (!syncSelectionForToolbarAction()) {
2254
+ return
2255
+ }
2256
+
2257
+ clearPendingSelectionToolbarClose()
1427
2258
  onapplyinlineformat?.(kind)
1428
2259
  queueSelectionRefresh()
1429
2260
  }
1430
2261
 
1431
2262
  function applyAlignment(kind: MarkdownTextAlignKind): void {
2263
+ if (!syncSelectionForToolbarAction()) {
2264
+ return
2265
+ }
2266
+
1432
2267
  onapplyalignment?.(kind)
1433
2268
  activeSelectionMenu = null
1434
2269
  activeSelectionMenuTrigger = null
1435
2270
  queueSelectionRefresh()
1436
2271
  }
1437
2272
 
2273
+ /**
2274
+ * 链接菜单提交时总是以恢复后的正文选区为准,避免输入框焦点把目标文本丢掉。
2275
+ */
1438
2276
  function applyLink(): void {
1439
- 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
+ )
1440
2290
  activeSelectionMenu = null
1441
2291
  activeSelectionMenuTrigger = null
1442
2292
  queueSelectionRefresh()
1443
2293
  }
1444
2294
 
1445
2295
  function removeLink(): void {
1446
- 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
+ )
1447
2309
  activeSelectionMenu = null
1448
2310
  activeSelectionMenuTrigger = null
1449
2311
  queueSelectionRefresh()
1450
2312
  }
1451
2313
 
2314
+ /**
2315
+ * 颜色动作除了颜色值,还会把当前选中文本一起带给外层,供宿主侧做兜底选区恢复。
2316
+ */
1452
2317
  function applyColor(request: MarkdownColorFormatRequest): void {
1453
- 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
+ })
1454
2331
  queueSelectionRefresh()
1455
2332
  }
1456
2333
 
1457
2334
  async function copySelection(): Promise<void> {
2335
+ if (!syncSelectionForToolbarAction()) {
2336
+ return
2337
+ }
2338
+
1458
2339
  await oncopyselection?.()
1459
2340
  activeSelectionMenu = null
1460
2341
  activeSelectionMenuTrigger = null
1461
2342
  }
1462
2343
 
1463
2344
  async function annotateSelection(): Promise<void> {
2345
+ if (!syncSelectionForToolbarAction()) {
2346
+ return
2347
+ }
2348
+
1464
2349
  await onannotation?.()
1465
2350
  activeSelectionMenu = null
1466
2351
  activeSelectionMenuTrigger = null
1467
2352
  }
1468
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
+ */
1469
2370
  function handlePreviewMouseUp(): void {
1470
2371
  queueSelectionRefresh()
1471
2372
  onpreviewmouseup?.()
1472
2373
  }
1473
2374
 
2375
+ /**
2376
+ * 输入事件本身只透传给宿主;选区状态交给 selectionchange/mouseup 统一管理。
2377
+ */
1474
2378
  function handlePreviewInput(event: Event): void {
1475
2379
  onpreviewinput?.(event)
1476
2380
  }
1477
2381
 
1478
- function handlePreviewBlur(): void {
2382
+ /**
2383
+ * 正文失焦时先观察焦点是否转移到工具条内部,避免点击链接输入框时误触发提交或收起。
2384
+ */
2385
+ function handlePreviewBlur(event: FocusEvent): void {
1479
2386
  queueSelectionRefresh(true)
2387
+
2388
+ const nextTarget = event.relatedTarget
2389
+ if (nextTarget instanceof Node && selectionChromeEl?.contains(nextTarget)) {
2390
+ return
2391
+ }
2392
+
1480
2393
  onpreviewblur?.()
1481
2394
  }
1482
2395
  </script>
@@ -1625,7 +2538,7 @@ ${safeCode}
1625
2538
  class='hai-ai-doc-layout'
1626
2539
  >
1627
2540
  {#if showOutline && !outlineCollapsed}
1628
- <aside class='hai-ai-doc-outline'>
2541
+ <aside bind:this={outlinePanelEl} class='hai-ai-doc-outline'>
1629
2542
  <div class='hai-ai-doc-outline-head'>
1630
2543
  <strong>{uiM('markdown_outline')}</strong>
1631
2544
 
@@ -1681,7 +2594,10 @@ ${safeCode}
1681
2594
  style={`top:${toolbarPosition.top}px; left:${toolbarPosition.left}px;`}
1682
2595
  >
1683
2596
  <div
2597
+ bind:this={selectionChromeEl}
1684
2598
  class='hai-ai-doc-selection-chrome'
2599
+ onmouseenter={clearPendingSelectionToolbarClose}
2600
+ onmouseleave={() => scheduleSelectionToolbarClose()}
1685
2601
  >
1686
2602
  <div
1687
2603
  bind:this={selectionToolbarEl}
@@ -1845,7 +2761,7 @@ ${safeCode}
1845
2761
  title={uiM('markdown_format_strike')}
1846
2762
  onclick={() => applyInlineFormat('strike')}
1847
2763
  >
1848
- S
2764
+ <s>S</s>
1849
2765
  </button>
1850
2766
  <button
1851
2767
  type='button'
@@ -1871,12 +2787,13 @@ ${safeCode}
1871
2787
  title={uiM('markdown_format_underline')}
1872
2788
  onclick={() => applyInlineFormat('underline')}
1873
2789
  >
1874
- U
2790
+ <u>U</u>
1875
2791
  </button>
1876
2792
  {/if}
1877
2793
 
1878
2794
  {#if onapplylink || onapplyinlineformat}
1879
2795
  <button
2796
+ bind:this={selectionLinkButtonEl}
1880
2797
  type='button'
1881
2798
  class={cn(
1882
2799
  'hai-ai-doc-selection-btn',
@@ -1893,10 +2810,18 @@ ${safeCode}
1893
2810
  ? toggleSelectionMenu('link', event)
1894
2811
  : applyInlineFormat('link')}
1895
2812
  >
1896
- <svg viewBox='0 0 24 24' aria-hidden='true'>
1897
- <path
1898
- 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'
1899
- ></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>
1900
2825
  </svg>
1901
2826
  </button>
1902
2827
  {/if}
@@ -1915,19 +2840,6 @@ ${safeCode}
1915
2840
  >
1916
2841
  &lt;/&gt;
1917
2842
  </button>
1918
- <button
1919
- type='button'
1920
- class={cn(
1921
- 'hai-ai-doc-selection-btn',
1922
- selectionFormatState.highlight
1923
- ? 'hai-ai-doc-selection-btn--active'
1924
- : '',
1925
- )}
1926
- title={uiM('markdown_format_highlight')}
1927
- onclick={() => applyInlineFormat('highlight')}
1928
- >
1929
- <span class='hai-ai-doc-selection-highlight-icon'>A</span>
1930
- </button>
1931
2843
  {/if}
1932
2844
 
1933
2845
  {#if onapplycolor}
@@ -2001,7 +2913,7 @@ ${safeCode}
2001
2913
  role='menu'
2002
2914
  tabindex='-1'
2003
2915
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2004
- onmousedown={event => event.preventDefault()}
2916
+ onmousedown={handleSelectionPanelMouseDown}
2005
2917
  >
2006
2918
  {#each resolvedRewriteActions as action}
2007
2919
  <button
@@ -2021,7 +2933,7 @@ ${safeCode}
2021
2933
  role='menu'
2022
2934
  tabindex='-1'
2023
2935
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2024
- onmousedown={event => event.preventDefault()}
2936
+ onmousedown={handleSelectionPanelMouseDown}
2025
2937
  >
2026
2938
  {#each BLOCK_FORMAT_OPTIONS as option}
2027
2939
  <button
@@ -2053,7 +2965,7 @@ ${safeCode}
2053
2965
  role='menu'
2054
2966
  tabindex='-1'
2055
2967
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2056
- onmousedown={event => event.preventDefault()}
2968
+ onmousedown={handleSelectionPanelMouseDown}
2057
2969
  >
2058
2970
  {#each ALIGN_OPTIONS as option}
2059
2971
  <button
@@ -2081,9 +2993,10 @@ ${safeCode}
2081
2993
  aria-label={uiM('markdown_link_dialog_label')}
2082
2994
  tabindex='-1'
2083
2995
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2084
- onmousedown={event => event.preventDefault()}
2996
+ onmousedown={handleSelectionPanelMouseDown}
2085
2997
  >
2086
2998
  <input
2999
+ bind:this={linkInputEl}
2087
3000
  class='hai-ai-doc-selection-input'
2088
3001
  bind:value={linkDraft}
2089
3002
  aria-label={uiM('markdown_link_input_label')}
@@ -2121,7 +3034,7 @@ ${safeCode}
2121
3034
  aria-label={uiM('markdown_color_dialog_label')}
2122
3035
  tabindex='-1'
2123
3036
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2124
- onmousedown={event => event.preventDefault()}
3037
+ onmousedown={handleSelectionPanelMouseDown}
2125
3038
  >
2126
3039
  <div class='hai-ai-doc-color-group'>
2127
3040
  <div class='hai-ai-doc-color-group-title'>
@@ -2151,23 +3064,34 @@ ${safeCode}
2151
3064
  {uiM('markdown_background_color')}
2152
3065
  </div>
2153
3066
  <div class='hai-ai-doc-color-grid'>
2154
- {#each BACKGROUND_COLOR_PRESETS as color}
3067
+ {#each BACKGROUND_COLOR_OPTIONS as colorOption}
2155
3068
  <button
2156
3069
  type='button'
2157
3070
  class={cn(
2158
3071
  'hai-ai-doc-color-swatch',
2159
- selectionFormatState.backgroundColor === color
3072
+ selectionFormatState.backgroundColor === resolveColorOptionValue(colorOption)
2160
3073
  ? 'hai-ai-doc-color-swatch--active'
2161
3074
  : '',
2162
3075
  )}
2163
- title={color}
3076
+ data-empty={typeof colorOption === 'string'
3077
+ ? undefined
3078
+ : 'true'}
3079
+ title={typeof colorOption === 'string'
3080
+ ? colorOption
3081
+ : uiM(colorOption.titleKey)}
2164
3082
  onclick={() =>
2165
3083
  applyColor({
2166
3084
  target: 'background',
2167
- value: color,
3085
+ value: typeof colorOption === 'string'
3086
+ ? colorOption
3087
+ : colorOption.value,
2168
3088
  })}
2169
3089
  >
2170
- <span style={`background:${color};`}></span>
3090
+ <span
3091
+ style={typeof colorOption === 'string'
3092
+ ? `background:${colorOption};`
3093
+ : undefined}
3094
+ ></span>
2171
3095
  </button>
2172
3096
  {/each}
2173
3097
  </div>
@@ -2188,11 +3112,87 @@ ${safeCode}
2188
3112
  </div>
2189
3113
  {/if}
2190
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
+
2191
3189
  <article
2192
3190
  bind:this={previewHost}
2193
3191
  class={readerDocumentClass}
2194
3192
  contenteditable={editable}
2195
3193
  role='document'
3194
+ onmousemove={handlePreviewMouseMove}
3195
+ onmouseleave={handlePreviewMouseLeave}
2196
3196
  onmouseup={handlePreviewMouseUp}
2197
3197
  oninput={handlePreviewInput}
2198
3198
  onblur={handlePreviewBlur}
@@ -2576,21 +3576,17 @@ ${safeCode}
2576
3576
  display: block;
2577
3577
  width: fit-content;
2578
3578
  max-width: calc(100% - 2rem);
2579
- transform: translateX(-50%);
2580
3579
  transition:
2581
3580
  top 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
2582
- left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
2583
- 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);
2584
3582
  pointer-events: none;
2585
3583
  }
2586
3584
 
2587
3585
  .hai-ai-doc-selection-layer[data-alignment='left'] {
2588
- transform: translateX(0);
2589
3586
  justify-items: start;
2590
3587
  }
2591
3588
 
2592
3589
  .hai-ai-doc-selection-layer[data-alignment='right'] {
2593
- transform: translateX(-100%);
2594
3590
  justify-items: end;
2595
3591
  }
2596
3592
 
@@ -2605,6 +3601,55 @@ ${safeCode}
2605
3601
  max-width: min(100%, calc(100vw - 2rem));
2606
3602
  }
2607
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
+
2608
3653
  .hai-ai-doc-selection-toolbar {
2609
3654
  display: flex;
2610
3655
  align-items: center;
@@ -2973,6 +4018,20 @@ ${safeCode}
2973
4018
  border: 1px solid rgb(15 23 42 / 0.08);
2974
4019
  }
2975
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
+
2976
4035
  .hai-ai-doc-rewrite-menu-btn {
2977
4036
  justify-content: center;
2978
4037
  }
@@ -3082,6 +4141,10 @@ ${safeCode}
3082
4141
  font-weight: 500;
3083
4142
  }
3084
4143
 
4144
+ .hai-markdown-editable :global(a[href]) {
4145
+ cursor: pointer;
4146
+ }
4147
+
3085
4148
  .hai-markdown :global(a:hover) {
3086
4149
  text-decoration: underline;
3087
4150
  }