@h-ai/ui 0.1.0-alpha.12 → 0.1.0-alpha.15

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',
@@ -206,6 +249,8 @@
206
249
  breaks = true,
207
250
  // 是否显示左侧目录。
208
251
  showOutline = true,
252
+ // 是否显示目录项的序号前缀。
253
+ showOutlineNumbering = false,
209
254
  // 是否显示顶部工具栏。
210
255
  showToolbar = true,
211
256
  // 目录面板初始是否折叠。
@@ -289,6 +334,10 @@
289
334
  placement: 'top',
290
335
  alignment: 'center',
291
336
  })
337
+ // 窄容器下目录会改成悬浮覆盖模式,保存 DOM 引用后才能在定位工具条时主动避让。
338
+ let outlinePanelEl = $state<HTMLElement | null>(null)
339
+ // chrome 节点同时包住主工具条和下拉面板,方便判断焦点是否仍留在浮层内部。
340
+ let selectionChromeEl = $state<HTMLDivElement | null>(null)
292
341
  // 选区工具条自身 DOM,用于根据真实尺寸修正贴边位置。
293
342
  let selectionToolbarEl = $state<HTMLDivElement | null>(null)
294
343
  // 当前展开菜单对应的触发按钮,用来把面板锚定到具体按钮下方/上方。
@@ -309,13 +358,28 @@
309
358
  })
310
359
  // linkDraft 只在链接弹层里暂存输入值,避免每次打开都丢掉正在编辑的链接。
311
360
  let linkDraft = $state('')
361
+ // 链接输入框允许接管焦点,但格式动作仍需要恢复到正文的最后一个有效选区。
362
+ let linkInputEl = $state<HTMLInputElement | null>(null)
363
+ // 链接按钮 DOM 用来在“悬停编辑链接”场景下复用现有链接弹层定位。
364
+ let selectionLinkButtonEl = $state<HTMLButtonElement | null>(null)
365
+ // 原始 DOM Range 在结构没变时恢复最快,也能保留浏览器原生的精确边界。
366
+ let savedSelectionRange = $state<Range | null>(null)
367
+ // 纯文本快照用于 DOM 被局部重建后的兜底恢复,避免只靠 Range 引用失效。
368
+ let savedSelectionSnapshot = $state<MarkdownSelectionSnapshot | null>(null)
312
369
  // 真实尺寸修正只需要排队一帧,避免反复触发 handleSelectionChange 形成抖动。
313
370
  let selectionToolbarMeasurePending = false
314
371
  // 选区刷新统一合并到同一帧,避免 selectionchange / mouseup / blur 连续触发时抖动。
315
372
  let selectionRefreshFrame: number | undefined
373
+ // 同一帧内任一来源要求重新量尺时,都要把这次刷新升级成 remeasure 版本。
316
374
  let selectionRefreshNeedsRemeasure = false
317
375
  // 浏览器在滚动到底部或切换焦点时会短暂给出空选区,这里延迟一拍再关闭工具条。
318
376
  let selectionToolbarCloseTimer: number | undefined
377
+ // 悬停到已配置链接时显示快捷浮层,便于直接打开或编辑。
378
+ let hoveredLinkState = $state<HoveredLinkState | null>(null)
379
+ // 悬停浮层需要拿到真实尺寸后再二次校正位置。
380
+ let hoveredLinkPopoverEl = $state<HTMLDivElement | null>(null)
381
+ // 鼠标从正文移动到悬停浮层时给一小段缓冲,避免浮层闪烁。
382
+ let hoveredLinkHideTimer: number | undefined
319
383
 
320
384
  // code 类型产物通常只有裸代码文本,这里统一包成 fenced block 进入同一条渲染链路。
321
385
  const documentContent = $derived(
@@ -325,6 +389,7 @@
325
389
  const codePreviewToggleEnabled = $derived(
326
390
  showCodePreviewToggle && sourceKind === 'code',
327
391
  )
392
+ // hint 文案统一做 trim,避免只传空格时仍把占位区域撑开。
328
393
  const normalizedCodePreviewHint = $derived(codePreviewHint.trim())
329
394
  const renderResult = $derived(
330
395
  renderMarkdownDocument(documentContent, {
@@ -348,6 +413,7 @@
348
413
  const resolvedRewriteActions = $derived(
349
414
  resolveRewriteActions(rewriteActions, onrewrite),
350
415
  )
416
+ // 只有一个改写动作时直接渲染成按钮,多动作才保留菜单,减少一次点击。
351
417
  const directRewriteAction = $derived(
352
418
  resolvedRewriteActions.length === 1 ? resolvedRewriteActions[0] : null,
353
419
  )
@@ -378,7 +444,7 @@
378
444
  const activeBlockFormatOption = $derived(
379
445
  BLOCK_FORMAT_OPTIONS.find(
380
446
  option => option.value === selectionFormatState.blockFormat,
381
- ) ?? BLOCK_FORMAT_OPTIONS[0],
447
+ ) ?? FALLBACK_BLOCK_FORMAT_OPTION,
382
448
  )
383
449
  // readerDocumentClass 只负责正文文章区域,不和外层容器类名混用。
384
450
  const readerDocumentClass = $derived(
@@ -432,7 +498,8 @@
432
498
  $effect(() => {
433
499
  void documentContent
434
500
  codePreviews = {}
435
- closeSelectionToolbar()
501
+ hoveredLinkState = null
502
+ queueSelectionRefresh(true)
436
503
  })
437
504
 
438
505
  $effect(() => {
@@ -457,6 +524,10 @@
457
524
  if (selectionToolbarCloseTimer) {
458
525
  window.clearTimeout(selectionToolbarCloseTimer)
459
526
  }
527
+
528
+ if (hoveredLinkHideTimer) {
529
+ window.clearTimeout(hoveredLinkHideTimer)
530
+ }
460
531
  }
461
532
  })
462
533
 
@@ -479,6 +550,64 @@
479
550
  }
480
551
  })
481
552
 
553
+ $effect(() => {
554
+ if (
555
+ typeof document === 'undefined'
556
+ || !selectionToolbarVisible
557
+ ) {
558
+ return
559
+ }
560
+
561
+ const handleDocumentPointerDown = (event: MouseEvent): void => {
562
+ const target = event.target
563
+ if (!(target instanceof Node)) {
564
+ return
565
+ }
566
+
567
+ if (selectionChromeEl?.contains(target)) {
568
+ return
569
+ }
570
+
571
+ closeSelectionToolbar()
572
+ }
573
+
574
+ document.addEventListener('mousedown', handleDocumentPointerDown, true)
575
+
576
+ return () => {
577
+ document.removeEventListener('mousedown', handleDocumentPointerDown, true)
578
+ }
579
+ })
580
+
581
+ $effect(() => {
582
+ if (typeof window === 'undefined' || activeSelectionMenu !== 'link' || !linkInputEl) {
583
+ return
584
+ }
585
+
586
+ const nextLinkInput = linkInputEl
587
+ const focusFrame = window.requestAnimationFrame(() => {
588
+ nextLinkInput.focus()
589
+ nextLinkInput.select()
590
+ })
591
+
592
+ return () => {
593
+ window.cancelAnimationFrame(focusFrame)
594
+ }
595
+ })
596
+
597
+ $effect(() => {
598
+ if (typeof window === 'undefined' || !hoveredLinkState || !hoveredLinkPopoverEl) {
599
+ return
600
+ }
601
+
602
+ const measureFrame = window.requestAnimationFrame(() => {
603
+ syncHoveredLinkPosition()
604
+ })
605
+
606
+ return () => {
607
+ window.cancelAnimationFrame(measureFrame)
608
+ }
609
+ })
610
+
482
611
  function resolveRewriteActions(
483
612
  actions: MarkdownRewriteAction[],
484
613
  handler?: AiDocumentEditorProps['onrewrite'],
@@ -786,6 +915,10 @@ ${safeCode}
786
915
  }
787
916
  }
788
917
 
918
+ /**
919
+ * 统一承接正文里通过 `{@html}` 注入的交互节点点击。
920
+ * 这些按钮和链接不受 Svelte 逐个绑定管理,因此在容器级别做代理更稳妥。
921
+ */
789
922
  async function handleClick(event: MouseEvent): Promise<void> {
790
923
  const target = event.target as HTMLElement
791
924
  const copyButton = target.closest(
@@ -822,6 +955,22 @@ ${safeCode}
822
955
  const codeBlockId = runButton?.dataset.codeBlockId
823
956
  if (runButton && codeBlockId) {
824
957
  await runCodeBlock(codeBlockId)
958
+ return
959
+ }
960
+
961
+ const link = target.closest('a[href]') as HTMLAnchorElement | null
962
+ if (editable && link && previewHost?.contains(link)) {
963
+ const href = link.href.trim()
964
+ if (!href) {
965
+ return
966
+ }
967
+
968
+ event.preventDefault()
969
+ window.open(
970
+ href,
971
+ link.target || '_blank',
972
+ 'noopener,noreferrer',
973
+ )
825
974
  }
826
975
  }
827
976
 
@@ -963,7 +1112,18 @@ ${safeCode}
963
1112
  }
