@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.
- package/dist/components/primitives/Switch.svelte +5 -0
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +1207 -144
- package/dist/components/scenes/ai/document-types.d.ts +26 -2
- package/dist/components/scenes/ai/editor-markdown-extensions.js +19 -4
- package/dist/messages/en-US.json +5 -0
- package/dist/messages/zh-CN.json +5 -0
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
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
|
-
) ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1128
|
-
if (
|
|
1915
|
+
const nextState = buildHoveredLinkState(hoveredLinkState.element)
|
|
1916
|
+
if (!nextState) {
|
|
1917
|
+
closeHoveredLink()
|
|
1129
1918
|
return
|
|
1130
1919
|
}
|
|
1131
1920
|
|
|
1132
|
-
|
|
1133
|
-
const nextRemeasured = selectionRefreshNeedsRemeasure
|
|
1134
|
-
selectionRefreshFrame = undefined
|
|
1135
|
-
selectionRefreshNeedsRemeasure = false
|
|
1136
|
-
handleSelectionChange(nextRemeasured)
|
|
1137
|
-
})
|
|
1921
|
+
hoveredLinkState = nextState
|
|
1138
1922
|
}
|
|
1139
1923
|
|
|
1140
|
-
function
|
|
1141
|
-
|
|
1142
|
-
|
|
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 (
|
|
1146
|
-
|
|
1934
|
+
if (!link) {
|
|
1935
|
+
scheduleHoveredLinkHide()
|
|
1936
|
+
return
|
|
1147
1937
|
}
|
|
1148
1938
|
|
|
1149
|
-
|
|
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
|
|
1153
|
-
if (
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1171
|
-
const maxCenter = Math.max(minCenter, toolbarWidth - 8 - estimatedHalfWidth)
|
|
1959
|
+
}
|
|
1172
1960
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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 (
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
return
|
|
1977
|
+
if (!firstTextNode || !lastTextNode) {
|
|
1978
|
+
range.selectNodeContents(link)
|
|
1979
|
+
return range
|
|
1183
1980
|
}
|
|
1184
1981
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1982
|
+
range.setStart(firstTextNode, 0)
|
|
1983
|
+
range.setEnd(lastTextNode, lastTextNode.textContent?.length ?? 0)
|
|
1984
|
+
return range
|
|
1187
1985
|
}
|
|
1188
1986
|
|
|
1189
|
-
function
|
|
1987
|
+
async function editHoveredLink(): Promise<void> {
|
|
1190
1988
|
if (
|
|
1191
1989
|
typeof window === 'undefined'
|
|
1192
|
-
||
|
|
1193
|
-
|| !
|
|
1990
|
+
|| !previewHost
|
|
1991
|
+
|| !onapplylink
|
|
1992
|
+
|| !hoveredLinkState
|
|
1194
1993
|
) {
|
|
1195
1994
|
return
|
|
1196
1995
|
}
|
|
1197
1996
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
2003
|
+
|
|
2004
|
+
const selection = window.getSelection()
|
|
2005
|
+
if (!selection) {
|
|
2006
|
+
return
|
|
1218
2007
|
}
|
|
1219
|
-
}
|
|
1220
2008
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
|
1355
|
-
?
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
|
1362
|
-
|
|
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
|
-
=
|
|
2183
|
+
= toolbarLeft <= minLeft + 0.5
|
|
1365
2184
|
? 'left'
|
|
1366
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
</>
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
|
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 ===
|
|
3072
|
+
selectionFormatState.backgroundColor === resolveColorOptionValue(colorOption)
|
|
2160
3073
|
? 'hai-ai-doc-color-swatch--active'
|
|
2161
3074
|
: '',
|
|
2162
3075
|
)}
|
|
2163
|
-
|
|
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:
|
|
3085
|
+
value: typeof colorOption === 'string'
|
|
3086
|
+
? colorOption
|
|
3087
|
+
: colorOption.value,
|
|
2168
3088
|
})}
|
|
2169
3089
|
>
|
|
2170
|
-
<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
|
}
|