964
1113
 
965
1114
  function readBlockFormat(element: HTMLElement | null): MarkdownBlockStyleKind {
966
- const tagName = getClosestBlockElement(element)?.tagName.toLowerCase()
1115
+ const block = getClosestBlockElement(element)
1116
+ const tagName = block?.tagName.toLowerCase()
1117
+
1118
+ if (tagName === 'li') {
1119
+ const listTag = block?.parentElement?.tagName.toLowerCase()
1120
+ if (listTag === 'ol') {
1121
+ return 'orderedList'
1122
+ }
1123
+ if (listTag === 'ul') {
1124
+ return 'bulletList'
1125
+ }
1126
+ }
967
1127
 
968
1128
  if (tagName === 'h1') {
969
1129
  return 'heading1'
@@ -1090,6 +1250,370 @@ ${safeCode}
1090
1250
  }
1091
1251
  }
1092
1252
 
1253
+ /**
1254
+ * 下面这组 helper 在 contenteditable DOM 与“正文纯文本偏移”之间来回映射。
1255
+ * 选区工具条一旦失焦或内容局部重渲染,就靠它们把原选区重新定位回来。
1256
+ */
1257
+ function getNodeTextLength(node: Node | null | undefined): number {
1258
+ if (!node) {
1259
+ return 0
1260
+ }
1261
+
1262
+ if (node.nodeType === Node.TEXT_NODE) {
1263
+ return node.textContent?.length ?? 0
1264
+ }
1265
+
1266
+ let length = 0
1267
+ for (const childNode of Array.from(node.childNodes)) {
1268
+ length += getNodeTextLength(childNode)
1269
+ }
1270
+
1271
+ return length
1272
+ }
1273
+
1274
+ function getTextOffsetWithinNode(node: Node, offset: number): number {
1275
+ if (node.nodeType === Node.TEXT_NODE) {
1276
+ return Math.max(0, Math.min(offset, node.textContent?.length ?? 0))
1277
+ }
1278
+
1279
+ const childNodes = Array.from(node.childNodes)
1280
+ const nextOffset = Math.max(0, Math.min(offset, childNodes.length))
1281
+ let length = 0
1282
+
1283
+ for (const childNode of childNodes.slice(0, nextOffset)) {
1284
+ length += getNodeTextLength(childNode)
1285
+ }
1286
+
1287
+ return length
1288
+ }
1289
+
1290
+ function getTextOffsetBeforeNode(root: Node, target: Node): number | null {
1291
+ let length = 0
1292
+
1293
+ const visit = (node: Node): boolean => {
1294
+ if (node === target) {
1295
+ return true
1296
+ }
1297
+
1298
+ if (node.nodeType === Node.TEXT_NODE) {
1299
+ length += node.textContent?.length ?? 0
1300
+ return false
1301
+ }
1302
+
1303
+ for (const childNode of Array.from(node.childNodes)) {
1304
+ if (visit(childNode)) {
1305
+ return true
1306
+ }
1307
+ }
1308
+
1309
+ return false
1310
+ }
1311
+
1312
+ return visit(root) ? length : null
1313
+ }
1314
+
1315
+ function getTextOffsetInPreview(node: Node, offset: number): number | null {
1316
+ if (!previewHost || (node !== previewHost && !previewHost.contains(node))) {
1317
+ return null
1318
+ }
1319
+
1320
+ const prefixLength = getTextOffsetBeforeNode(previewHost, node)
1321
+ if (prefixLength === null) {
1322
+ return null
1323
+ }
1324
+
1325
+ return prefixLength + getTextOffsetWithinNode(node, offset)
1326
+ }
1327
+
1328
+ function resolveTextPositionInPreview(
1329
+ offset: number,
1330
+ ): { node: Node, offset: number } | null {
1331
+ if (!previewHost) {
1332
+ return null
1333
+ }
1334
+
1335
+ const targetOffset = Math.max(0, offset)
1336
+ const walker = document.createTreeWalker(previewHost, NodeFilter.SHOW_TEXT)
1337
+ let current = walker.nextNode()
1338
+ let accumulatedLength = 0
1339
+ let lastTextNode: Node | null = null
1340
+
1341
+ while (current) {
1342
+ const textLength = current.textContent?.length ?? 0
1343
+ if (targetOffset <= accumulatedLength + textLength) {
1344
+ return {
1345
+ node: current,
1346
+ offset: Math.max(0, targetOffset - accumulatedLength),
1347
+ }
1348
+ }
1349
+
1350
+ accumulatedLength += textLength
1351
+ lastTextNode = current
1352
+ current = walker.nextNode()
1353
+ }
1354
+
1355
+ if (lastTextNode) {
1356
+ return {
1357
+ node: lastTextNode,
1358
+ offset: lastTextNode.textContent?.length ?? 0,
1359
+ }
1360
+ }
1361
+
1362
+ return {
1363
+ node: previewHost,
1364
+ offset: Math.min(targetOffset, previewHost.childNodes.length),
1365
+ }
1366
+ }
1367
+
1368
+ /**
1369
+ * 把当前 Range 压缩成纯文本偏移快照。
1370
+ * 只记录非空选区,避免工具条动作把折叠光标也当成可恢复选区。
1371
+ */
1372
+ function capturePreviewSelectionSnapshot(
1373
+ range: Range,
1374
+ ): MarkdownSelectionSnapshot | null {
1375
+ if (!previewHost) {
1376
+ return null
1377
+ }
1378
+
1379
+ const start = getTextOffsetInPreview(range.startContainer, range.startOffset)
1380
+ const end = getTextOffsetInPreview(range.endContainer, range.endOffset)
1381
+ const text = range.toString()
1382
+
1383
+ if (start === null || end === null || end <= start || !text.trim()) {
1384
+ return null
1385
+ }
1386
+
1387
+ return { start, end, text }
1388
+ }
1389
+
1390
+ /**
1391
+ * 用快照重新读取一遍当前正文中的文本,用来判断 DOM 变化后原 offsets 是否仍然可信。
1392
+ */
1393
+ function readTextFromSnapshot(
1394
+ snapshot: MarkdownSelectionSnapshot,
1395
+ ): string | null {
1396
+ const startPosition = resolveTextPositionInPreview(snapshot.start)
1397
+ const endPosition = resolveTextPositionInPreview(snapshot.end)
1398
+ if (!startPosition || !endPosition) {
1399
+ return null
1400
+ }
1401
+
1402
+ try {
1403
+ const range = document.createRange()
1404
+ range.setStart(startPosition.node, startPosition.offset)
1405
+ range.setEnd(endPosition.node, endPosition.offset)
1406
+ return range.toString()
1407
+ }
1408
+ catch {
1409
+ return null
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * 当原 offsets 对应的文本已经变了时,按“离旧起点最近”的同文本片段回退。
1415
+ * 这样比只取第一个命中更接近用户刚才真实操作的位置。
1416
+ */
1417
+ function findClosestSelectionOffsets(
1418
+ snapshot: MarkdownSelectionSnapshot,
1419
+ ): { start: number, end: number } | null {
1420
+ if (!previewHost || !snapshot.text) {
1421
+ return null
1422
+ }
1423
+
1424
+ const textContent = previewHost.textContent ?? ''
1425
+ let bestMatchStart = -1
1426
+ let smallestDistance = Number.POSITIVE_INFINITY
1427
+ let searchIndex = textContent.indexOf(snapshot.text)
1428
+
1429
+ while (searchIndex >= 0) {
1430
+ const distance = Math.abs(searchIndex - snapshot.start)
1431
+ if (distance < smallestDistance) {
1432
+ smallestDistance = distance
1433
+ bestMatchStart = searchIndex
1434
+ }
1435
+
1436
+ searchIndex = textContent.indexOf(snapshot.text, searchIndex + 1)
1437
+ }
1438
+
1439
+ if (bestMatchStart < 0) {
1440
+ return null
1441
+ }
1442
+
1443
+ return {
1444
+ start: bestMatchStart,
1445
+ end: bestMatchStart + snapshot.text.length,
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * 优先按纯文本偏移恢复选区;如果 DOM 结构变动导致 offsets 漂移,再退回最近文本片段。
1451
+ * 这一步会主动把焦点拉回正文,保证后续格式化命令仍作用于内容区。
1452
+ */
1453
+ function restoreSelectionFromSnapshot(
1454
+ snapshot: MarkdownSelectionSnapshot,
1455
+ ): boolean {
1456
+ if (typeof window === 'undefined' || !previewHost) {
1457
+ return false
1458
+ }
1459
+
1460
+ const selection = window.getSelection()
1461
+ if (!selection || snapshot.end <= snapshot.start) {
1462
+ return false
1463
+ }
1464
+
1465
+ let nextOffsets = {
1466
+ start: snapshot.start,
1467
+ end: snapshot.end,
1468
+ }
1469
+ const currentText = readTextFromSnapshot(snapshot)
1470
+ if (snapshot.text && currentText !== snapshot.text) {
1471
+ const matchedOffsets = findClosestSelectionOffsets(snapshot)
1472
+ if (!matchedOffsets) {
1473
+ return false
1474
+ }
1475
+
1476
+ nextOffsets = matchedOffsets
1477
+ }
1478
+
1479
+ const startPosition = resolveTextPositionInPreview(nextOffsets.start)
1480
+ const endPosition = resolveTextPositionInPreview(nextOffsets.end)
1481
+ if (!startPosition || !endPosition) {
1482
+ return false
1483
+ }
1484
+
1485
+ try {
1486
+ const range = document.createRange()
1487
+ range.setStart(startPosition.node, startPosition.offset)
1488
+ range.setEnd(endPosition.node, endPosition.offset)
1489
+ previewHost.focus()
1490
+ selection.removeAllRanges()
1491
+ selection.addRange(range)
1492
+ return true
1493
+ }
1494
+ catch {
1495
+ return false
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * 纯文本匹配是最后一道兜底,只在 Range 和偏移快照都失效时使用。
1501
+ * 它无法区分完全相同的多个片段,所以只适合作为尽力恢复方案。
1502
+ */
1503
+ function restoreSelectionByTextMatch(text: string): boolean {
1504
+ if (typeof window === 'undefined' || !previewHost || !text.trim()) {
1505
+ return false
1506
+ }
1507
+
1508
+ const selection = window.getSelection()
1509
+ if (!selection) {
1510
+ return false
1511
+ }
1512
+
1513
+ const walker = document.createTreeWalker(previewHost, NodeFilter.SHOW_TEXT)
1514
+ let current = walker.nextNode()
1515
+
1516
+ while (current) {
1517
+ const content = current.textContent ?? ''
1518
+ const start = content.indexOf(text)
1519
+ if (start >= 0) {
1520
+ try {
1521
+ const range = document.createRange()
1522
+ range.setStart(current, start)
1523
+ range.setEnd(current, start + text.length)
1524
+ previewHost.focus()
1525
+ selection.removeAllRanges()
1526
+ selection.addRange(range)
1527
+ return true
1528
+ }
1529
+ catch {
1530
+ return false
1531
+ }
1532
+ }
1533
+
1534
+ current = walker.nextNode()
1535
+ }
1536
+
1537
+ return false
1538
+ }
1539
+
1540
+ /**
1541
+ * 工具条包含输入框和下拉面板,焦点落在内部时不应把“正文失焦”误判成关闭信号。
1542
+ */
1543
+ function isSelectionChromeFocused(): boolean {
1544
+ if (typeof document === 'undefined' || !selectionChromeEl) {
1545
+ return false
1546
+ }
1547
+
1548
+ const activeElement = document.activeElement
1549
+ return activeElement instanceof HTMLElement && selectionChromeEl.contains(activeElement)
1550
+ }
1551
+
1552
+ /**
1553
+ * 同时保存浏览器原生 Range 和纯文本快照。
1554
+ * 前者恢复精度更高,后者能跨 DOM 重建,两者组合能覆盖大多数编辑场景。
1555
+ */
1556
+ function rememberPreviewSelection(range: Range): void {
1557
+ savedSelectionRange = range.cloneRange()
1558
+ savedSelectionSnapshot = capturePreviewSelectionSnapshot(range)
1559
+ }
1560
+
1561
+ /**
1562
+ * 选区恢复按“快照 -> 原始 Range -> 文本兜底”三层顺序尝试。
1563
+ * 这样既优先命中最精确的位置,也能在内容轻微重排后保留工具条操作连续性。
1564
+ */
1565
+ function restoreSavedPreviewSelection(): boolean {
1566
+ if (savedSelectionSnapshot && restoreSelectionFromSnapshot(savedSelectionSnapshot)) {
1567
+ return true
1568
+ }
1569
+
1570
+ if (typeof window === 'undefined' || !previewHost || !savedSelectionRange) {
1571
+ const fallbackText = savedSelectionSnapshot?.text || selectedText
1572
+ return restoreSelectionByTextMatch(fallbackText)
1573
+ }
1574
+
1575
+ const selection = window.getSelection()
1576
+ if (!selection) {
1577
+ return false
1578
+ }
1579
+
1580
+ try {
1581
+ const restoredRange = savedSelectionRange.cloneRange()
1582
+ if (
1583
+ !previewHost.contains(restoredRange.startContainer)
1584
+ || !previewHost.contains(restoredRange.endContainer)
1585
+ ) {
1586
+ return false
1587
+ }
1588
+
1589
+ previewHost.focus()
1590
+ selection.removeAllRanges()
1591
+ selection.addRange(restoredRange)
1592
+ return true
1593
+ }
1594
+ catch {
1595
+ const fallbackText = savedSelectionSnapshot?.text || selectedText
1596
+ return restoreSelectionByTextMatch(fallbackText)
1597
+ }
1598
+ }
1599
+
1600
+ /**
1601
+ * 工具条动作触发前先同步一次真实选区,避免点击按钮后浏览器把正文选区清掉。
1602
+ */
1603
+ function syncSelectionForToolbarAction(): boolean {
1604
+ const previewSelection = readPreviewSelectionState()
1605
+ if (previewSelection) {
1606
+ rememberPreviewSelection(previewSelection.range)
1607
+ selectedText = previewSelection.text
1608
+ return true
1609
+ }
1610
+
1611
+ return restoreSavedPreviewSelection()
1612
+ }
1613
+
1614
+ /**
1615
+ * 所有关闭动作都走同一个 timer,方便菜单 hover、输入框 focus 等场景随时撤销关闭。
1616
+ */
1093
1617
  function clearPendingSelectionToolbarClose(): void {
1094
1618
  if (typeof window === 'undefined' || !selectionToolbarCloseTimer) {
1095
1619
  return
@@ -1099,135 +1623,406 @@ ${safeCode}
1099
1623
  selectionToolbarCloseTimer = undefined
1100
1624
  }
1101
1625
 
1626
+ /**
1627
+ * 关闭工具条刻意延后一小拍,给菜单 hover、输入框 focus 和选区回流留出时间。
1628
+ * 只要子菜单还开着,或焦点仍在 chrome 里,就继续保留当前工具条。
1629
+ */
1102
1630
  function scheduleSelectionToolbarClose(): void {
1103
1631
  if (typeof window === 'undefined') {
1104
1632
  closeSelectionToolbar()
1105
1633
  return
1106
1634
  }
1107
1635
 
1108
- if (!selectionToolbarVisible) {
1109
- closeSelectionToolbar()
1636
+ if (!selectionToolbarVisible) {
1637
+ closeSelectionToolbar()
1638
+ return
1639
+ }
1640
+
1641
+ if (activeSelectionMenu) {
1642
+ return
1643
+ }
1644
+
1645
+ clearPendingSelectionToolbarClose()
1646
+ selectionToolbarCloseTimer = window.setTimeout(() => {
1647
+ selectionToolbarCloseTimer = undefined
1648
+ if (activeSelectionMenu || isSelectionChromeFocused()) {
1649
+ return
1650
+ }
1651
+
1652
+ if (!readPreviewSelectionState()) {
1653
+ closeSelectionToolbar()
1654
+ }
1655
+ }, 600)
1656
+ }
1657
+
1658
+ /**
1659
+ * 选区变化可能在一帧内收到多次事件,这里统一合并,避免定位和状态读取来回抖动。
1660
+ */
1661
+ function queueSelectionRefresh(remeasured = false): void {
1662
+ if (typeof window === 'undefined') {
1663
+ return
1664
+ }
1665
+
1666
+ selectionRefreshNeedsRemeasure ||= remeasured
1667
+ if (selectionRefreshFrame) {
1668
+ return
1669
+ }
1670
+
1671
+ selectionRefreshFrame = window.requestAnimationFrame(() => {
1672
+ const nextRemeasured = selectionRefreshNeedsRemeasure
1673
+ selectionRefreshFrame = undefined
1674
+ selectionRefreshNeedsRemeasure = false
1675
+ handleSelectionChange(nextRemeasured)
1676
+ })
1677
+ }
1678
+
1679
+ /**
1680
+ * 子菜单首次打开前拿不到真实宽度,先给一个稳定的经验值参与贴边计算。
1681
+ */
1682
+ function estimateSelectionMenuWidth(menu: SelectionMenuKind): number {
1683
+ if (menu === 'color' || menu === 'link') {
1684
+ return 256
1685
+ }
1686
+
1687
+ if (menu === 'rewrite') {
1688
+ return 320
1689
+ }
1690
+
1691
+ return 288
1692
+ }
1693
+
1694
+ /**
1695
+ * 下拉面板以触发按钮为锚点,但最终位置要限制在工具条宽度内,避免菜单越界裁切。
1696
+ */
1697
+ function updateSelectionMenuAnchor(menu: SelectionMenuKind): void {
1698
+ if (!selectionToolbarEl || !activeSelectionMenuTrigger) {
1699
+ selectionMenuLeft = 0
1700
+ selectionMenuAlignment = 'center'
1701
+ return
1702
+ }
1703
+
1704
+ const toolbarWidth = Math.max(
1705
+ selectionToolbarEl.offsetWidth,
1706
+ selectionToolbarEl.clientWidth,
1707
+ )
1708
+ const triggerCenter
1709
+ = activeSelectionMenuTrigger.offsetLeft
1710
+ + activeSelectionMenuTrigger.offsetWidth / 2
1711
+ const estimatedHalfWidth = Math.min(
1712
+ estimateSelectionMenuWidth(menu) / 2,
1713
+ Math.max(112, (toolbarWidth - 16) / 2),
1714
+ )
1715
+ const minCenter = 8 + estimatedHalfWidth
1716
+ const maxCenter = Math.max(minCenter, toolbarWidth - 8 - estimatedHalfWidth)
1717
+
1718
+ if (triggerCenter <= minCenter) {
1719
+ selectionMenuLeft = 0
1720
+ selectionMenuAlignment = 'left'
1721
+ return
1722
+ }
1723
+
1724
+ if (triggerCenter >= maxCenter) {
1725
+ selectionMenuLeft = toolbarWidth
1726
+ selectionMenuAlignment = 'right'
1727
+ return
1728
+ }
1729
+
1730
+ selectionMenuLeft = triggerCenter
1731
+ selectionMenuAlignment = 'center'
1732
+ }
1733
+
1734
+ /**
1735
+ * 工具条初次出现时尺寸还没稳定,下一帧再量一次可以避免首帧贴边位置偏差。
1736
+ */
1737
+ function scheduleSelectionToolbarMeasurement(): void {
1738
+ if (
1739
+ typeof window === 'undefined'
1740
+ || selectionToolbarMeasurePending
1741
+ || !selectionToolbarVisible
1742
+ ) {
1743
+ return
1744
+ }
1745
+
1746
+ selectionToolbarMeasurePending = true
1747
+ window.requestAnimationFrame(() => {
1748
+ selectionToolbarMeasurePending = false
1749
+ if (selectionToolbarVisible) {
1750
+ handleSelectionChange(true)
1751
+ }
1752
+ })
1753
+ }
1754
+
1755
+ /**
1756
+ * 子菜单统一从这里开关,顺手记录触发按钮和链接初始值,保证定位与默认值一致。
1757
+ */
1758
+ function toggleSelectionMenu(menu: SelectionMenuKind, event?: MouseEvent): void {
1759
+ clearPendingSelectionToolbarClose()
1760
+ const opening = activeSelectionMenu !== menu
1761
+ activeSelectionMenu = opening ? menu : null
1762
+ activeSelectionMenuTrigger = opening && event?.currentTarget instanceof HTMLElement
1763
+ ? event.currentTarget
1764
+ : null
1765
+ if (opening) {
1766
+ updateSelectionMenuAnchor(menu)
1767
+ }
1768
+ if (opening && menu === 'link') {
1769
+ linkDraft = selectionFormatState.linkHref || 'https://'
1770
+ }
1771
+ }
1772
+
1773
+ /**
1774
+ * 关闭时把选区相关缓存一并清干净,避免下一次打开还误用上一次的链接和格式状态。
1775
+ */
1776
+ function closeSelectionToolbar(): void {
1777
+ clearPendingSelectionToolbarClose()
1778
+ selectionToolbarVisible = false
1779
+ activeSelectionMenu = null
1780
+ activeSelectionMenuTrigger = null
1781
+ selectionMenuLeft = 0
1782
+ selectionMenuAlignment = 'center'
1783
+ selectedText = ''
1784
+ linkDraft = ''
1785
+ savedSelectionRange = null
1786
+ savedSelectionSnapshot = null
1787
+ selectionFormatState = { ...DEFAULT_SELECTION_FORMAT_STATE }
1788
+ }
1789
+
1790
+ /**
1791
+ * 面板上的按钮点击不应让浏览器先把正文选区清掉。
1792
+ * 但输入框要保留原生 focus 行为,方便直接编辑链接。
1793
+ */
1794
+ function handleSelectionPanelMouseDown(event: MouseEvent): void {
1795
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1796
+ return
1797
+ }
1798
+
1799
+ event.preventDefault()
1800
+ }
1801
+
1802
+ function clearPendingHoveredLinkHide(): void {
1803
+ if (typeof window === 'undefined' || !hoveredLinkHideTimer) {
1804
+ return
1805
+ }
1806
+
1807
+ window.clearTimeout(hoveredLinkHideTimer)
1808
+ hoveredLinkHideTimer = undefined
1809
+ }
1810
+
1811
+ function closeHoveredLink(): void {
1812
+ clearPendingHoveredLinkHide()
1813
+ hoveredLinkState = null
1814
+ }
1815
+
1816
+ function scheduleHoveredLinkHide(): void {
1817
+ if (typeof window === 'undefined') {
1818
+ closeHoveredLink()
1819
+ return
1820
+ }
1821
+
1822
+ clearPendingHoveredLinkHide()
1823
+ hoveredLinkHideTimer = window.setTimeout(() => {
1824
+ hoveredLinkHideTimer = undefined
1825
+ hoveredLinkState = null
1826
+ }, 140)
1827
+ }
1828
+
1829
+ function resolveHoveredLinkTargets(
1830
+ link: HTMLAnchorElement,
1831
+ ): { href: string, rawHref: string } | null {
1832
+ const rawHref = link.getAttribute('href')?.trim() ?? ''
1833
+ const href = link.href.trim() || rawHref
1834
+ if (!href) {
1835
+ return null
1836
+ }
1837
+
1838
+ return {
1839
+ href,
1840
+ rawHref: rawHref || href,
1841
+ }
1842
+ }
1843
+
1844
+ function measureHoveredLinkPopover(): { width: number, height: number } {
1845
+ const width = hoveredLinkPopoverEl
1846
+ ? Math.max(
1847
+ hoveredLinkPopoverEl.offsetWidth,
1848
+ hoveredLinkPopoverEl.clientWidth,
1849
+ )
1850
+ : 92
1851
+ const height = hoveredLinkPopoverEl
1852
+ ? Math.max(
1853
+ hoveredLinkPopoverEl.offsetHeight,
1854
+ hoveredLinkPopoverEl.clientHeight,
1855
+ )
1856
+ : 40
1857
+
1858
+ return { width, height }
1859
+ }
1860
+
1861
+ function buildHoveredLinkState(
1862
+ link: HTMLAnchorElement,
1863
+ ): HoveredLinkState | null {
1864
+ if (!editorScrollHost || !previewHost || !previewHost.contains(link)) {
1865
+ return null
1866
+ }
1867
+
1868
+ const targets = resolveHoveredLinkTargets(link)
1869
+ if (!targets) {
1870
+ return null
1871
+ }
1872
+
1873
+ const hostRect = editorScrollHost.getBoundingClientRect()
1874
+ const linkRect = link.getBoundingClientRect()
1875
+ const { width, height } = measureHoveredLinkPopover()
1876
+ const offset = 8
1877
+ const horizontalPadding = 16
1878
+ const topSpace = linkRect.top - hostRect.top
1879
+ const bottomSpace = hostRect.bottom - linkRect.bottom
1880
+ const placement: HoveredLinkState['placement']
1881
+ = topSpace >= height + offset || bottomSpace < height + offset
1882
+ ? 'top'
1883
+ : 'bottom'
1884
+ const center = linkRect.left - hostRect.left + linkRect.width / 2
1885
+ const maxLeft = Math.max(
1886
+ horizontalPadding,
1887
+ editorScrollHost.clientWidth - horizontalPadding - width,
1888
+ )
1889
+ const left = Math.min(
1890
+ Math.max(center - width / 2, horizontalPadding),
1891
+ maxLeft,
1892
+ )
1893
+ const top = placement === 'top'
1894
+ ? editorScrollHost.scrollTop + linkRect.top - hostRect.top - height - offset
1895
+ : editorScrollHost.scrollTop + linkRect.bottom - hostRect.top + offset
1896
+
1897
+ return {
1898
+ element: link,
1899
+ href: targets.href,
1900
+ rawHref: targets.rawHref,
1901
+ top: Math.max(8, top),
1902
+ left,
1903
+ placement,
1904
+ }
1905
+ }
1906
+
1907
+ function syncHoveredLinkPosition(): void {
1908
+ if (!hoveredLinkState) {
1110
1909
  return
1111
1910
  }
1112
1911
 
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') {
1912
+ if (!hoveredLinkState.element.isConnected) {
1913
+ closeHoveredLink()
1124
1914
  return
1125
1915
  }
1126
1916
 
1127
- selectionRefreshNeedsRemeasure ||= remeasured
1128
- if (selectionRefreshFrame) {
1917
+ const nextState = buildHoveredLinkState(hoveredLinkState.element)
1918
+ if (!nextState) {
1919
+ closeHoveredLink()
1129
1920
  return
1130
1921
  }
1131
1922
 
1132
- selectionRefreshFrame = window.requestAnimationFrame(() => {
1133
- const nextRemeasured = selectionRefreshNeedsRemeasure
1134
- selectionRefreshFrame = undefined
1135
- selectionRefreshNeedsRemeasure = false
1136
- handleSelectionChange(nextRemeasured)
1137
- })
1923
+ hoveredLinkState = nextState
1138
1924
  }
1139
1925
 
1140
- function estimateSelectionMenuWidth(menu: SelectionMenuKind): number {
1141
- if (menu === 'color' || menu === 'link') {
1142
- return 256
1926
+ function resolveColorOptionValue(colorOption: string | ColorSwatchOption): string | null {
1927
+ return typeof colorOption === 'string' ? colorOption : colorOption.value
1928
+ }
1929
+
1930
+ function updateHoveredLink(link: HTMLAnchorElement | null): void {
1931
+ if (!editable || !onapplylink || selectionToolbarVisible) {
1932
+ closeHoveredLink()
1933
+ return
1143
1934
  }
1144
1935
 
1145
- if (menu === 'rewrite') {
1146
- return 320
1936
+ if (!link) {
1937
+ scheduleHoveredLinkHide()
1938
+ return
1147
1939
  }
1148
1940
 
1149
- return 288
1941
+ const nextState = buildHoveredLinkState(link)
1942
+ if (!nextState) {
1943
+ scheduleHoveredLinkHide()
1944
+ return
1945
+ }
1946
+
1947
+ clearPendingHoveredLinkHide()
1948
+ hoveredLinkState = nextState
1150
1949
  }
1151
1950
 
1152
- function updateSelectionMenuAnchor(menu: SelectionMenuKind): void {
1153
- if (!selectionToolbarEl || !activeSelectionMenuTrigger) {
1154
- selectionMenuLeft = 0
1155
- selectionMenuAlignment = 'center'
1951
+ function openHoveredLink(): void {
1952
+ if (typeof window === 'undefined' || !hoveredLinkState?.href) {
1156
1953
  return
1157
1954
  }
1158
1955
 
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),
1956
+ window.open(
1957
+ hoveredLinkState.href,
1958
+ hoveredLinkState.element.target || '_blank',
1959
+ 'noopener,noreferrer',
1169
1960
  )
1170
- const minCenter = 8 + estimatedHalfWidth
1171
- const maxCenter = Math.max(minCenter, toolbarWidth - 8 - estimatedHalfWidth)
1961
+ }
1172
1962
 
1173
- if (triggerCenter <= minCenter) {
1174
- selectionMenuLeft = 0
1175
- selectionMenuAlignment = 'left'
1176
- return
1963
+ function createRangeForLinkText(link: HTMLAnchorElement): Range {
1964
+ const range = document.createRange()
1965
+ const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT)
1966
+ let current = walker.nextNode()
1967
+ let firstTextNode: Node | null = null
1968
+ let lastTextNode: Node | null = null
1969
+
1970
+ while (current) {
1971
+ if ((current.textContent ?? '').length > 0) {
1972
+ firstTextNode ??= current
1973
+ lastTextNode = current
1974
+ }
1975
+
1976
+ current = walker.nextNode()
1177
1977
  }
1178
1978
 
1179
- if (triggerCenter >= maxCenter) {
1180
- selectionMenuLeft = toolbarWidth
1181
- selectionMenuAlignment = 'right'
1182
- return
1979
+ if (!firstTextNode || !lastTextNode) {
1980
+ range.selectNodeContents(link)
1981
+ return range
1183
1982
  }
1184
1983
 
1185
- selectionMenuLeft = triggerCenter
1186
- selectionMenuAlignment = 'center'
1984
+ range.setStart(firstTextNode, 0)
1985
+ range.setEnd(lastTextNode, lastTextNode.textContent?.length ?? 0)
1986
+ return range
1187
1987
  }
1188
1988
 
1189
- function scheduleSelectionToolbarMeasurement(): void {
1989
+ async function editHoveredLink(): Promise<void> {
1190
1990
  if (
1191
1991
  typeof window === 'undefined'
1192
- || selectionToolbarMeasurePending
1193
- || !selectionToolbarVisible
1992
+ || !previewHost
1993
+ || !onapplylink
1994
+ || !hoveredLinkState
1194
1995
  ) {
1195
1996
  return
1196
1997
  }
1197
1998
 
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)
1999
+ const link = hoveredLinkState.element
2000
+ const linkHref = hoveredLinkState.rawHref
2001
+ if (!previewHost.contains(link)) {
2002
+ closeHoveredLink()
2003
+ return
1215
2004
  }
1216
- if (opening && menu === 'link') {
1217
- linkDraft = selectionFormatState.linkHref || 'https://'
2005
+
2006
+ const selection = window.getSelection()
2007
+ if (!selection) {
2008
+ return
1218
2009
  }
1219
- }
1220
2010
 
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 }
2011
+ const range = createRangeForLinkText(link)
2012
+ previewHost.focus()
2013
+ selection.removeAllRanges()
2014
+ selection.addRange(range)
2015
+ rememberPreviewSelection(range)
2016
+ selectedText = range.toString().trim()
2017
+ closeHoveredLink()
2018
+ handleSelectionChange(true)
2019
+ await tick()
2020
+ linkDraft = linkHref || 'https://'
2021
+ activeSelectionMenu = 'link'
2022
+ activeSelectionMenuTrigger = selectionLinkButtonEl
2023
+ if (selectionLinkButtonEl) {
2024
+ updateSelectionMenuAnchor('link')
2025
+ }
1231
2026
  }
1232
2027
 
1233
2028
  /**
@@ -1293,6 +2088,7 @@ ${safeCode}
1293
2088
  function handleDocumentScroll(event: Event): void {
1294
2089
  syncActiveHeadingFromScroll()
1295
2090
  syncPinnedTitleVisibility()
2091
+ syncHoveredLinkPosition()
1296
2092
  if (selectionToolbarVisible) {
1297
2093
  queueSelectionRefresh(true)
1298
2094
  }
@@ -1332,11 +2128,16 @@ ${safeCode}
1332
2128
 
1333
2129
  const previewSelection = readPreviewSelectionState()
1334
2130
  if (!previewSelection) {
2131
+ if (savedSelectionRange && isSelectionChromeFocused()) {
2132
+ return
2133
+ }
2134
+
1335
2135
  scheduleSelectionToolbarClose()
1336
2136
  return
1337
2137
  }
1338
2138
 
1339
2139
  const { selection, range, text } = previewSelection
2140
+ hoveredLinkState = null
1340
2141
  const rect = range.getBoundingClientRect()
1341
2142
  const hostRect = editorScrollHost.getBoundingClientRect()
1342
2143
  // 浮层尺寸在首次渲染前拿不到精确高度,这里用稳定的经验值决定上下避让方向,避免直接盖住选中文本。
@@ -1351,31 +2152,47 @@ ${safeCode}
1351
2152
  : 'bottom'
1352
2153
  const center = rect.left - hostRect.left + rect.width / 2
1353
2154
  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),
2155
+ const outlineOverlap = outlinePanelEl
2156
+ ? Math.max(
2157
+ 0,
2158
+ Math.min(
2159
+ outlinePanelEl.getBoundingClientRect().right,
2160
+ hostRect.right,
2161
+ ) - hostRect.left,
2162
+ )
2163
+ : 0
2164
+ const minLeft = Math.max(
2165
+ horizontalPadding,
2166
+ outlineOverlap > 24 ? outlineOverlap + 12 : horizontalPadding,
1360
2167
  )
1361
- const minCenter = horizontalPadding + estimatedToolbarHalfWidth
1362
- const maxCenter = editorScrollHost.clientWidth - minCenter
2168
+ const measuredToolbarWidth = selectionChromeEl
2169
+ ? Math.max(selectionChromeEl.offsetWidth, selectionChromeEl.clientWidth)
2170
+ : selectionToolbarEl
2171
+ ? Math.max(selectionToolbarEl.offsetWidth, selectionToolbarEl.clientWidth)
2172
+ : 360
2173
+ const maxToolbarWidth = Math.max(
2174
+ 220,
2175
+ editorScrollHost.clientWidth - minLeft - horizontalPadding,
2176
+ )
2177
+ const toolbarWidth = Math.min(measuredToolbarWidth, maxToolbarWidth)
2178
+ const maxLeft = Math.max(
2179
+ minLeft,
2180
+ editorScrollHost.clientWidth - horizontalPadding - toolbarWidth,
2181
+ )
2182
+ const unclampedLeft = center - toolbarWidth / 2
2183
+ const toolbarLeft = Math.min(Math.max(unclampedLeft, minLeft), maxLeft)
1363
2184
  const alignment: SelectionToolbarPosition['alignment']
1364
- = center <= minCenter
2185
+ = toolbarLeft <= minLeft + 0.5
1365
2186
  ? 'left'
1366
- : center >= maxCenter
2187
+ : toolbarLeft >= maxLeft - 0.5
1367
2188
  ? 'right'
1368
2189
  : 'center'
1369
- const toolbarLeft = alignment === 'left'
1370
- ? horizontalPadding
1371
- : alignment === 'right'
1372
- ? editorScrollHost.clientWidth - horizontalPadding
1373
- : center
1374
2190
  const toolbarTop = placement === 'top'
1375
2191
  ? editorScrollHost.scrollTop + rect.top - hostRect.top - estimatedToolbarHeight - offset
1376
2192
  : editorScrollHost.scrollTop + rect.bottom - hostRect.top + offset
1377
2193
 
1378
2194
  selectedText = text
2195
+ rememberPreviewSelection(range)
1379
2196
  selectionToolbarVisible = true
1380
2197
  selectionFormatState = readSelectionFormatState(selection)
1381
2198
  toolbarPosition = {
@@ -1410,6 +2227,10 @@ ${safeCode}
1410
2227
  }
1411
2228
 
1412
2229
  function applyBlockFormat(kind: MarkdownBlockFormatKind): void {
2230
+ if (!syncSelectionForToolbarAction()) {
2231
+ return
2232
+ }
2233
+
1413
2234
  onapplyblockformat?.(kind)
1414
2235
  activeSelectionMenu = null
1415
2236
  activeSelectionMenuTrigger = null
@@ -1417,66 +2238,160 @@ ${safeCode}
1417
2238
  }
1418
2239
 
1419
2240
  function applyBlockStyle(kind: MarkdownBlockStyleKind): void {
2241
+ if (!syncSelectionForToolbarAction()) {
2242
+ return
2243
+ }
2244
+
1420
2245
  onapplyblockstyle?.(kind)
1421
2246
  activeSelectionMenu = null
1422
2247
  activeSelectionMenuTrigger = null
1423
2248
  queueSelectionRefresh()
1424
2249
  }
1425
2250
 
2251
+ /**
2252
+ * 行内样式按钮允许连续点按,因此这里不主动关掉工具条,只刷新状态。
2253
+ */
1426
2254
  function applyInlineFormat(kind: MarkdownInlineFormatKind): void {
2255
+ if (!syncSelectionForToolbarAction()) {
2256
+ return
2257
+ }
2258
+
2259
+ clearPendingSelectionToolbarClose()
1427
2260
  onapplyinlineformat?.(kind)
1428
2261
  queueSelectionRefresh()
1429
2262
  }
1430
2263
 
1431
2264
  function applyAlignment(kind: MarkdownTextAlignKind): void {
2265
+ if (!syncSelectionForToolbarAction()) {
2266
+ return
2267
+ }
2268
+
1432
2269
  onapplyalignment?.(kind)
1433
2270
  activeSelectionMenu = null
1434
2271
  activeSelectionMenuTrigger = null
1435
2272
  queueSelectionRefresh()
1436
2273
  }
1437
2274
 
2275
+ /**
2276
+ * 链接菜单提交时总是以恢复后的正文选区为准,避免输入框焦点把目标文本丢掉。
2277
+ */
1438
2278
  function applyLink(): void {
1439
- onapplylink?.(linkDraft.trim() || null)
2279
+ if (!syncSelectionForToolbarAction()) {
2280
+ return
2281
+ }
2282
+
2283
+ onapplylink?.(
2284
+ linkDraft.trim() || null,
2285
+ savedSelectionSnapshot
2286
+ ? {
2287
+ ...savedSelectionSnapshot,
2288
+ }
2289
+ : undefined,
2290
+ selectedText.trim() || undefined,
2291
+ )
1440
2292
  activeSelectionMenu = null
1441
2293
  activeSelectionMenuTrigger = null
1442
2294
  queueSelectionRefresh()
1443
2295
  }
1444
2296
 
1445
2297
  function removeLink(): void {
1446
- onapplylink?.(null)
2298
+ if (!syncSelectionForToolbarAction()) {
2299
+ return
2300
+ }
2301
+
2302
+ onapplylink?.(
2303
+ null,
2304
+ savedSelectionSnapshot
2305
+ ? {
2306
+ ...savedSelectionSnapshot,
2307
+ }
2308
+ : undefined,
2309
+ selectedText.trim() || undefined,
2310
+ )
1447
2311
  activeSelectionMenu = null
1448
2312
  activeSelectionMenuTrigger = null
1449
2313
  queueSelectionRefresh()
1450
2314
  }
1451
2315
 
2316
+ /**
2317
+ * 颜色动作除了颜色值,还会把当前选中文本一起带给外层,供宿主侧做兜底选区恢复。
2318
+ */
1452
2319
  function applyColor(request: MarkdownColorFormatRequest): void {
1453
- onapplycolor?.(request)
2320
+ if (!syncSelectionForToolbarAction()) {
2321
+ return
2322
+ }
2323
+
2324
+ onapplycolor?.({
2325
+ ...request,
2326
+ selectedText: selectedText.trim() || undefined,
2327
+ selectionSnapshot: savedSelectionSnapshot
2328
+ ? {
2329
+ ...savedSelectionSnapshot,
2330
+ }
2331
+ : undefined,
2332
+ })
1454
2333
  queueSelectionRefresh()
1455
2334
  }
1456
2335
 
1457
2336
  async function copySelection(): Promise<void> {
2337
+ if (!syncSelectionForToolbarAction()) {
2338
+ return
2339
+ }
2340
+
1458
2341
  await oncopyselection?.()
1459
2342
  activeSelectionMenu = null
1460
2343
  activeSelectionMenuTrigger = null
1461
2344
  }
1462
2345
 
1463
2346
  async function annotateSelection(): Promise<void> {
2347
+ if (!syncSelectionForToolbarAction()) {
2348
+ return
2349
+ }
2350
+
1464
2351
  await onannotation?.()
1465
2352
  activeSelectionMenu = null
1466
2353
  activeSelectionMenuTrigger = null
1467
2354
  }
1468
2355
 
2356
+ function handlePreviewMouseMove(event: MouseEvent): void {
2357
+ const target = event.target
2358
+ const link = target instanceof Element
2359
+ ? target.closest<HTMLAnchorElement>('a[href]')
2360
+ : null
2361
+
2362
+ updateHoveredLink(link && previewHost?.contains(link) ? link : null)
2363
+ }
2364
+
2365
+ function handlePreviewMouseLeave(): void {
2366
+ scheduleHoveredLinkHide()
2367
+ }
2368
+
2369
+ /**
2370
+ * mouseup 比 selectionchange 更接近用户“完成一次选中”的时机,适合触发工具条刷新。
2371
+ */
1469
2372
  function handlePreviewMouseUp(): void {
1470
2373
  queueSelectionRefresh()
1471
2374
  onpreviewmouseup?.()
1472
2375
  }
1473
2376
 
2377
+ /**
2378
+ * 输入事件本身只透传给宿主;选区状态交给 selectionchange/mouseup 统一管理。
2379
+ */
1474
2380
  function handlePreviewInput(event: Event): void {
1475
2381
  onpreviewinput?.(event)
1476
2382
  }
1477
2383
 
1478
- function handlePreviewBlur(): void {
2384
+ /**
2385
+ * 正文失焦时先观察焦点是否转移到工具条内部,避免点击链接输入框时误触发提交或收起。
2386
+ */
2387
+ function handlePreviewBlur(event: FocusEvent): void {
1479
2388
  queueSelectionRefresh(true)
2389
+
2390
+ const nextTarget = event.relatedTarget
2391
+ if (nextTarget instanceof Node && selectionChromeEl?.contains(nextTarget)) {
2392
+ return
2393
+ }
2394
+
1480
2395
  onpreviewblur?.()
1481
2396
  }
1482
2397
  </script>
@@ -1625,7 +2540,7 @@ ${safeCode}
1625
2540
  class='hai-ai-doc-layout'
1626
2541
  >
1627
2542
  {#if showOutline && !outlineCollapsed}
1628
- <aside class='hai-ai-doc-outline'>
2543
+ <aside bind:this={outlinePanelEl} class='hai-ai-doc-outline'>
1629
2544
  <div class='hai-ai-doc-outline-head'>
1630
2545
  <strong>{uiM('markdown_outline')}</strong>
1631
2546
 
@@ -1654,7 +2569,7 @@ ${safeCode}
1654
2569
  style={`padding-left:${0.65 + (item.level - 1) * 0.56}rem`}
1655
2570
  onclick={() => scrollToHeading(item.id)}
1656
2571
  >
1657
- {item.numberedTitle}
2572
+ {showOutlineNumbering ? item.numberedTitle : item.text}
1658
2573
  </button>
1659
2574
  {/each}
1660
2575
  </nav>
@@ -1681,7 +2596,10 @@ ${safeCode}
1681
2596
  style={`top:${toolbarPosition.top}px; left:${toolbarPosition.left}px;`}
1682
2597
  >
1683
2598
  <div
2599
+ bind:this={selectionChromeEl}
1684
2600
  class='hai-ai-doc-selection-chrome'
2601
+ onmouseenter={clearPendingSelectionToolbarClose}
2602
+ onmouseleave={() => scheduleSelectionToolbarClose()}
1685
2603
  >
1686
2604
  <div
1687
2605
  bind:this={selectionToolbarEl}
@@ -1845,7 +2763,7 @@ ${safeCode}
1845
2763
  title={uiM('markdown_format_strike')}
1846
2764
  onclick={() => applyInlineFormat('strike')}
1847
2765
  >
1848
- S
2766
+ <s>S</s>
1849
2767
  </button>
1850
2768
  <button
1851
2769
  type='button'
@@ -1871,12 +2789,13 @@ ${safeCode}
1871
2789
  title={uiM('markdown_format_underline')}
1872
2790
  onclick={() => applyInlineFormat('underline')}
1873
2791
  >
1874
- U
2792
+ <u>U</u>
1875
2793
  </button>
1876
2794
  {/if}
1877
2795
 
1878
2796
  {#if onapplylink || onapplyinlineformat}
1879
2797
  <button
2798
+ bind:this={selectionLinkButtonEl}
1880
2799
  type='button'
1881
2800
  class={cn(
1882
2801
  'hai-ai-doc-selection-btn',
@@ -1893,10 +2812,18 @@ ${safeCode}
1893
2812
  ? toggleSelectionMenu('link', event)
1894
2813
  : applyInlineFormat('link')}
1895
2814
  >
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>
2815
+ <svg
2816
+ viewBox='0 0 24 24'
2817
+ aria-hidden='true'
2818
+ fill='none'
2819
+ stroke='currentColor'
2820
+ stroke-width='2'
2821
+ stroke-linecap='round'
2822
+ stroke-linejoin='round'
2823
+ >
2824
+ <path d='M9.5 14.5 14.5 9.5'></path>
2825
+ <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>
2826
+ <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
2827
  </svg>
1901
2828
  </button>
1902
2829
  {/if}
@@ -1915,19 +2842,6 @@ ${safeCode}
1915
2842
  >
1916
2843
  &lt;/&gt;
1917
2844
  </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
2845
  {/if}
1932
2846
 
1933
2847
  {#if onapplycolor}
@@ -2001,7 +2915,7 @@ ${safeCode}
2001
2915
  role='menu'
2002
2916
  tabindex='-1'
2003
2917
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2004
- onmousedown={event => event.preventDefault()}
2918
+ onmousedown={handleSelectionPanelMouseDown}
2005
2919
  >
2006
2920
  {#each resolvedRewriteActions as action}
2007
2921
  <button
@@ -2021,7 +2935,7 @@ ${safeCode}
2021
2935
  role='menu'
2022
2936
  tabindex='-1'
2023
2937
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2024
- onmousedown={event => event.preventDefault()}
2938
+ onmousedown={handleSelectionPanelMouseDown}
2025
2939
  >
2026
2940
  {#each BLOCK_FORMAT_OPTIONS as option}
2027
2941
  <button
@@ -2053,7 +2967,7 @@ ${safeCode}
2053
2967
  role='menu'
2054
2968
  tabindex='-1'
2055
2969
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2056
- onmousedown={event => event.preventDefault()}
2970
+ onmousedown={handleSelectionPanelMouseDown}
2057
2971
  >
2058
2972
  {#each ALIGN_OPTIONS as option}
2059
2973
  <button
@@ -2081,9 +2995,10 @@ ${safeCode}
2081
2995
  aria-label={uiM('markdown_link_dialog_label')}
2082
2996
  tabindex='-1'
2083
2997
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2084
- onmousedown={event => event.preventDefault()}
2998
+ onmousedown={handleSelectionPanelMouseDown}
2085
2999
  >
2086
3000
  <input
3001
+ bind:this={linkInputEl}
2087
3002
  class='hai-ai-doc-selection-input'
2088
3003
  bind:value={linkDraft}
2089
3004
  aria-label={uiM('markdown_link_input_label')}
@@ -2121,7 +3036,7 @@ ${safeCode}
2121
3036
  aria-label={uiM('markdown_color_dialog_label')}
2122
3037
  tabindex='-1'
2123
3038
  style={`--hai-ai-doc-selection-menu-left:${selectionMenuLeft}px;`}
2124
- onmousedown={event => event.preventDefault()}
3039
+ onmousedown={handleSelectionPanelMouseDown}
2125
3040
  >
2126
3041
  <div class='hai-ai-doc-color-group'>
2127
3042
  <div class='hai-ai-doc-color-group-title'>
@@ -2151,23 +3066,34 @@ ${safeCode}
2151
3066
  {uiM('markdown_background_color')}
2152
3067
  </div>
2153
3068
  <div class='hai-ai-doc-color-grid'>
2154
- {#each BACKGROUND_COLOR_PRESETS as color}
3069
+ {#each BACKGROUND_COLOR_OPTIONS as colorOption}
2155
3070
  <button
2156
3071
  type='button'
2157
3072
  class={cn(
2158
3073
  'hai-ai-doc-color-swatch',
2159
- selectionFormatState.backgroundColor === color
3074
+ selectionFormatState.backgroundColor === resolveColorOptionValue(colorOption)
2160
3075
  ? 'hai-ai-doc-color-swatch--active'
2161
3076
  : '',
2162
3077
  )}
2163
- title={color}
3078
+ data-empty={typeof colorOption === 'string'
3079
+ ? undefined
3080
+ : 'true'}
3081
+ title={typeof colorOption === 'string'
3082
+ ? colorOption
3083
+ : uiM(colorOption.titleKey)}
2164
3084
  onclick={() =>
2165
3085
  applyColor({
2166
3086
  target: 'background',
2167
- value: color,
3087
+ value: typeof colorOption === 'string'
3088
+ ? colorOption
3089
+ : colorOption.value,
2168
3090
  })}
2169
3091
  >
2170
- <span style={`background:${color};`}></span>
3092
+ <span
3093
+ style={typeof colorOption === 'string'
3094
+ ? `background:${colorOption};`
3095
+ : undefined}
3096
+ ></span>
2171
3097
  </button>
2172
3098
  {/each}
2173
3099
  </div>
@@ -2188,11 +3114,87 @@ ${safeCode}
2188
3114
  </div>
2189
3115
  {/if}
2190
3116
 
3117
+ {#if hoveredLinkState}
3118
+ <div
3119
+ bind:this={hoveredLinkPopoverEl}
3120
+ class='hai-ai-doc-link-hover'
3121
+ data-placement={hoveredLinkState.placement}
3122
+ style={`top:${hoveredLinkState.top}px; left:${hoveredLinkState.left}px;`}
3123
+ onmouseenter={clearPendingHoveredLinkHide}
3124
+ onmouseleave={handlePreviewMouseLeave}
3125
+ >
3126
+ <button
3127
+ type='button'
3128
+ class='hai-ai-doc-link-hover-btn'
3129
+ title={uiM('markdown_link_open')}
3130
+ aria-label={uiM('markdown_link_open')}
3131
+ onmousedown={event => event.preventDefault()}
3132
+ onclick={openHoveredLink}
3133
+ >
3134
+ <svg viewBox='0 0 24 24' aria-hidden='true'>
3135
+ <path
3136
+ d='M13.75 5.75h4.5v4.5'
3137
+ fill='none'
3138
+ stroke='currentColor'
3139
+ stroke-width='1.9'
3140
+ stroke-linecap='round'
3141
+ stroke-linejoin='round'
3142
+ ></path>
3143
+ <path
3144
+ d='M18 6 11 13'
3145
+ fill='none'
3146
+ stroke='currentColor'
3147
+ stroke-width='1.9'
3148
+ stroke-linecap='round'
3149
+ stroke-linejoin='round'
3150
+ ></path>
3151
+ <path
3152
+ 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'
3153
+ fill='none'
3154
+ stroke='currentColor'
3155
+ stroke-width='1.9'
3156
+ stroke-linecap='round'
3157
+ stroke-linejoin='round'
3158
+ ></path>
3159
+ </svg>
3160
+ </button>
3161
+ <button
3162
+ type='button'
3163
+ class='hai-ai-doc-link-hover-btn'
3164
+ title={uiM('markdown_link_edit')}
3165
+ aria-label={uiM('markdown_link_edit')}
3166
+ onmousedown={event => event.preventDefault()}
3167
+ onclick={() => void editHoveredLink()}
3168
+ >
3169
+ <svg viewBox='0 0 24 24' aria-hidden='true'>
3170
+ <path
3171
+ d='M4.75 16.88V19.25h2.37l9.96-9.96-2.37-2.37-9.96 9.96Z'
3172
+ fill='none'
3173
+ stroke='currentColor'
3174
+ stroke-width='1.9'
3175
+ stroke-linecap='round'
3176
+ stroke-linejoin='round'
3177
+ ></path>
3178
+ <path
3179
+ d='M13.96 7.92 16.33 5.55a1.68 1.68 0 0 1 2.37 2.37l-2.37 2.37'
3180
+ fill='none'
3181
+ stroke='currentColor'
3182
+ stroke-width='1.9'
3183
+ stroke-linecap='round'
3184
+ stroke-linejoin='round'
3185
+ ></path>
3186
+ </svg>
3187
+ </button>
3188
+ </div>
3189
+ {/if}
3190
+
2191
3191
  <article
2192
3192
  bind:this={previewHost}
2193
3193
  class={readerDocumentClass}
2194
3194
  contenteditable={editable}
2195
3195
  role='document'
3196
+ onmousemove={handlePreviewMouseMove}
3197
+ onmouseleave={handlePreviewMouseLeave}
2196
3198
  onmouseup={handlePreviewMouseUp}
2197
3199
  oninput={handlePreviewInput}
2198
3200
  onblur={handlePreviewBlur}
@@ -2576,21 +3578,17 @@ ${safeCode}
2576
3578
  display: block;
2577
3579
  width: fit-content;
2578
3580
  max-width: calc(100% - 2rem);
2579
- transform: translateX(-50%);
2580
3581
  transition:
2581
3582
  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);
3583
+ left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1);
2584
3584
  pointer-events: none;
2585
3585
  }
2586
3586
 
2587
3587
  .hai-ai-doc-selection-layer[data-alignment='left'] {
2588
- transform: translateX(0);
2589
3588
  justify-items: start;
2590
3589
  }
2591
3590
 
2592
3591
  .hai-ai-doc-selection-layer[data-alignment='right'] {
2593
- transform: translateX(-100%);
2594
3592
  justify-items: end;
2595
3593
  }
2596
3594
 
@@ -2605,6 +3603,55 @@ ${safeCode}
2605
3603
  max-width: min(100%, calc(100vw - 2rem));
2606
3604
  }
2607
3605
 
3606
+ .hai-ai-doc-link-hover {
3607
+ position: absolute;
3608
+ z-index: 58;
3609
+ display: inline-flex;
3610
+ align-items: center;
3611
+ gap: 0.18rem;
3612
+ padding: 0.22rem;
3613
+ border-radius: 9999px;
3614
+ background: color-mix(in srgb, oklch(var(--b1, 1 0 0)) 94%, white 6%);
3615
+ border: 1px solid color-mix(in srgb, oklch(var(--bc, 0.22 0 0)) 12%, white 88%);
3616
+ box-shadow:
3617
+ 0 20px 36px -26px rgb(15 23 42 / 0.26),
3618
+ 0 10px 18px -16px rgb(15 23 42 / 0.18);
3619
+ pointer-events: auto;
3620
+ transition:
3621
+ top 0.14s cubic-bezier(0.2, 0.85, 0.24, 1),
3622
+ left 0.14s cubic-bezier(0.2, 0.85, 0.24, 1);
3623
+ }
3624
+
3625
+ .hai-ai-doc-link-hover-btn {
3626
+ width: 2rem;
3627
+ height: 2rem;
3628
+ display: inline-flex;
3629
+ align-items: center;
3630
+ justify-content: center;
3631
+ border: none;
3632
+ border-radius: 9999px;
3633
+ background: transparent;
3634
+ color: oklch(var(--bc) / 0.76);
3635
+ cursor: pointer;
3636
+ transition:
3637
+ background-color 0.14s ease,
3638
+ color 0.14s ease,
3639
+ transform 0.14s ease;
3640
+ }
3641
+
3642
+ .hai-ai-doc-link-hover-btn:hover {
3643
+ background: oklch(var(--bc) / 0.06);
3644
+ color: oklch(var(--bc));
3645
+ transform: translateY(-1px);
3646
+ }
3647
+
3648
+ .hai-ai-doc-link-hover-btn svg {
3649
+ width: 1rem;
3650
+ height: 1rem;
3651
+ fill: none;
3652
+ stroke: currentColor;
3653
+ }
3654
+
2608
3655
  .hai-ai-doc-selection-toolbar {
2609
3656
  display: flex;
2610
3657
  align-items: center;
@@ -2973,6 +4020,20 @@ ${safeCode}
2973
4020
  border: 1px solid rgb(15 23 42 / 0.08);
2974
4021
  }
2975
4022
 
4023
+ .hai-ai-doc-color-swatch[data-empty='true'] span {
4024
+ position: relative;
4025
+ border-color: oklch(var(--bc) / 0.18);
4026
+ background:
4027
+ linear-gradient(
4028
+ 135deg,
4029
+ transparent calc(50% - 0.75px),
4030
+ oklch(var(--bc) / 0.34) calc(50% - 0.75px),
4031
+ oklch(var(--bc) / 0.34) calc(50% + 0.75px),
4032
+ transparent calc(50% + 0.75px)
4033
+ ),
4034
+ color-mix(in srgb, oklch(var(--b1)) 96%, white 4%);
4035
+ }
4036
+
2976
4037
  .hai-ai-doc-rewrite-menu-btn {
2977
4038
  justify-content: center;
2978
4039
  }
@@ -3082,6 +4143,10 @@ ${safeCode}
3082
4143
  font-weight: 500;
3083
4144
  }
3084
4145
 
4146
+ .hai-markdown-editable :global(a[href]) {
4147
+ cursor: pointer;
4148
+ }
4149
+
3085
4150
  .hai-markdown :global(a:hover) {
3086
4151
  text-decoration: underline;
3087
4152
  }