@tnotesjs/ui 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +68 -0
  4. package/src/components/BilibiliVideo/BilibiliVideo.vue +55 -0
  5. package/src/components/Footprints/Footprints.vue +339 -0
  6. package/src/components/Footprints/parse.ts +122 -0
  7. package/src/components/Mermaid/Mermaid.vue +553 -0
  8. package/src/components/Mermaid/icons/icon__center_off.svg +1 -0
  9. package/src/components/Mermaid/icons/icon__center_on.svg +1 -0
  10. package/src/components/Mermaid/icons/icon__check.svg +3 -0
  11. package/src/components/Mermaid/icons/icon__clipboard.svg +8 -0
  12. package/src/components/Mermaid/icons/icon__fullscreen.svg +1 -0
  13. package/src/components/Mermaid/icons/icon__fullscreen_exit.svg +1 -0
  14. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +265 -0
  15. package/src/components/Mindmap/FocusBreadcrumbs.vue +436 -0
  16. package/src/components/Mindmap/InlineRuns.ts +25 -0
  17. package/src/components/Mindmap/Mindmap.vue +1210 -0
  18. package/src/components/Mindmap/MindmapOutlineNode.vue +62 -0
  19. package/src/components/Mindmap/MindmapViewIcon.vue +41 -0
  20. package/src/components/Mindmap/editor/AppIcon.vue +107 -0
  21. package/src/components/Mindmap/editor/CanvasContextMenu.vue +157 -0
  22. package/src/components/Mindmap/editor/LinkPopover.vue +83 -0
  23. package/src/components/Mindmap/editor/MarkdownView.vue +163 -0
  24. package/src/components/Mindmap/editor/MindmapView.vue +253 -0
  25. package/src/components/Mindmap/editor/OutlineView.vue +2494 -0
  26. package/src/components/Mindmap/editor/RichInlineEditor.vue +393 -0
  27. package/src/components/Mindmap/editor/SelectionToolbar.vue +191 -0
  28. package/src/components/Mindmap/editor/canvasClipboard.ts +6 -0
  29. package/src/components/Mindmap/editor/imagePaste.ts +32 -0
  30. package/src/components/Mindmap/editor/mindmapClipboard.ts +93 -0
  31. package/src/components/Mindmap/editor/outlineDrag.ts +29 -0
  32. package/src/components/Mindmap/editor/platform.ts +18 -0
  33. package/src/components/Mindmap/expandLevel.ts +28 -0
  34. package/src/components/Mindmap/icons/icon__fullscreen.svg +1 -0
  35. package/src/components/Mindmap/icons/icon__fullscreen_exit.svg +1 -0
  36. package/src/components/Mindmap/icons/icon__zoom_fit.svg +1 -0
  37. package/src/components/Mindmap/markdown.ts +83 -0
  38. package/src/components/Mindmap/wheelInteraction.ts +7 -0
  39. package/src/components/NotesTable/NotesTable.vue +119 -0
  40. package/src/components/NotesTable/types.ts +6 -0
  41. package/src/components/WordList/RightClickMenu.vue +106 -0
  42. package/src/components/WordList/WordList.vue +692 -0
  43. package/src/components/WordList/wordListFeatures.ts +38 -0
  44. package/src/index.ts +30 -0
  45. package/src/styles/tokens.css +35 -0
@@ -0,0 +1,2494 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import {
4
+ caretOffsetFromPoint,
5
+ cloneSubtree,
6
+ parseMarkdown,
7
+ richSelectionRect,
8
+ serializeSubtree,
9
+ wrapTextLines,
10
+ } from '@tnotesjs/mindmap-core'
11
+ import type { InlineFormat, InlineLink, MindmapNode, MindmapSession, RichInlineEditorElement } from '@tnotesjs/mindmap-core'
12
+ import LinkPopover from './LinkPopover.vue'
13
+ import RichInlineEditor from './RichInlineEditor.vue'
14
+ import SelectionToolbar from './SelectionToolbar.vue'
15
+ import { resolveAfterDropLevel } from './outlineDrag'
16
+
17
+ const props = defineProps<{
18
+ session: MindmapSession
19
+ /** 每次文档/选中/折叠/聚焦变化时 +1,驱动本组件重算 */
20
+ version: number
21
+ resolveImageSrc?: (src: string) => string
22
+ }>()
23
+
24
+ function resolvedImageSrc(src: string): string {
25
+ return props.resolveImageSrc?.(src) ?? src
26
+ }
27
+
28
+ const emit = defineEmits<{
29
+ imagePreview: [src: string]
30
+ requestSearch: []
31
+ pasteImage: [anchorId: string, blob: Blob]
32
+ }>()
33
+
34
+ const ROW_HEIGHT = 32
35
+ const TITLE_LINE = 36
36
+ /** 与幕布一致:每层水平步进 */
37
+ const INDENT = 28
38
+ /** 与 .outline-view padding-top / .outline-title margin-bottom 保持一致 */
39
+ const VIEW_PADDING_TOP = 12
40
+ const TITLE_MARGIN_BOTTOM = 8
41
+ const TEXT_LINE = 22
42
+ /** 折叠按钮占位(叠在父级圆点列,不额外把圆点挤开) */
43
+ const COLLAPSE_LEAD = 18
44
+ const BULLET_SIZE = 22
45
+ /** 深度 0 圆点中心 X;装饰线与祖先圆点对齐 */
46
+ const BULLET_CENTER = COLLAPSE_LEAD + BULLET_SIZE / 2
47
+ /** 大纲行左侧控件占位(折叠列 + 圆点 + 间隙) */
48
+ const ROW_CHROME = COLLAPSE_LEAD + BULLET_SIZE + 8
49
+ /** 大纲图片默认/边界宽度(与 md `|宽度` 一致) */
50
+ const DEFAULT_OUTLINE_IMG_W = 240
51
+ const MIN_IMG_W = 80
52
+ const MAX_IMG_W = 720
53
+ const IMG_BLOCK_PAD = 10
54
+ const IMG_MAX_H = 480
55
+
56
+ function gutterWidth(depth: number): number {
57
+ return COLLAPSE_LEAD + depth * INDENT + BULLET_SIZE
58
+ }
59
+
60
+ interface Row {
61
+ node: MindmapNode
62
+ depth: number
63
+ index: number
64
+ top: number
65
+ height: number
66
+ textHeight: number
67
+ }
68
+
69
+ /** src → 宽/高比;加载后更新以正确计算行高 */
70
+ const imageAspects = ref(new Map<string, number>())
71
+ /** 拖拽调宽时的即时预览宽度 */
72
+ const liveImageWidth = ref<Map<string, number>>(new Map())
73
+ /** 编辑中未提交文案(数据源仍为一行;用于避免 :value 重渲染冲掉输入) */
74
+ const draftTexts = ref(new Map<string, string>())
75
+ /** 编辑中未提交文案的折行高度 */
76
+ const draftTextHeights = ref(new Map<string, number>())
77
+ /** 外层内容区宽度,驱动折行 */
78
+ const containerWidth = ref(800)
79
+
80
+ let measureCtx: CanvasRenderingContext2D | null = null
81
+ function measureOutlineText(text: string, fontSize: number): number {
82
+ if (typeof document === 'undefined') return text.length * fontSize * 0.6
83
+ if (!measureCtx) {
84
+ const c = document.createElement('canvas')
85
+ measureCtx = c.getContext('2d')
86
+ }
87
+ if (!measureCtx) return text.length * fontSize * 0.6
88
+ measureCtx.font = `${fontSize}px system-ui, -apple-system, "Segoe UI", sans-serif`
89
+ return measureCtx.measureText(text).width
90
+ }
91
+
92
+ function contentAreaWidth(): number {
93
+ // .outline-view padding: 12px 24px
94
+ return Math.max(80, containerWidth.value - 48)
95
+ }
96
+
97
+ function outlineTextMaxWidth(depth: number, hasCheckbox: boolean, hasLink: boolean): number {
98
+ const chrome = depth * INDENT + ROW_CHROME + (hasCheckbox ? 20 : 0) + (hasLink ? 24 : 0) + 12
99
+ return Math.max(60, contentAreaWidth() - chrome)
100
+ }
101
+
102
+ function measureTextBlockHeight(
103
+ text: string,
104
+ maxWidth: number,
105
+ fontSize: number,
106
+ lineHeight: number,
107
+ minHeight: number,
108
+ ): number {
109
+ const lines = wrapTextLines(text || ' ', maxWidth, (s) => measureOutlineText(s, fontSize))
110
+ return Math.max(minHeight, lines.length * lineHeight + 8)
111
+ }
112
+
113
+ function displayImageWidth(node: MindmapNode): number {
114
+ const live = liveImageWidth.value.get(node.id)
115
+ if (live != null) return live
116
+ return node.content.image?.width ?? DEFAULT_OUTLINE_IMG_W
117
+ }
118
+
119
+ function imageBlockHeight(node: MindmapNode): number {
120
+ if (!node.content.image) return 0
121
+ const w = displayImageWidth(node)
122
+ const aspect = imageAspects.value.get(node.content.image.src) ?? 1
123
+ const h = Math.round(w / Math.max(aspect, 0.15))
124
+ return Math.min(h, IMG_MAX_H) + IMG_BLOCK_PAD
125
+ }
126
+
127
+ function layoutDisplayText(node: MindmapNode): string {
128
+ // 大纲始终按用户看到的文案排版,Markdown 标记只存在于源码层。
129
+ return node.content.text
130
+ }
131
+
132
+ function measureRowTextHeight(node: MindmapNode, depth: number): number {
133
+ const draft = draftTextHeights.value.get(node.id)
134
+ if (draft != null) return draft
135
+ const maxW = outlineTextMaxWidth(depth, node.content.checked !== null, !!node.content.link)
136
+ return measureTextBlockHeight(layoutDisplayText(node), maxW, 15, TEXT_LINE, ROW_HEIGHT)
137
+ }
138
+
139
+ /** 列表行 = 聚焦根的后代(不含根本身;根作为上方标题渲染,对齐幕布) */
140
+ const rows = computed<Row[]>(() => {
141
+ if (props.version < 0) return []
142
+ const root = props.session.focusRootNode
143
+ const out: Row[] = []
144
+ let top = 0
145
+ const walk = (n: MindmapNode, depth: number) => {
146
+ if (n.collapsed) return
147
+ for (const c of n.children) {
148
+ const textHeight = measureRowTextHeight(c, depth)
149
+ const height = textHeight + imageBlockHeight(c)
150
+ out.push({ node: c, depth, index: out.length, top, height, textHeight })
151
+ top += height
152
+ walk(c, depth + 1)
153
+ }
154
+ }
155
+ walk(root, 0)
156
+ return out
157
+ })
158
+
159
+ const titleHeight = computed(() => {
160
+ if (props.version < 0) return 56
161
+ const root = props.session.focusRootNode
162
+ const draft = draftTextHeights.value.get(root.id)
163
+ const textH =
164
+ draft ??
165
+ measureTextBlockHeight(layoutDisplayText(root), contentAreaWidth(), 28, TITLE_LINE, 56)
166
+ return textH + imageBlockHeight(root)
167
+ })
168
+
169
+ const totalListHeight = computed(() => {
170
+ const list = rows.value
171
+ if (list.length === 0) return 0
172
+ const last = list[list.length - 1]
173
+ return last.top + last.height
174
+ })
175
+
176
+ const focusRoot = computed(() => {
177
+ if (props.version < 0) return null
178
+ return props.session.focusRootNode
179
+ })
180
+
181
+ const selectedId = computed(() => {
182
+ if (props.version < 0) return null
183
+ return props.session.selectedNode?.id ?? null
184
+ })
185
+
186
+ const selectedIds = computed(() => {
187
+ if (props.version < 0) return new Set<string>()
188
+ return props.session.selectionIds
189
+ })
190
+
191
+ const matches = computed(() => {
192
+ if (props.version < 0) return new Set<string>()
193
+ return props.session.matches
194
+ })
195
+
196
+ // ---------- 虚拟滚动 ----------
197
+
198
+ const containerRef = ref<HTMLElement>()
199
+ const scrollTop = ref(0)
200
+ const viewportH = ref(600)
201
+
202
+ /** 列表(spacer)相对滚动内容顶部的偏移:padding + 标题 + 标题下边距 */
203
+ function listChromeHeight(): number {
204
+ return VIEW_PADDING_TOP + titleHeight.value + TITLE_MARGIN_BOTTOM
205
+ }
206
+
207
+ const listScrollTop = computed(() => Math.max(0, scrollTop.value - listChromeHeight()))
208
+
209
+ const startIndex = computed(() => {
210
+ const list = rows.value
211
+ const y = listScrollTop.value
212
+ let lo = 0
213
+ let hi = list.length
214
+ while (lo < hi) {
215
+ const mid = (lo + hi) >> 1
216
+ if (list[mid].top + list[mid].height < y) lo = mid + 1
217
+ else hi = mid
218
+ }
219
+ return Math.max(0, lo - 2)
220
+ })
221
+
222
+ const endIndex = computed(() => {
223
+ const list = rows.value
224
+ const y = listScrollTop.value + viewportH.value
225
+ let i = startIndex.value
226
+ while (i < list.length && list[i].top < y) i++
227
+ return Math.min(list.length, i + 2)
228
+ })
229
+
230
+ const visibleRows = computed(() => rows.value.slice(startIndex.value, endIndex.value))
231
+
232
+ function onScroll() {
233
+ scrollTop.value = containerRef.value?.scrollTop ?? 0
234
+ textSelection.value = null
235
+ if (linkEditor.value?.mode === 'existing') linkEditor.value = null
236
+ }
237
+
238
+ let resizeObserver: ResizeObserver | null = null
239
+ onMounted(() => {
240
+ if (containerRef.value) {
241
+ viewportH.value = containerRef.value.clientHeight || 600
242
+ containerWidth.value = containerRef.value.clientWidth || 800
243
+ resizeObserver = new ResizeObserver(() => {
244
+ const el = containerRef.value
245
+ if (!el) return
246
+ viewportH.value = el.clientHeight || 600
247
+ containerWidth.value = el.clientWidth || 800
248
+ })
249
+ resizeObserver.observe(containerRef.value)
250
+ }
251
+ })
252
+ onBeforeUnmount(() => {
253
+ resizeObserver?.disconnect()
254
+ stopRangeSelect?.()
255
+ if (linkLeaveTimer) clearTimeout(linkLeaveTimer)
256
+ })
257
+
258
+ const focusPathKey = computed(() => {
259
+ if (props.version < 0) return ''
260
+ return props.session.focusPath.map((node) => node.id).join('/')
261
+ })
262
+
263
+ // ---------- 行焦点(焦点 = 选中 = 编辑态,幕布式) ----------
264
+
265
+ const focusedId = ref<string | null>(null)
266
+ const hoveredId = ref<string | null>(null)
267
+
268
+ interface TextSelectionState {
269
+ nodeId: string
270
+ start: number
271
+ end: number
272
+ position: { left: number; top: number }
273
+ }
274
+
275
+ interface LinkEditorState {
276
+ mode: 'selection' | 'existing'
277
+ nodeId: string
278
+ url: string
279
+ position: { left: number; top: number }
280
+ start?: number
281
+ end?: number
282
+ rawStart?: number
283
+ rawEnd?: number
284
+ }
285
+
286
+ const textSelection = ref<TextSelectionState | null>(null)
287
+ const linkEditor = ref<LinkEditorState | null>(null)
288
+ const linkPopoverRef = ref<InstanceType<typeof LinkPopover> | null>(null)
289
+ const imagePickerRef = ref<HTMLInputElement>()
290
+ const pendingImageAnchorId = ref<string | null>(null)
291
+ let linkLeaveTimer: ReturnType<typeof setTimeout> | null = null
292
+
293
+ watch(focusPathKey, () => {
294
+ // 进入/退出子树聚焦时,聚焦根应先以富文本标题展示,而不是继续停留在
295
+ // 编辑态;先提交当前受控草稿,再清理局部选区与浮层。
296
+ const active = document.activeElement
297
+ if (isInlineEditorElement(active)) {
298
+ const activeNode = props.session.document.find(active.dataset.id ?? '')
299
+ if (activeNode) commitRow(activeNode, active)
300
+ }
301
+ focusedId.value = null
302
+ textSelection.value = null
303
+ linkEditor.value = null
304
+ draftTexts.value = new Map()
305
+ draftTextHeights.value = new Map()
306
+ scrollTop.value = 0
307
+ if (containerRef.value) containerRef.value.scrollTop = 0
308
+ nextTick(() => containerRef.value?.focus())
309
+ })
310
+
311
+ function inputOf(id: string): RichInlineEditorElement | null {
312
+ return containerRef.value?.querySelector(`.rich-inline-editor[data-id="${id}"]`) ?? null
313
+ }
314
+
315
+ function isInlineEditorElement(value: unknown): value is RichInlineEditorElement {
316
+ return value instanceof HTMLDivElement && value.classList.contains('rich-inline-editor')
317
+ }
318
+
319
+ /** 聚焦某行并放置光标(col 省略时到末尾);DOM 更新后可能需多拍再试 */
320
+ function focusRow(id: string, col?: number) {
321
+ focusedId.value = id
322
+ const attempt = (left: number) => {
323
+ nextTick(() => {
324
+ const input = inputOf(id)
325
+ if (!input) {
326
+ if (left > 0) attempt(left - 1)
327
+ return
328
+ }
329
+ input.focus()
330
+ const pos = col === undefined ? input.value.length : Math.min(col, input.value.length)
331
+ input.setSelectionRange(pos, pos)
332
+ })
333
+ }
334
+ attempt(3)
335
+ }
336
+
337
+ function focusRowRange(id: string, start: number, end: number) {
338
+ focusedId.value = id
339
+ nextTick(() => {
340
+ const input = inputOf(id)
341
+ if (!input) return
342
+ input.focus()
343
+ input.setSelectionRange(Math.min(start, input.value.length), Math.min(end, input.value.length))
344
+ syncTextSelection(id, input)
345
+ })
346
+ }
347
+
348
+ function selectionPosition(input: RichInlineEditorElement): { left: number; top: number } {
349
+ const rect = richSelectionRect(input)
350
+ return {
351
+ left: Math.max(190, Math.min(window.innerWidth - 190, rect.left + rect.width / 2)),
352
+ top: Math.max(58, rect.top - 8),
353
+ }
354
+ }
355
+
356
+ function syncTextSelection(nodeId: string, input: RichInlineEditorElement) {
357
+ const start = input.selectionStart ?? 0
358
+ const end = input.selectionEnd ?? 0
359
+ if (start === end) {
360
+ if (textSelection.value?.nodeId === nodeId) textSelection.value = null
361
+ return
362
+ }
363
+ textSelection.value = { nodeId, start, end, position: selectionPosition(input) }
364
+ }
365
+
366
+ function onTextSelection(node: MindmapNode, event: Event) {
367
+ syncTextSelection(node.id, event.currentTarget as RichInlineEditorElement)
368
+ }
369
+
370
+ const selectedTextFormats = computed<Partial<Record<InlineFormat, boolean>>>(() => {
371
+ if (props.version < 0) return {}
372
+ const selection = textSelection.value
373
+ if (!selection) return {}
374
+ const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'highlight', 'code']
375
+ return Object.fromEntries(formats.map((format) => [
376
+ format,
377
+ props.session.inlineFormatActive(selection.nodeId, selection.start, selection.end, format),
378
+ ]))
379
+ })
380
+
381
+ const multiSelectionPosition = computed(() => {
382
+ if (props.version < 0 || props.session.selectionIds.size <= 1 || textSelection.value) return null
383
+ const id = props.session.selectedNode?.id
384
+ if (!id) return null
385
+ const row = containerRef.value?.querySelector(`.outline-row[data-node-id="${id}"]`) as HTMLElement | null
386
+ if (!row) return null
387
+ const rect = row.getBoundingClientRect()
388
+ if (rect.width === 0 && rect.height === 0) {
389
+ return { left: Math.max(180, window.innerWidth / 2), top: 60 }
390
+ }
391
+ if (rect.bottom < 48 || rect.top > window.innerHeight) return null
392
+ return {
393
+ left: Math.max(180, Math.min(window.innerWidth - 180, rect.left + rect.width / 2)),
394
+ top: Math.max(58, rect.top - 8),
395
+ }
396
+ })
397
+
398
+ function restoreTextSelection(selection: TextSelectionState) {
399
+ nextTick(() => focusRowRange(selection.nodeId, selection.start, selection.end))
400
+ }
401
+
402
+ function applyTextFormat(format: InlineFormat) {
403
+ const selection = textSelection.value
404
+ if (!selection) return
405
+ const node = props.session.document.find(selection.nodeId)
406
+ const input = inputOf(selection.nodeId)
407
+ if (!node || !input) return
408
+ commitRow(node, input)
409
+ props.session.toggleNodeInlineFormat(selection.nodeId, selection.start, selection.end, format)
410
+ restoreTextSelection(selection)
411
+ }
412
+
413
+ function applyNodeFormat(format: InlineFormat) {
414
+ props.session.formatSelectedNodes(format)
415
+ }
416
+
417
+ function clearTextFormat() {
418
+ const selection = textSelection.value
419
+ if (!selection) return
420
+ const node = props.session.document.find(selection.nodeId)
421
+ const input = inputOf(selection.nodeId)
422
+ if (!node || !input) return
423
+ commitRow(node, input)
424
+ props.session.clearNodeInlineFormats(selection.nodeId, selection.start, selection.end)
425
+ restoreTextSelection(selection)
426
+ }
427
+
428
+ function clearNodeFormats() {
429
+ props.session.clearSelectedNodeFormats()
430
+ }
431
+
432
+ function toggleSelectionTask() {
433
+ const selection = textSelection.value
434
+ if (selection) props.session.toggleTask(selection.nodeId)
435
+ else props.session.toggleTaskSelectedNodes()
436
+ }
437
+
438
+ function removeToolbarSelection() {
439
+ textSelection.value = null
440
+ linkEditor.value = null
441
+ props.session.removeSelectedNodes()
442
+ containerRef.value?.focus()
443
+ }
444
+
445
+ async function copySelectedFromToolbar() {
446
+ const text = serializeSelection()
447
+ if (!text) return
448
+ try {
449
+ await navigator.clipboard.writeText(text)
450
+ } catch {
451
+ // 浏览器拒绝异步剪贴板时,仍可使用系统 Cmd/Ctrl+C 的既有路径。
452
+ }
453
+ }
454
+
455
+ async function cutSelectedToClipboard() {
456
+ const text = serializeSelection()
457
+ if (!text) return
458
+ const ids = [...props.session.selectionIds]
459
+ try {
460
+ await navigator.clipboard.writeText(text)
461
+ props.session.removeNodesByIds(ids)
462
+ containerRef.value?.focus()
463
+ } catch {
464
+ // 剪贴板写入失败时不能删除节点,避免剪切造成数据丢失。
465
+ }
466
+ }
467
+
468
+ function openSelectionLinkEditor() {
469
+ const selection = textSelection.value
470
+ if (!selection) return
471
+ linkEditor.value = {
472
+ mode: 'selection',
473
+ nodeId: selection.nodeId,
474
+ start: selection.start,
475
+ end: selection.end,
476
+ url: 'https://',
477
+ position: { left: selection.position.left, top: selection.position.top + 12 },
478
+ }
479
+ nextTick(() => linkPopoverRef.value?.focusInput())
480
+ }
481
+
482
+ function requestSelectionImage() {
483
+ pendingImageAnchorId.value = textSelection.value?.nodeId ?? props.session.selectedNode?.id ?? null
484
+ imagePickerRef.value?.click()
485
+ }
486
+
487
+ function onSelectionImagePicked(event: Event) {
488
+ const input = event.target as HTMLInputElement
489
+ const file = input.files?.[0]
490
+ input.value = ''
491
+ const anchorId = pendingImageAnchorId.value ?? textSelection.value?.nodeId
492
+ pendingImageAnchorId.value = null
493
+ if (file && anchorId) emit('pasteImage', anchorId, file)
494
+ }
495
+
496
+ function focusTitle(col?: number) {
497
+ const root = focusRoot.value
498
+ if (!root) return
499
+ focusRow(root.id, col)
500
+ }
501
+
502
+ function scrollRowIntoView(index: number) {
503
+ const el = containerRef.value
504
+ const row = rows.value[index]
505
+ if (!el || !row) return
506
+ const target = listChromeHeight() + row.top
507
+ const bottom = target + row.height
508
+ if (target < el.scrollTop) el.scrollTop = Math.max(0, target - 8)
509
+ else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight + 8
510
+ }
511
+
512
+ function locateNode(id: string) {
513
+ const session = props.session
514
+ session.expandAncestors(id)
515
+ session.select(id)
516
+ nextTick(() => {
517
+ if (id === session.focusRootNode.id) {
518
+ focusTitle()
519
+ return
520
+ }
521
+ const idx = rows.value.findIndex((r) => r.node.id === id)
522
+ if (idx >= 0) scrollRowIntoView(idx)
523
+ })
524
+ }
525
+
526
+ defineExpose({ locateNode, selectAllFromHost, undoFromHost, redoFromHost })
527
+
528
+ // ---------- 行内编辑行为(光标感知,对齐幕布) ----------
529
+
530
+ function imageMarkdown(alt: string, src: string, width: number | null): string {
531
+ const w = width != null && width > 0 ? `|${Math.round(width)}` : ''
532
+ return `![${alt || '图片'}${w}](${src})`
533
+ }
534
+
535
+ function commitRow(node: MindmapNode, input: RichInlineEditorElement): boolean {
536
+ const session = props.session
537
+ const typed = input.value.trim().replace(/\n/g, '')
538
+ clearDraft(node.id)
539
+ // 图片节点:行内文案 = 描述(alt),保留 src / 宽度
540
+ if (node.content.image) {
541
+ const img = node.content.image
542
+ if (typed !== img.alt) {
543
+ session.updateNodeRaw(node.id, imageMarkdown(typed, img.src, img.width))
544
+ input.markCommitted()
545
+ return true
546
+ }
547
+ input.markCommitted()
548
+ return false
549
+ }
550
+ // contenteditable 草稿已经是合法 Markdown;直接提交 raw,保留全部行内 marks / href。
551
+ const raw = typed === '' ? '' : input.rawValue.trim()
552
+ if (raw !== node.content.raw) {
553
+ session.updateNodeRaw(node.id, raw)
554
+ input.markCommitted()
555
+ return true
556
+ }
557
+ input.markCommitted()
558
+ return false
559
+ }
560
+
561
+ function commitEditorPayload(node: MindmapNode, payload: { raw: string; text: string }) {
562
+ clearDraft(node.id)
563
+ if (node.content.image) {
564
+ const image = node.content.image
565
+ if (payload.text !== image.alt) {
566
+ props.session.updateNodeRaw(node.id, imageMarkdown(payload.text, image.src, image.width))
567
+ }
568
+ } else if (payload.raw !== node.content.raw) {
569
+ props.session.updateNodeRaw(node.id, payload.raw)
570
+ }
571
+ inputOf(node.id)?.markCommitted()
572
+ }
573
+
574
+ function clearDraft(id: string) {
575
+ let changed = false
576
+ if (draftTextHeights.value.has(id)) {
577
+ const next = new Map(draftTextHeights.value)
578
+ next.delete(id)
579
+ draftTextHeights.value = next
580
+ changed = true
581
+ }
582
+ if (draftTexts.value.has(id)) {
583
+ const next = new Map(draftTexts.value)
584
+ next.delete(id)
585
+ draftTexts.value = next
586
+ changed = true
587
+ }
588
+ return changed
589
+ }
590
+
591
+ function syncDraftText(node: MindmapNode, depth: number | null, text: string) {
592
+ if (draftTexts.value.get(node.id) !== text) {
593
+ const next = new Map(draftTexts.value)
594
+ next.set(node.id, text)
595
+ draftTexts.value = next
596
+ }
597
+ const isTitle = depth == null
598
+ const maxW = isTitle
599
+ ? contentAreaWidth()
600
+ : outlineTextMaxWidth(depth, node.content.checked !== null, !!node.content.link)
601
+ const h = measureTextBlockHeight(
602
+ text,
603
+ maxW,
604
+ isTitle ? 28 : 15,
605
+ isTitle ? TITLE_LINE : TEXT_LINE,
606
+ isTitle ? 56 : ROW_HEIGHT,
607
+ )
608
+ if (draftTextHeights.value.get(node.id) !== h) {
609
+ const next = new Map(draftTextHeights.value)
610
+ next.set(node.id, h)
611
+ draftTextHeights.value = next
612
+ }
613
+ }
614
+
615
+ function onRowDraftChange(node: MindmapNode, depth: number | null, payload: { text: string }) {
616
+ syncDraftText(node, depth, payload.text)
617
+ }
618
+
619
+ /**
620
+ * textarea 没有原生 caretClientRect;用同样排版样式的隐藏镜像判断光标是否位于首/末视觉行。
621
+ * 仅在跨节点导航前调用,行内上下移动仍交给浏览器原生处理。
622
+ */
623
+ function caretVisualBoundary(input: RichInlineEditorElement): { first: boolean; last: boolean } {
624
+ const value = input.value
625
+ const position = input.selectionStart ?? 0
626
+ if (value.length === 0 || input.clientWidth === 0) return { first: true, last: true }
627
+
628
+ const style = getComputedStyle(input)
629
+ const mirror = document.createElement('div')
630
+ const copied = [
631
+ 'boxSizing',
632
+ 'width',
633
+ 'borderTopWidth',
634
+ 'borderRightWidth',
635
+ 'borderBottomWidth',
636
+ 'borderLeftWidth',
637
+ 'paddingTop',
638
+ 'paddingRight',
639
+ 'paddingBottom',
640
+ 'paddingLeft',
641
+ 'fontFamily',
642
+ 'fontSize',
643
+ 'fontStyle',
644
+ 'fontWeight',
645
+ 'fontVariant',
646
+ 'lineHeight',
647
+ 'letterSpacing',
648
+ 'textTransform',
649
+ 'textIndent',
650
+ 'textAlign',
651
+ 'tabSize',
652
+ 'whiteSpace',
653
+ 'wordBreak',
654
+ 'overflowWrap',
655
+ ] as const
656
+ for (const property of copied) {
657
+ mirror.style.setProperty(property.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`), style[property] as string)
658
+ }
659
+ mirror.style.position = 'fixed'
660
+ mirror.style.left = '-10000px'
661
+ mirror.style.top = '0'
662
+ mirror.style.height = 'auto'
663
+ mirror.style.minHeight = '0'
664
+ mirror.style.maxHeight = 'none'
665
+ mirror.style.overflow = 'hidden'
666
+ mirror.style.visibility = 'hidden'
667
+ mirror.style.pointerEvents = 'none'
668
+ document.body.append(mirror)
669
+
670
+ const topAt = (offset: number) => {
671
+ mirror.textContent = value.slice(0, offset)
672
+ const marker = document.createElement('span')
673
+ marker.textContent = value.slice(offset, offset + 1) || '\u200b'
674
+ mirror.append(marker)
675
+ const top = marker.offsetTop
676
+ marker.remove()
677
+ return top
678
+ }
679
+ const firstTop = topAt(0)
680
+ const currentTop = topAt(position)
681
+ const lastTop = topAt(value.length)
682
+ mirror.remove()
683
+ return { first: currentTop <= firstTop + 1, last: currentTop >= lastTop - 1 }
684
+ }
685
+
686
+ function inlineFormatShortcut(event: KeyboardEvent, hasSelection = true): InlineFormat | null {
687
+ const key = event.key.toLowerCase()
688
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return null
689
+ if (key === 'b' && !event.shiftKey) return 'bold'
690
+ if (key === 'i' && !event.shiftKey) return 'italic'
691
+ if (key === 'u' && !event.shiftKey) return 'underline'
692
+ if (key === 'enter' && !event.shiftKey && hasSelection) return 'strike'
693
+ if ((key === 's' || key === 'x') && event.shiftKey) return 'strike'
694
+ if (key === 'h' && event.shiftKey) return 'highlight'
695
+ if (key === 'e' && !event.shiftKey) return 'code'
696
+ return null
697
+ }
698
+
699
+ function applyInputFormatShortcut(node: MindmapNode, input: RichInlineEditorElement, format: InlineFormat) {
700
+ const caretStart = input.selectionStart ?? 0
701
+ const caretEnd = input.selectionEnd ?? 0
702
+ if (input.value.length === 0) return
703
+ const collapsed = caretStart === caretEnd
704
+ const start = collapsed ? 0 : caretStart
705
+ const end = collapsed ? input.value.length : caretEnd
706
+ commitRow(node, input)
707
+ props.session.toggleNodeInlineFormat(node.id, start, end, format)
708
+ if (collapsed) {
709
+ textSelection.value = null
710
+ nextTick(() => {
711
+ const editor = inputOf(node.id)
712
+ editor?.focus()
713
+ editor?.setSelectionRange(caretStart, caretStart)
714
+ })
715
+ } else {
716
+ const selection: TextSelectionState = { nodeId: node.id, start, end, position: selectionPosition(input) }
717
+ textSelection.value = selection
718
+ restoreTextSelection(selection)
719
+ }
720
+ }
721
+
722
+ function applyInputClearFormats(node: MindmapNode, input: RichInlineEditorElement) {
723
+ const caretStart = input.selectionStart ?? 0
724
+ const caretEnd = input.selectionEnd ?? 0
725
+ if (input.value.length === 0) return
726
+ const collapsed = caretStart === caretEnd
727
+ const start = collapsed ? 0 : caretStart
728
+ const end = collapsed ? input.value.length : caretEnd
729
+ commitRow(node, input)
730
+ props.session.clearNodeInlineFormats(node.id, start, end)
731
+ if (collapsed) {
732
+ textSelection.value = null
733
+ nextTick(() => {
734
+ const editor = inputOf(node.id)
735
+ editor?.focus()
736
+ editor?.setSelectionRange(caretStart, caretStart)
737
+ })
738
+ } else {
739
+ const selection: TextSelectionState = { nodeId: node.id, start, end, position: selectionPosition(input) }
740
+ textSelection.value = selection
741
+ restoreTextSelection(selection)
742
+ }
743
+ }
744
+
745
+ function onTitleKeydown(e: KeyboardEvent) {
746
+ const root = focusRoot.value
747
+ if (!root) return
748
+ if (focusedId.value !== root.id) return
749
+ const session = props.session
750
+ const input = e.currentTarget as RichInlineEditorElement
751
+ if (e.isComposing || input.isComposing) return
752
+ e.stopPropagation()
753
+
754
+ const mod = e.metaKey || e.ctrlKey
755
+ if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
756
+ e.preventDefault()
757
+ commitRow(root, input)
758
+ if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
759
+ else session.toggleCollapse(root.id)
760
+ focusTitle(input.selectionStart ?? 0)
761
+ return
762
+ }
763
+ if (mod) {
764
+ const key = e.key.toLowerCase()
765
+ if (key === '\\') {
766
+ e.preventDefault()
767
+ applyInputClearFormats(root, input)
768
+ return
769
+ }
770
+ const format = inlineFormatShortcut(e, input.selectionStart !== input.selectionEnd)
771
+ if (format) {
772
+ e.preventDefault()
773
+ applyInputFormatShortcut(root, input, format)
774
+ return
775
+ }
776
+ if (key === 'z' || key === 'y') {
777
+ e.preventDefault()
778
+ commitRow(root, input)
779
+ if (key === 'z' && !e.shiftKey) session.undo()
780
+ else session.redo()
781
+ } else if (key === 'f') {
782
+ e.preventDefault()
783
+ emit('requestSearch')
784
+ } else if (key === '[') {
785
+ e.preventDefault()
786
+ commitRow(root, input)
787
+ if (session.focusPath.length > 0) {
788
+ session.exitFocusTo(session.focusPath.length - 1)
789
+ focusRow(root.id)
790
+ }
791
+ } else if (key === 'a') {
792
+ // Nested under ProseMirror: native Cmd+A selects the whole note. Select
793
+ // within this field first; escalate to all outline rows when already full.
794
+ e.preventDefault()
795
+ const fullySelected = input.selectionStart === 0 && input.selectionEnd === input.value.length
796
+ if (fullySelected) {
797
+ commitRow(root, input)
798
+ selectAllRows()
799
+ } else {
800
+ input.setSelectionRange?.(0, input.value.length)
801
+ }
802
+ }
803
+ return
804
+ }
805
+
806
+ switch (e.key) {
807
+ case 'Enter': {
808
+ e.preventDefault()
809
+ commitRow(root, input)
810
+ // 幕布:标题行尾 Enter → 聚焦首个子节点;无子节点则新建
811
+ if (root.children.length > 0) {
812
+ focusRow(root.children[0].id, 0)
813
+ } else {
814
+ const created = session.insertChildOf(root.id, 0)
815
+ if (created) focusRow(created.id, 0)
816
+ }
817
+ break
818
+ }
819
+ case 'ArrowDown': {
820
+ if (input.selectionStart !== input.selectionEnd || !caretVisualBoundary(input).last) break
821
+ e.preventDefault()
822
+ commitRow(root, input)
823
+ if (rows.value.length > 0) focusRow(rows.value[0].node.id, input.selectionStart ?? 0)
824
+ break
825
+ }
826
+ case 'Escape': {
827
+ e.preventDefault()
828
+ commitRow(root, input)
829
+ containerRef.value?.focus()
830
+ break
831
+ }
832
+ }
833
+ }
834
+
835
+ function onEditKeydown(node: MindmapNode, e: KeyboardEvent) {
836
+ if (focusedId.value !== node.id) return
837
+ const session = props.session
838
+ const input = e.currentTarget as RichInlineEditorElement
839
+ if (e.isComposing || input.isComposing) return
840
+ e.stopPropagation()
841
+
842
+ const mod = e.metaKey || e.ctrlKey
843
+ if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
844
+ e.preventDefault()
845
+ commitRow(node, input)
846
+ if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
847
+ else if (mod && e.shiftKey) session.toggleCollapseSiblings(node.id)
848
+ else session.toggleCollapse(node.id)
849
+ focusRow(node.id, input.selectionStart ?? 0)
850
+ return
851
+ }
852
+ if (e.altKey && !mod && e.key === 'Enter') {
853
+ e.preventDefault()
854
+ pendingImageAnchorId.value = node.id
855
+ imagePickerRef.value?.click()
856
+ return
857
+ }
858
+ if (mod) {
859
+ const key = e.key.toLowerCase()
860
+ const col = input.selectionStart ?? 0
861
+ if (key === '\\') {
862
+ e.preventDefault()
863
+ applyInputClearFormats(node, input)
864
+ return
865
+ }
866
+ const format = inlineFormatShortcut(e, input.selectionStart !== input.selectionEnd)
867
+ if (format) {
868
+ e.preventDefault()
869
+ applyInputFormatShortcut(node, input, format)
870
+ } else if (key === 'k' && !e.shiftKey && input.selectionStart !== input.selectionEnd) {
871
+ e.preventDefault()
872
+ syncTextSelection(node.id, input)
873
+ openSelectionLinkEditor()
874
+ } else if (key === 'z' || key === 'y') {
875
+ e.preventDefault()
876
+ commitRow(node, input)
877
+ if (key === 'z' && !e.shiftKey) session.undo()
878
+ else session.redo()
879
+ } else if (key === 'f') {
880
+ e.preventDefault()
881
+ emit('requestSearch')
882
+ } else if (key === ']') {
883
+ e.preventDefault()
884
+ commitRow(node, input)
885
+ session.focusNode(node.id)
886
+ } else if (key === '[') {
887
+ e.preventDefault()
888
+ commitRow(node, input)
889
+ if (session.focusPath.length > 0) {
890
+ session.exitFocusTo(session.focusPath.length - 1)
891
+ focusRow(node.id, col)
892
+ }
893
+ } else if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
894
+ e.preventDefault()
895
+ commitRow(node, input)
896
+ session.moveSelectedNodes(e.key === 'ArrowUp' ? -1 : 1)
897
+ focusRow(node.id, col)
898
+ } else if (e.shiftKey && (key === 'backspace' || key === 'd')) {
899
+ e.preventDefault()
900
+ commitRow(node, input)
901
+ session.removeSelectedNodes()
902
+ containerRef.value?.focus()
903
+ } else if (e.shiftKey && key === 'l') {
904
+ e.preventDefault()
905
+ commitRow(node, input)
906
+ session.toggleTaskSelectedNodes()
907
+ focusRow(node.id, col)
908
+ } else if (e.shiftKey && key === 'k') {
909
+ e.preventDefault()
910
+ commitRow(node, input)
911
+ session.toggleCheckedSelectedNodes()
912
+ focusRow(node.id, col)
913
+ } else if (key === 'd') {
914
+ e.preventDefault()
915
+ commitRow(node, input)
916
+ session.duplicateSelectedNodes()
917
+ containerRef.value?.focus()
918
+ } else if (key === 'enter') {
919
+ // Cmd/Ctrl+Enter:新建子节点(脑图视图同款)
920
+ e.preventDefault()
921
+ commitRow(node, input)
922
+ const created = session.insertChildOf(node.id)
923
+ if (created) focusRow(created.id, 0)
924
+ } else if (key === 'a') {
925
+ // Nested under ProseMirror: native Cmd+A selects the whole note. Select
926
+ // within this field first; escalate to all outline rows when already full.
927
+ e.preventDefault()
928
+ const fullySelected = input.selectionStart === 0 && input.selectionEnd === input.value.length
929
+ if (fullySelected) {
930
+ commitRow(node, input)
931
+ selectAllRows()
932
+ } else {
933
+ input.setSelectionRange?.(0, input.value.length)
934
+ }
935
+ }
936
+ return
937
+ }
938
+
939
+ if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
940
+ e.preventDefault()
941
+ commitRow(node, input)
942
+ extendSelection(e.key === 'ArrowUp' ? -1 : 1)
943
+ containerRef.value?.focus()
944
+ return
945
+ }
946
+
947
+ const start = input.selectionStart ?? 0
948
+ const end = input.selectionEnd ?? 0
949
+ const collapsedSelection = start === end
950
+ const value = input.value
951
+ const list = rows.value
952
+ const rowIndex = list.findIndex((r) => r.node.id === node.id)
953
+ const prevRow = rowIndex > 0 ? list[rowIndex - 1] : null
954
+ const nextRow = rowIndex >= 0 && rowIndex < list.length - 1 ? list[rowIndex + 1] : null
955
+ const root = session.focusRootNode
956
+
957
+ switch (e.key) {
958
+ case 'Enter': {
959
+ e.preventDefault()
960
+ if (start === 0 && end === 0 && value.length > 0) {
961
+ const created = session.insertBeforeOf(node.id)
962
+ if (created) focusRow(created.id, 0)
963
+ } else if (end < value.length) {
964
+ const before = value.slice(0, start)
965
+ const after = value.slice(end)
966
+ clearDraft(node.id)
967
+ input.value = before
968
+ let created: MindmapNode | null = null
969
+ session.transact((doc) => {
970
+ if (node.content.image) {
971
+ const img = node.content.image
972
+ doc.updateRaw(node, imageMarkdown(before, img.src, img.width))
973
+ } else {
974
+ doc.updateDisplayText(node, before)
975
+ }
976
+ created = doc.insertAfter(node, after)
977
+ })
978
+ if (created) {
979
+ session.select((created as MindmapNode).id)
980
+ focusRow((created as MindmapNode).id, 0)
981
+ }
982
+ } else {
983
+ commitRow(node, input)
984
+ const created = session.insertSiblingOf(node.id)
985
+ if (created) {
986
+ session.select(created.id)
987
+ focusRow(created.id, 0)
988
+ }
989
+ }
990
+ break
991
+ }
992
+ case 'Backspace': {
993
+ if (!collapsedSelection || start > 0) break
994
+ e.preventDefault()
995
+ if (!prevRow) {
996
+ // 列表首行:空行删除后回到标题;有内容则不与标题合并(幕布行为)
997
+ if (value.length === 0) {
998
+ if (node.children.length > 0) break
999
+ session.removeNode(node.id)
1000
+ focusTitle()
1001
+ }
1002
+ break
1003
+ }
1004
+ if (value.length === 0) {
1005
+ if (node.children.length > 0) break
1006
+ const prevId = prevRow.node.id
1007
+ const prevLen = prevRow.node.content.text.length
1008
+ session.removeNode(node.id)
1009
+ focusRow(prevId, prevLen)
1010
+ } else if (node.parent && node.parent.children[0] === node) {
1011
+ // 首个子节点行首 Backspace:升级(聚焦根/文档根的直接子节点除外)
1012
+ if (node.parent === session.document.root || node.parent === root) break
1013
+ session.outdentNode(node.id)
1014
+ focusRow(node.id, 0)
1015
+ } else {
1016
+ const prevId = prevRow.node.id
1017
+ const prevLen = prevRow.node.content.text.length
1018
+ session.transact((doc) => {
1019
+ doc.updateDisplayText(prevRow.node, prevRow.node.content.text + value)
1020
+ for (const c of [...node.children]) doc.move(c, prevRow.node, prevRow.node.children.length)
1021
+ doc.remove(node)
1022
+ })
1023
+ session.select(prevId)
1024
+ focusRow(prevId, prevLen)
1025
+ }
1026
+ break
1027
+ }
1028
+ case 'Delete': {
1029
+ if (!collapsedSelection || end < value.length) break
1030
+ if (!nextRow || nextRow.node.children.length > 0) break
1031
+ e.preventDefault()
1032
+ const nextText = nextRow.node.content.text
1033
+ session.transact((doc) => {
1034
+ doc.updateDisplayText(node, value + nextText)
1035
+ doc.remove(nextRow.node)
1036
+ })
1037
+ focusRow(node.id, value.length)
1038
+ break
1039
+ }
1040
+ case 'Tab': {
1041
+ e.preventDefault()
1042
+ const col = input.selectionStart ?? 0
1043
+ commitRow(node, input)
1044
+ if (e.shiftKey) session.outdentNode(node.id)
1045
+ else session.indentNode(node.id)
1046
+ focusRow(node.id, col)
1047
+ break
1048
+ }
1049
+ case 'Escape': {
1050
+ e.preventDefault()
1051
+ commitRow(node, input)
1052
+ containerRef.value?.focus()
1053
+ break
1054
+ }
1055
+ case 'ArrowUp': {
1056
+ if (!collapsedSelection || !caretVisualBoundary(input).first) break
1057
+ e.preventDefault()
1058
+ commitRow(node, input)
1059
+ if (prevRow) focusRow(prevRow.node.id, start)
1060
+ else focusTitle(start)
1061
+ break
1062
+ }
1063
+ case 'ArrowDown': {
1064
+ if (!collapsedSelection || !caretVisualBoundary(input).last) break
1065
+ e.preventDefault()
1066
+ commitRow(node, input)
1067
+ if (nextRow) focusRow(nextRow.node.id, start)
1068
+ break
1069
+ }
1070
+ case 'ArrowLeft': {
1071
+ if (!collapsedSelection || start > 0) break
1072
+ e.preventDefault()
1073
+ commitRow(node, input)
1074
+ if (prevRow) focusRow(prevRow.node.id)
1075
+ else focusTitle()
1076
+ break
1077
+ }
1078
+ case 'ArrowRight': {
1079
+ if (!collapsedSelection || end < value.length) break
1080
+ e.preventDefault()
1081
+ commitRow(node, input)
1082
+ if (nextRow) focusRow(nextRow.node.id, 0)
1083
+ break
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ function onRowInputBlur(node: MindmapNode, e: FocusEvent) {
1089
+ const input = e.currentTarget as RichInlineEditorElement
1090
+ commitRow(node, input)
1091
+ if (focusedId.value === node.id) focusedId.value = null
1092
+ if (!linkEditor.value && textSelection.value?.nodeId === node.id) textSelection.value = null
1093
+ }
1094
+
1095
+ function onRowInputFocus(node: MindmapNode, e?: FocusEvent) {
1096
+ focusedId.value = node.id
1097
+ props.session.select(node.id)
1098
+ const input = (e?.currentTarget as RichInlineEditorElement | undefined) ?? inputOf(node.id)
1099
+ if (input && !draftTexts.value.has(node.id)) {
1100
+ const next = new Map(draftTexts.value)
1101
+ next.set(node.id, input.value)
1102
+ draftTexts.value = next
1103
+ }
1104
+ }
1105
+
1106
+ // ---------- 复制 / 剪切 / 粘贴 ----------
1107
+
1108
+ const LIST_LINE_RE = /^\s*[-*+]\s+/
1109
+
1110
+ function selectedRoots(): MindmapNode[] {
1111
+ const ids = props.session.selectionIds
1112
+ return props.session.selectedNodes.filter((node) => {
1113
+ if (node === props.session.focusRootNode || node === props.session.document.root) return false
1114
+ let parent = node.parent
1115
+ while (parent) {
1116
+ if (ids.has(parent.id)) return false
1117
+ parent = parent.parent
1118
+ }
1119
+ return true
1120
+ })
1121
+ }
1122
+
1123
+ function serializeSelection(): string {
1124
+ return selectedRoots().map((node) => serializeSubtree(node)).join('\n')
1125
+ }
1126
+
1127
+ function selectAllRows() {
1128
+ const list = rows.value
1129
+ if (list.length === 0) return
1130
+ props.session.selectMany(
1131
+ list.map((row) => row.node.id),
1132
+ list[list.length - 1].node.id,
1133
+ list[0].node.id,
1134
+ )
1135
+ containerRef.value?.focus()
1136
+ }
1137
+
1138
+ /** Desk host Mod+A when focus isn't on a row editor target. */
1139
+ function selectAllFromHost(): void {
1140
+ const active = document.activeElement
1141
+ if (active && containerRef.value?.contains(active) && 'selectionStart' in active) {
1142
+ const input = active as RichInlineEditorElement
1143
+ const len = input.value?.length ?? 0
1144
+ const fullySelected = (input.selectionStart ?? 0) === 0 && (input.selectionEnd ?? 0) === len
1145
+ if (!fullySelected && typeof input.setSelectionRange === 'function') {
1146
+ input.setSelectionRange(0, len)
1147
+ return
1148
+ }
1149
+ }
1150
+ selectAllRows()
1151
+ }
1152
+
1153
+ function commitFocusedRowIfAny(): void {
1154
+ const id = focusedId.value
1155
+ if (!id) return
1156
+ const node = props.session.document.find(id)
1157
+ const active = document.activeElement
1158
+ if (!node || !active || !containerRef.value?.contains(active)) return
1159
+ if (!('selectionStart' in active)) return
1160
+ commitRow(node, active as RichInlineEditorElement)
1161
+ }
1162
+
1163
+ /** Desk host Mod+Z — commit in-flight row edit then undo session history. */
1164
+ function undoFromHost(): void {
1165
+ commitFocusedRowIfAny()
1166
+ props.session.undo()
1167
+ containerRef.value?.focus()
1168
+ }
1169
+
1170
+ /** Desk host Mod+Shift+Z / Mod+Y. */
1171
+ function redoFromHost(): void {
1172
+ commitFocusedRowIfAny()
1173
+ props.session.redo()
1174
+ containerRef.value?.focus()
1175
+ }
1176
+
1177
+ function extendSelectionTo(index: number) {
1178
+ const list = rows.value
1179
+ const target = list[index]
1180
+ if (!target) return
1181
+ const anchorId = props.session.selectionAnchor?.id ?? props.session.selectedNode?.id ?? target.node.id
1182
+ const anchorIndex = list.findIndex((row) => row.node.id === anchorId)
1183
+ const normalizedAnchor = anchorIndex >= 0 ? anchorIndex : index
1184
+ const from = Math.min(normalizedAnchor, index)
1185
+ const to = Math.max(normalizedAnchor, index)
1186
+ props.session.selectMany(
1187
+ list.slice(from, to + 1).map((row) => row.node.id),
1188
+ target.node.id,
1189
+ list[normalizedAnchor].node.id,
1190
+ )
1191
+ scrollRowIntoView(index)
1192
+ }
1193
+
1194
+ function extendSelection(direction: -1 | 1) {
1195
+ const list = rows.value
1196
+ if (list.length === 0) return
1197
+ const selected = props.session.selectedNode
1198
+ const current = selected ? list.findIndex((row) => row.node.id === selected.id) : -1
1199
+ const next = current < 0 ? (direction > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, current + direction))
1200
+ extendSelectionTo(next)
1201
+ }
1202
+
1203
+ function onEditorPasteImage(node: MindmapNode, image: Blob) {
1204
+ const input = inputOf(node.id)
1205
+ if (input) commitRow(node, input)
1206
+ emit('pasteImage', node.id, image)
1207
+ }
1208
+
1209
+ function onEditorPasteMultiline(node: MindmapNode, text: string) {
1210
+ const session = props.session
1211
+ const input = inputOf(node.id)
1212
+ if (input) commitRow(node, input)
1213
+
1214
+ const fragment = text
1215
+ .split(/\r?\n/)
1216
+ .filter((l) => l.trim() !== '')
1217
+ .map((l) => {
1218
+ if (LIST_LINE_RE.test(l)) return l
1219
+ const m = /^(\s*)(.*)$/.exec(l)!
1220
+ return `${m[1]}- ${m[2]}`
1221
+ })
1222
+ .join('\n')
1223
+ const { doc: tmp } = parseMarkdown(`# _\n\n${fragment}\n`)
1224
+ const nodes = tmp.root.children.map((c) => cloneSubtree(c))
1225
+ if (nodes.length === 0) return
1226
+
1227
+ const currentText = node.content.text.trim()
1228
+ session.transact((doc) => {
1229
+ let anchor = node
1230
+ if (currentText === '' && node !== doc.root && node !== session.focusRootNode) {
1231
+ const first = nodes.shift()!
1232
+ doc.updateRaw(node, first.content.raw)
1233
+ for (const c of [...first.children]) doc.move(c, node, node.children.length)
1234
+ } else if (node.content.image) {
1235
+ const img = node.content.image
1236
+ if (currentText !== img.alt) {
1237
+ doc.updateRaw(node, imageMarkdown(currentText, img.src, img.width))
1238
+ }
1239
+ } else if (currentText !== node.content.text) {
1240
+ doc.updateDisplayText(node, currentText)
1241
+ }
1242
+ // 粘贴到标题:插入为标题的子节点
1243
+ if (node === session.focusRootNode) {
1244
+ let index = node.children.length
1245
+ for (const n of nodes) {
1246
+ const inserted = doc.addNode(node, n.content, index++)
1247
+ inserted.collapsed = n.collapsed
1248
+ for (const c of [...n.children]) doc.move(c, inserted, inserted.children.length)
1249
+ }
1250
+ return
1251
+ }
1252
+ const parent = anchor.parent ?? doc.root
1253
+ let index = parent.children.indexOf(anchor) + 1
1254
+ for (const n of nodes) {
1255
+ const inserted = doc.addNode(parent, n.content, index++)
1256
+ inserted.collapsed = n.collapsed
1257
+ for (const c of [...n.children]) doc.move(c, inserted, inserted.children.length)
1258
+ }
1259
+ })
1260
+ session.select(node.id)
1261
+ }
1262
+
1263
+ function onCopy(node: MindmapNode, e: ClipboardEvent) {
1264
+ const input = e.currentTarget as RichInlineEditorElement
1265
+ if (input.selectionStart !== input.selectionEnd) return
1266
+ e.preventDefault()
1267
+ const text = props.session.selectionIds.size > 1 && props.session.selectionIds.has(node.id)
1268
+ ? serializeSelection()
1269
+ : serializeSubtree(node)
1270
+ e.clipboardData?.setData('text/plain', text)
1271
+ }
1272
+
1273
+ function onCut(node: MindmapNode, e: ClipboardEvent) {
1274
+ const input = e.currentTarget as RichInlineEditorElement
1275
+ if (input.selectionStart !== input.selectionEnd) return
1276
+ e.preventDefault()
1277
+ if (props.session.selectionIds.size > 1 && props.session.selectionIds.has(node.id)) {
1278
+ e.clipboardData?.setData('text/plain', serializeSelection())
1279
+ props.session.removeSelectedNodes()
1280
+ } else {
1281
+ e.clipboardData?.setData('text/plain', serializeSubtree(node))
1282
+ if (node !== props.session.document.root && node !== props.session.focusRootNode) {
1283
+ props.session.removeNode(node.id)
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ function onContainerCopy(e: ClipboardEvent) {
1289
+ if (props.session.selectionIds.size === 0) return
1290
+ const text = serializeSelection()
1291
+ if (!text) return
1292
+ e.preventDefault()
1293
+ e.clipboardData?.setData('text/plain', text)
1294
+ }
1295
+
1296
+ function onContainerCut(e: ClipboardEvent) {
1297
+ if (props.session.selectionIds.size === 0) return
1298
+ const text = serializeSelection()
1299
+ if (!text) return
1300
+ e.preventDefault()
1301
+ e.clipboardData?.setData('text/plain', text)
1302
+ props.session.removeSelectedNodes()
1303
+ }
1304
+
1305
+ // ---------- 行点击 / bullet / 折叠 ----------
1306
+
1307
+ let suppressRowClick = false
1308
+
1309
+ function onBulletClick(node: MindmapNode, e: MouseEvent) {
1310
+ e.stopPropagation()
1311
+ if (suppressRowClick) return
1312
+ props.session.focusNode(node.id)
1313
+ }
1314
+
1315
+ function onArrowClick(node: MindmapNode, e: MouseEvent) {
1316
+ e.stopPropagation()
1317
+ if (suppressRowClick) return
1318
+ props.session.toggleCollapse(node.id)
1319
+ }
1320
+
1321
+ function onCheckboxClick(node: MindmapNode, e: MouseEvent) {
1322
+ e.stopPropagation()
1323
+ props.session.toggleChecked(node.id)
1324
+ }
1325
+
1326
+ function onInlineDisplayClick(node: MindmapNode, e: MouseEvent) {
1327
+ e.stopPropagation()
1328
+ if ((e.target as HTMLElement | null)?.closest('.inline-run.link')) return
1329
+ if (focusedId.value === node.id) return
1330
+ const editor = e.currentTarget as HTMLElement
1331
+ focusRow(node.id, caretOffsetFromPoint(editor, e.clientX, e.clientY))
1332
+ }
1333
+
1334
+ function navigableUrl(raw: string): string | null {
1335
+ const url = raw.trim()
1336
+ if (!url || /^(?:javascript|data|vbscript):/i.test(url)) return null
1337
+ if (/^(?:https?|mailto|tel):/i.test(url) || /^(?:[./#]|\/)/.test(url)) return url
1338
+ return `https://${url}`
1339
+ }
1340
+
1341
+ function onInlineLinkClick(link: InlineLink, event: Event) {
1342
+ event.preventDefault()
1343
+ event.stopPropagation()
1344
+ const url = navigableUrl(link.url)
1345
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
1346
+ }
1347
+
1348
+ function keepLinkPopover() {
1349
+ if (linkLeaveTimer) clearTimeout(linkLeaveTimer)
1350
+ linkLeaveTimer = null
1351
+ }
1352
+
1353
+ function closeLinkPopoverSoon() {
1354
+ keepLinkPopover()
1355
+ linkLeaveTimer = setTimeout(() => {
1356
+ if (linkEditor.value?.mode === 'existing') linkEditor.value = null
1357
+ }, 180)
1358
+ }
1359
+
1360
+ function onInlineLinkEnter(node: MindmapNode, link: InlineLink, event: MouseEvent) {
1361
+ if (linkEditor.value?.mode === 'selection') return
1362
+ keepLinkPopover()
1363
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
1364
+ linkEditor.value = {
1365
+ mode: 'existing',
1366
+ nodeId: node.id,
1367
+ rawStart: link.rawStart,
1368
+ rawEnd: link.rawEnd,
1369
+ url: link.url,
1370
+ position: {
1371
+ left: Math.max(220, Math.min(window.innerWidth - 220, rect.left + rect.width / 2)),
1372
+ top: Math.min(window.innerHeight - 54, rect.bottom + 6),
1373
+ },
1374
+ }
1375
+ }
1376
+
1377
+ function onTitleLinkEnter(link: InlineLink, event: MouseEvent) {
1378
+ const root = focusRoot.value
1379
+ if (root) onInlineLinkEnter(root, link, event)
1380
+ }
1381
+
1382
+ function saveLink(url: string) {
1383
+ const editor = linkEditor.value
1384
+ if (!editor) return
1385
+ if (editor.mode === 'existing') {
1386
+ props.session.updateNodeInlineLink(editor.nodeId, editor.rawStart!, editor.rawEnd!, url)
1387
+ } else {
1388
+ props.session.setNodeInlineLink(editor.nodeId, editor.start!, editor.end!, url)
1389
+ }
1390
+ linkEditor.value = null
1391
+ const selection = textSelection.value
1392
+ if (selection) restoreTextSelection(selection)
1393
+ }
1394
+
1395
+ function removeLink() {
1396
+ const editor = linkEditor.value
1397
+ if (!editor) return
1398
+ if (editor.mode === 'existing') {
1399
+ props.session.updateNodeInlineLink(editor.nodeId, editor.rawStart!, editor.rawEnd!, null)
1400
+ } else {
1401
+ props.session.setNodeInlineLink(editor.nodeId, editor.start!, editor.end!, null)
1402
+ }
1403
+ linkEditor.value = null
1404
+ }
1405
+
1406
+ function onImageClick(node: MindmapNode, e: MouseEvent) {
1407
+ e.stopPropagation()
1408
+ if (node.content.image) emit('imagePreview', resolvedImageSrc(node.content.image.src))
1409
+ }
1410
+
1411
+ function onOutlineImageLoad(src: string, e: Event) {
1412
+ const el = e.target as HTMLImageElement
1413
+ if (!el.naturalWidth || !el.naturalHeight) return
1414
+ const aspect = el.naturalWidth / el.naturalHeight
1415
+ if (imageAspects.value.get(src) === aspect) return
1416
+ const next = new Map(imageAspects.value)
1417
+ next.set(src, aspect)
1418
+ imageAspects.value = next
1419
+ }
1420
+
1421
+ interface ImgResizeState {
1422
+ id: string
1423
+ startX: number
1424
+ startW: number
1425
+ }
1426
+
1427
+ let imgResize: ImgResizeState | null = null
1428
+
1429
+ function onImageResizePointerDown(node: MindmapNode, e: PointerEvent) {
1430
+ if (e.button !== 0 || !node.content.image) return
1431
+ e.preventDefault()
1432
+ e.stopPropagation()
1433
+ imgResize = {
1434
+ id: node.id,
1435
+ startX: e.clientX,
1436
+ startW: node.content.image.width ?? DEFAULT_OUTLINE_IMG_W,
1437
+ }
1438
+ suppressRowClick = true
1439
+ window.addEventListener('pointermove', onImageResizeMove)
1440
+ window.addEventListener('pointerup', onImageResizeUp, { once: true })
1441
+ }
1442
+
1443
+ function onImageResizeMove(e: PointerEvent) {
1444
+ if (!imgResize) return
1445
+ const next = Math.max(MIN_IMG_W, Math.min(MAX_IMG_W, Math.round(imgResize.startW + (e.clientX - imgResize.startX))))
1446
+ const map = new Map(liveImageWidth.value)
1447
+ map.set(imgResize.id, next)
1448
+ liveImageWidth.value = map
1449
+ }
1450
+
1451
+ function onImageResizeUp() {
1452
+ window.removeEventListener('pointermove', onImageResizeMove)
1453
+ const state = imgResize
1454
+ imgResize = null
1455
+ setTimeout(() => (suppressRowClick = false), 0)
1456
+ if (!state) return
1457
+ const w = liveImageWidth.value.get(state.id) ?? state.startW
1458
+ const map = new Map(liveImageWidth.value)
1459
+ map.delete(state.id)
1460
+ liveImageWidth.value = map
1461
+ if (w !== state.startW) props.session.setImageWidth(state.id, w)
1462
+ }
1463
+
1464
+ // ---------- 键盘导航(焦点在容器、非编辑态时) ----------
1465
+
1466
+ function onKeydown(e: KeyboardEvent) {
1467
+ const session = props.session
1468
+ const sel = session.selectedNode
1469
+ const mod = e.metaKey || e.ctrlKey
1470
+ const key = e.key.toLowerCase()
1471
+
1472
+ if (mod && key === '\\' && session.selectionIds.size > 0) {
1473
+ e.preventDefault()
1474
+ session.clearSelectedNodeFormats()
1475
+ return
1476
+ }
1477
+
1478
+ const format = inlineFormatShortcut(e, session.selectionIds.size > 1)
1479
+ if (format && session.selectionIds.size > 0) {
1480
+ e.preventDefault()
1481
+ session.formatSelectedNodes(format)
1482
+ return
1483
+ }
1484
+
1485
+ if (mod && key === 'a') {
1486
+ e.preventDefault()
1487
+ selectAllRows()
1488
+ return
1489
+ }
1490
+
1491
+ if (mod && key === 'z') {
1492
+ e.preventDefault()
1493
+ if (e.shiftKey) session.redo()
1494
+ else session.undo()
1495
+ return
1496
+ }
1497
+ if (mod && key === 'y') {
1498
+ e.preventDefault()
1499
+ session.redo()
1500
+ return
1501
+ }
1502
+ if (mod && key === 'f') {
1503
+ e.preventDefault()
1504
+ emit('requestSearch')
1505
+ return
1506
+ }
1507
+ if (mod && !e.shiftKey && key === 'x' && session.selectionIds.size > 0) {
1508
+ e.preventDefault()
1509
+ void cutSelectedToClipboard()
1510
+ return
1511
+ }
1512
+ if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
1513
+ e.preventDefault()
1514
+ if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
1515
+ else if (mod && e.shiftKey && sel) session.toggleCollapseSiblings(sel.id)
1516
+ else if (sel) session.toggleCollapse(sel.id)
1517
+ return
1518
+ }
1519
+ if (mod && e.key === ']') {
1520
+ e.preventDefault()
1521
+ if (sel) {
1522
+ session.focusNode(sel.id)
1523
+ focusTitle()
1524
+ }
1525
+ return
1526
+ }
1527
+ if (mod && e.key === '[') {
1528
+ e.preventDefault()
1529
+ if (session.focusPath.length > 0) session.exitFocusTo(session.focusPath.length - 1)
1530
+ return
1531
+ }
1532
+ if (mod && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
1533
+ e.preventDefault()
1534
+ session.moveSelectedNodes(e.key === 'ArrowUp' ? -1 : 1)
1535
+ return
1536
+ }
1537
+ if (mod && e.shiftKey && (key === 'backspace' || key === 'd')) {
1538
+ e.preventDefault()
1539
+ session.removeSelectedNodes()
1540
+ return
1541
+ }
1542
+ if (mod && e.shiftKey && key === 'l') {
1543
+ e.preventDefault()
1544
+ session.toggleTaskSelectedNodes()
1545
+ return
1546
+ }
1547
+ if (mod && e.shiftKey && key === 'k') {
1548
+ e.preventDefault()
1549
+ session.toggleCheckedSelectedNodes()
1550
+ return
1551
+ }
1552
+ if (mod && key === 'd') {
1553
+ e.preventDefault()
1554
+ session.duplicateSelectedNodes()
1555
+ return
1556
+ }
1557
+
1558
+ const list = rows.value
1559
+ const idx = sel ? list.findIndex((r) => r.node.id === sel.id) : -1
1560
+ const root = session.focusRootNode
1561
+
1562
+ switch (e.key) {
1563
+ case 'Enter':
1564
+ e.preventDefault()
1565
+ if (sel) focusRow(sel.id)
1566
+ else focusTitle()
1567
+ break
1568
+ case 'Tab':
1569
+ e.preventDefault()
1570
+ if (sel && sel !== root) {
1571
+ if (e.shiftKey) session.outdentSelectedNodes()
1572
+ else session.indentSelectedNodes()
1573
+ }
1574
+ break
1575
+ case 'Delete':
1576
+ case 'Backspace':
1577
+ e.preventDefault()
1578
+ if (sel && sel !== root) session.removeSelectedNodes()
1579
+ break
1580
+ case 'ArrowUp':
1581
+ case 'ArrowDown': {
1582
+ e.preventDefault()
1583
+ if (e.shiftKey) {
1584
+ extendSelection(e.key === 'ArrowDown' ? 1 : -1)
1585
+ break
1586
+ }
1587
+ if (list.length === 0) {
1588
+ session.select(root.id)
1589
+ break
1590
+ }
1591
+ if (sel?.id === root.id && e.key === 'ArrowDown') {
1592
+ session.select(list[0].node.id)
1593
+ scrollRowIntoView(0)
1594
+ break
1595
+ }
1596
+ const next =
1597
+ idx < 0
1598
+ ? list[0]
1599
+ : list[Math.max(0, Math.min(list.length - 1, idx + (e.key === 'ArrowDown' ? 1 : -1)))]
1600
+ session.select(next.node.id)
1601
+ scrollRowIntoView(next.index)
1602
+ break
1603
+ }
1604
+ case 'ArrowLeft':
1605
+ e.preventDefault()
1606
+ if (sel && sel !== root) {
1607
+ if (sel.children.length > 0 && !sel.collapsed) session.toggleCollapse(sel.id)
1608
+ else if (sel.parent) session.select(sel.parent.id)
1609
+ }
1610
+ break
1611
+ case 'ArrowRight':
1612
+ e.preventDefault()
1613
+ if (sel) {
1614
+ if (sel.collapsed) session.toggleCollapse(sel.id)
1615
+ else if (sel.children.length > 0) session.select(sel.children[0].id)
1616
+ }
1617
+ break
1618
+ }
1619
+ }
1620
+
1621
+ // ---------- 鼠标跨节点连续选择 ----------
1622
+
1623
+ const rangeSelecting = ref(false)
1624
+ let stopRangeSelect: (() => void) | null = null
1625
+
1626
+ function onRowPointerDown(row: Row, e: PointerEvent) {
1627
+ if (e.button !== 0) return
1628
+ const target = e.target as HTMLElement | null
1629
+ if (target?.closest('.row-gutter, .row-checkbox, .row-badge, .row-image, .row-image-handle')) return
1630
+
1631
+ if (e.shiftKey) {
1632
+ e.preventDefault()
1633
+ const active = document.activeElement
1634
+ if (isInlineEditorElement(active)) active.blur()
1635
+ extendSelectionTo(row.index)
1636
+ containerRef.value?.focus()
1637
+ return
1638
+ }
1639
+
1640
+ stopRangeSelect?.()
1641
+ const pointerId = e.pointerId
1642
+ const startIndex = row.index
1643
+ const startY = e.clientY
1644
+ let dragging = false
1645
+
1646
+ const cleanup = () => {
1647
+ document.removeEventListener('pointermove', onMove)
1648
+ document.removeEventListener('pointerup', onUp)
1649
+ document.removeEventListener('pointercancel', onUp)
1650
+ if (dragging) {
1651
+ document.body.style.removeProperty('user-select')
1652
+ document.body.style.removeProperty('-webkit-user-select')
1653
+ setTimeout(() => (rangeSelecting.value = false), 0)
1654
+ }
1655
+ stopRangeSelect = null
1656
+ }
1657
+ const onMove = (ev: PointerEvent) => {
1658
+ if (ev.isTrusted && ev.pointerId !== pointerId) return
1659
+ const index = rowIndexAt(ev.clientY)
1660
+ if (index < 0) return
1661
+ if (!dragging) {
1662
+ if (index === startIndex || Math.abs(ev.clientY - startY) < 5) return
1663
+ dragging = true
1664
+ rangeSelecting.value = true
1665
+ document.body.style.setProperty('user-select', 'none')
1666
+ document.body.style.setProperty('-webkit-user-select', 'none')
1667
+ const active = document.activeElement
1668
+ if (isInlineEditorElement(active)) active.blur()
1669
+ window.getSelection()?.removeAllRanges()
1670
+ }
1671
+ ev.preventDefault()
1672
+ const from = Math.min(startIndex, index)
1673
+ const to = Math.max(startIndex, index)
1674
+ const selected = rows.value.slice(from, to + 1)
1675
+ props.session.selectMany(
1676
+ selected.map((item) => item.node.id),
1677
+ rows.value[index]?.node.id ?? null,
1678
+ rows.value[startIndex]?.node.id ?? null,
1679
+ )
1680
+ }
1681
+ const onUp = (ev: PointerEvent) => {
1682
+ if (ev.isTrusted && ev.pointerId !== pointerId) return
1683
+ cleanup()
1684
+ if (dragging) containerRef.value?.focus()
1685
+ }
1686
+
1687
+ stopRangeSelect = cleanup
1688
+ document.addEventListener('pointermove', onMove, { passive: false })
1689
+ document.addEventListener('pointerup', onUp)
1690
+ document.addEventListener('pointercancel', onUp)
1691
+ }
1692
+
1693
+ // ---------- 拖拽移动(幕布口径:圆点发起;横线 + 层级线高亮) ----------
1694
+
1695
+ interface DropIndicator {
1696
+ type: 'before' | 'after' | 'child'
1697
+ targetId: string
1698
+ /** 相对 .outline-spacer 的横线位置 */
1699
+ top: number
1700
+ left: number
1701
+ width: number
1702
+ /** child:高亮与父级圆点对齐的那一列引导线(depth 值) */
1703
+ guideDepth: number | null
1704
+ }
1705
+
1706
+ interface DragState {
1707
+ id: string
1708
+ startY: number
1709
+ dragging: boolean
1710
+ pointerX: number
1711
+ pointerY: number
1712
+ indicator: DropIndicator | null
1713
+ }
1714
+
1715
+ const drag = ref<DragState | null>(null)
1716
+
1717
+ function bulletCenterX(depth: number): number {
1718
+ return COLLAPSE_LEAD + depth * INDENT + BULLET_SIZE / 2
1719
+ }
1720
+
1721
+ function isUnderAncestor(node: MindmapNode, ancestorId: string): boolean {
1722
+ let p = node.parent
1723
+ while (p) {
1724
+ if (p.id === ancestorId) return true
1725
+ p = p.parent
1726
+ }
1727
+ return false
1728
+ }
1729
+
1730
+ function isDropGuideHighlighted(row: Row, guideDepth: number): boolean {
1731
+ const ind = drag.value?.indicator
1732
+ if (!ind || ind.type !== 'child' || ind.guideDepth !== guideDepth) return false
1733
+ return row.node.id === ind.targetId || isUnderAncestor(row.node, ind.targetId)
1734
+ }
1735
+
1736
+ function onGripPointerDown(node: MindmapNode, e: PointerEvent) {
1737
+ if (e.button !== 0) return
1738
+ if (node === props.session.focusRootNode) return
1739
+ e.preventDefault()
1740
+ const grip = e.currentTarget as HTMLElement | null
1741
+ try {
1742
+ grip?.setPointerCapture?.(e.pointerId)
1743
+ } catch {
1744
+ /* ignore */
1745
+ }
1746
+ document.body.style.setProperty('user-select', 'none')
1747
+ document.body.style.setProperty('-webkit-user-select', 'none')
1748
+ window.getSelection()?.removeAllRanges()
1749
+ const pointerId = e.pointerId
1750
+ // 阈值前不写 drag ref,避免重渲染拆掉 grip 上的 capture/监听
1751
+ const pending: DragState = {
1752
+ id: node.id,
1753
+ startY: e.clientY,
1754
+ dragging: false,
1755
+ pointerX: e.clientX,
1756
+ pointerY: e.clientY,
1757
+ indicator: null,
1758
+ }
1759
+ let finished = false
1760
+ const startX = e.clientX
1761
+ const onMove = (ev: PointerEvent) => {
1762
+ if (ev.isTrusted && ev.pointerId !== pointerId) return
1763
+ pending.pointerX = ev.clientX
1764
+ pending.pointerY = ev.clientY
1765
+ const moved =
1766
+ pending.dragging ||
1767
+ Math.abs(ev.clientY - pending.startY) >= 5 ||
1768
+ Math.abs(ev.clientX - startX) >= 5
1769
+ if (!moved) return
1770
+ if (!pending.dragging) {
1771
+ pending.dragging = true
1772
+ suppressRowClick = true
1773
+ window.getSelection()?.removeAllRanges()
1774
+ }
1775
+ pending.indicator = calcDropIndicator(ev.clientX, ev.clientY, pending.id)
1776
+ drag.value = { ...pending }
1777
+ }
1778
+ const onUp = (ev: PointerEvent) => {
1779
+ if (ev.isTrusted && ev.pointerId !== pointerId) return
1780
+ if (finished) return
1781
+ finished = true
1782
+ document.removeEventListener('pointermove', onMove)
1783
+ document.removeEventListener('pointerup', onUp)
1784
+ document.removeEventListener('pointercancel', onUp)
1785
+ try {
1786
+ if (grip?.hasPointerCapture?.(pointerId)) grip.releasePointerCapture(pointerId)
1787
+ } catch {
1788
+ /* ignore */
1789
+ }
1790
+ onDragUp()
1791
+ }
1792
+ // 只挂 document:setPointerCapture 后事件仍冒泡到 document;避免 grip 重渲染丢监听
1793
+ document.addEventListener('pointermove', onMove)
1794
+ document.addEventListener('pointerup', onUp)
1795
+ document.addEventListener('pointercancel', onUp)
1796
+ }
1797
+
1798
+ /** 用 spacer 可视坐标命中行,避免 padding/标题 margin 造成的偏移 */
1799
+ function rowIndexAt(clientY: number): number {
1800
+ const el = containerRef.value
1801
+ const spacer = el?.querySelector('.outline-spacer') as HTMLElement | null
1802
+ if (!el || !spacer) return -1
1803
+ const y = clientY - spacer.getBoundingClientRect().top
1804
+ const list = rows.value
1805
+ if (list.length === 0) return -1
1806
+ if (y < 0) return 0
1807
+ for (let i = 0; i < list.length; i++) {
1808
+ const row = list[i]
1809
+ if (y < row.top + row.height) return i
1810
+ }
1811
+ return list.length - 1
1812
+ }
1813
+
1814
+ function descendantCount(node: MindmapNode): number {
1815
+ return node.children.reduce((total, child) => total + 1 + descendantCount(child), 0)
1816
+ }
1817
+
1818
+ /** 幕布式落点:下半区锚定当前行,偏右/有子则收为子节点,否则插到同级之后(可左移升层) */
1819
+ function calcDropIndicator(clientX: number, clientY: number, dragId: string): DropIndicator | null {
1820
+ const el = containerRef.value
1821
+ const spacer = el?.querySelector('.outline-spacer') as HTMLElement | null
1822
+ if (!el || !spacer) return null
1823
+ const list = rows.value
1824
+ if (list.length === 0) return null
1825
+
1826
+ const idx = rowIndexAt(clientY)
1827
+ if (idx < 0) return null
1828
+ const hit = list[idx]
1829
+ const hitEl = el.querySelector(`.outline-row[data-node-id="${hit.node.id}"]`) as HTMLElement | null
1830
+ if (!hitEl) return null
1831
+ const hitRect = hitEl.getBoundingClientRect()
1832
+ const midY = (hitRect.top + hitRect.bottom) / 2
1833
+
1834
+ // 锚定节点:指针在行下半 → 本行;上半 → 上一行(幕布 _calcDropOperation)
1835
+ let anchorIdx = idx
1836
+ if (clientY <= midY) {
1837
+ if (idx === 0) {
1838
+ // 插到列表最前:作为聚焦根的第一个子节点之前 → before 首行
1839
+ if (hit.node.id === dragId || isUnderAncestor(hit.node, dragId)) return null
1840
+ return lineIndicator('before', hit, 0, spacer)
1841
+ }
1842
+ anchorIdx = idx - 1
1843
+ }
1844
+
1845
+ // 落在被拖节点或其子树上时,改锚定到最近的合法邻居
1846
+ while (
1847
+ anchorIdx >= 0 &&
1848
+ (list[anchorIdx].node.id === dragId || isUnderAncestor(list[anchorIdx].node, dragId))
1849
+ ) {
1850
+ anchorIdx -= 1
1851
+ }
1852
+ if (anchorIdx < 0) {
1853
+ const first = list.find((r) => r.node.id !== dragId && !isUnderAncestor(r.node, dragId))
1854
+ if (!first) return null
1855
+ return lineIndicator('before', first, first.depth, spacer)
1856
+ }
1857
+ const anchor = list[anchorIdx]
1858
+
1859
+ const anchorEl = el.querySelector(`.outline-row[data-node-id="${anchor.node.id}"]`) as HTMLElement | null
1860
+ const anchorRect = (anchorEl ?? hitEl).getBoundingClientRect()
1861
+ const indentEdge = anchorRect.left + anchor.depth * INDENT
1862
+ const hasVisibleKids = anchor.node.children.some((c) => c.id !== dragId) && !anchor.node.collapsed
1863
+ // 偏右超过一层缩进,或目标已有展开子节点 → 收为第一个子节点(幕布口径)
1864
+ if ((hasVisibleKids || clientX > indentEdge + INDENT) && !anchor.node.collapsed) {
1865
+ return lineIndicator('child', anchor, anchor.depth + 1, spacer)
1866
+ }
1867
+
1868
+ // 向左拖:按指针所在缩进层级逐级提升。不能只允许“末子”提升,
1869
+ // 否则有后续兄弟的节点永远无法拖回顶层。
1870
+ const resolved = resolveAfterDropLevel(
1871
+ anchor.node,
1872
+ props.session.focusRootNode,
1873
+ clientX,
1874
+ indentEdge,
1875
+ anchor.depth,
1876
+ INDENT,
1877
+ )
1878
+ const place = resolved.node
1879
+ const depth = resolved.depth
1880
+ if (place.id === dragId || isUnderAncestor(place, dragId)) return null
1881
+
1882
+ const placeRow = list.find((r) => r.node.id === place.id) ?? anchor
1883
+ // 横线画在指针锚定行附近(幕布画在 curNode);提交时插到 place 之后
1884
+ const visual = lineIndicator('after', anchor, depth, spacer)
1885
+ visual.targetId = placeRow.node.id
1886
+ return visual
1887
+ }
1888
+
1889
+ function lineIndicator(
1890
+ type: 'before' | 'after' | 'child',
1891
+ row: Row,
1892
+ lineDepth: number,
1893
+ spacer: HTMLElement,
1894
+ ): DropIndicator {
1895
+ const top =
1896
+ type === 'before'
1897
+ ? row.top
1898
+ : type === 'after'
1899
+ ? row.top + row.height
1900
+ : row.top + Math.min(row.textHeight, ROW_HEIGHT)
1901
+ const lineLeft = Math.max(0, COLLAPSE_LEAD + Math.max(0, lineDepth) * INDENT)
1902
+ const width = Math.max(120, spacer.clientWidth - lineLeft - 8)
1903
+ return {
1904
+ type,
1905
+ targetId: row.node.id,
1906
+ top,
1907
+ left: lineLeft,
1908
+ width,
1909
+ guideDepth: type === 'child' ? row.depth : null,
1910
+ }
1911
+ }
1912
+
1913
+ function onDragUp() {
1914
+ document.body.style.removeProperty('user-select')
1915
+ document.body.style.removeProperty('-webkit-user-select')
1916
+ window.getSelection()?.removeAllRanges()
1917
+ const d = drag.value
1918
+ drag.value = null
1919
+ setTimeout(() => (suppressRowClick = false), 0)
1920
+ if (!d?.dragging || !d.indicator) return
1921
+ const session = props.session
1922
+ const target = session.document.find(d.indicator.targetId)
1923
+ if (!target) return
1924
+ if (d.indicator.type === 'child') {
1925
+ session.moveNode(d.id, target.id, 0)
1926
+ } else if (target.parent) {
1927
+ const idx = target.parent.children.indexOf(target)
1928
+ session.moveNode(d.id, target.parent.id, d.indicator.type === 'before' ? idx : idx + 1)
1929
+ }
1930
+ session.select(d.id)
1931
+ }
1932
+ </script>
1933
+
1934
+ <template>
1935
+ <div
1936
+ ref="containerRef"
1937
+ class="outline-view"
1938
+ :class="{ 'is-dragging': !!drag?.dragging, 'is-range-selecting': rangeSelecting }"
1939
+ tabindex="0"
1940
+ @scroll="onScroll"
1941
+ @keydown="onKeydown"
1942
+ @copy="onContainerCopy"
1943
+ @cut="onContainerCut"
1944
+ >
1945
+ <div v-if="focusRoot" class="outline-title" :class="{ 'is-selected': focusRoot.id === selectedId, 'has-image': !!focusRoot.content.image }" :style="{ minHeight: `${titleHeight}px` }">
1946
+ <RichInlineEditor
1947
+ class="title-input"
1948
+ :class="{ 'is-view-mode': focusedId !== focusRoot.id }"
1949
+ :editor-id="focusRoot.id"
1950
+ :raw="focusRoot.content.image ? focusRoot.content.text : focusRoot.content.raw"
1951
+ :active="focusedId === focusRoot.id"
1952
+ :placeholder="focusRoot.content.image ? '图片描述' : '标题'"
1953
+ paste-mode="outline"
1954
+ :style="{ height: `${Math.max(36, titleHeight - imageBlockHeight(focusRoot))}px` }"
1955
+ @click="onInlineDisplayClick(focusRoot, $event)"
1956
+ @focus="onRowInputFocus(focusRoot, $event)"
1957
+ @blur="onRowInputBlur(focusRoot, $event)"
1958
+ @draft-change="onRowDraftChange(focusRoot, null, $event)"
1959
+ @commit="commitEditorPayload(focusRoot, $event)"
1960
+ @keydown="onTitleKeydown"
1961
+ @keyup="onTextSelection(focusRoot, $event)"
1962
+ @mouseup="onTextSelection(focusRoot, $event)"
1963
+ @select="onTextSelection(focusRoot, $event)"
1964
+ @copy="onCopy(focusRoot, $event)"
1965
+ @cut="onCut(focusRoot, $event)"
1966
+ @paste-image="onEditorPasteImage(focusRoot, $event)"
1967
+ @paste-multiline="onEditorPasteMultiline(focusRoot, $event)"
1968
+ @link-enter="onTitleLinkEnter"
1969
+ @link-leave="closeLinkPopoverSoon"
1970
+ @link-click="onInlineLinkClick"
1971
+ />
1972
+ <div v-if="focusRoot.content.image" class="title-image">
1973
+ <div class="row-image-frame" :style="{ width: `${displayImageWidth(focusRoot)}px` }">
1974
+ <img
1975
+ class="row-image-img"
1976
+ :src="resolvedImageSrc(focusRoot.content.image.src)"
1977
+ :alt="focusRoot.content.image.alt"
1978
+ draggable="false"
1979
+ @load="onOutlineImageLoad(focusRoot.content.image.src, $event)"
1980
+ @click="onImageClick(focusRoot, $event)"
1981
+ />
1982
+ <span
1983
+ class="row-image-handle"
1984
+ title="拖动调节宽度"
1985
+ @pointerdown="onImageResizePointerDown(focusRoot, $event)"
1986
+ />
1987
+ </div>
1988
+ </div>
1989
+ </div>
1990
+
1991
+ <div class="outline-spacer" :style="{ height: `${totalListHeight}px` }">
1992
+ <div
1993
+ v-for="row in visibleRows"
1994
+ :key="row.node.id"
1995
+ class="outline-row"
1996
+ :data-node-id="row.node.id"
1997
+ :class="{
1998
+ 'is-selected': selectedIds.has(row.node.id),
1999
+ 'is-matched': matches.has(row.node.id),
2000
+ 'is-hovered': hoveredId === row.node.id,
2001
+ 'has-image': !!row.node.content.image,
2002
+ 'is-drag-source': drag?.dragging && drag.id === row.node.id,
2003
+ }"
2004
+ :style="{ top: `${row.top}px`, height: `${row.height}px` }"
2005
+ @mouseenter="hoveredId = row.node.id"
2006
+ @mouseleave="hoveredId = null"
2007
+ @pointerdown.capture="onRowPointerDown(row, $event)"
2008
+ >
2009
+ <!-- 引导线与祖先圆点中心对齐(幕布 indent-item 口径) -->
2010
+ <span class="row-indents" :style="{ width: `${gutterWidth(row.depth)}px` }">
2011
+ <i
2012
+ v-for="i in row.depth"
2013
+ :key="i"
2014
+ class="indent-guide"
2015
+ :class="{ 'is-drop-highlight': isDropGuideHighlighted(row, i - 1) }"
2016
+ :style="{ left: `${(i - 1) * INDENT + BULLET_CENTER}px` }"
2017
+ />
2018
+ <!-- 收为子节点时:在目标行自身圆点列补一条高亮层级线 -->
2019
+ <i
2020
+ v-if="drag?.indicator?.type === 'child' && drag.indicator.targetId === row.node.id"
2021
+ class="indent-guide is-drop-highlight is-drop-parent"
2022
+ :style="{ left: `${bulletCenterX(row.depth)}px` }"
2023
+ />
2024
+ </span>
2025
+
2026
+ <div class="row-main">
2027
+ <span
2028
+ class="row-gutter"
2029
+ :style="{ width: `${gutterWidth(row.depth)}px` }"
2030
+ @pointerdown="onGripPointerDown(row.node, $event)"
2031
+ >
2032
+ <button
2033
+ v-if="row.node.children.length > 0"
2034
+ type="button"
2035
+ class="row-collapse"
2036
+ :class="{
2037
+ collapsed: row.node.collapsed,
2038
+ visible: hoveredId === row.node.id || row.node.collapsed,
2039
+ }"
2040
+ :style="{ left: `${row.depth > 0 ? (row.depth - 1) * INDENT + BULLET_CENTER - COLLAPSE_LEAD / 2 : 0}px` }"
2041
+ title="折叠/展开"
2042
+ @click="onArrowClick(row.node, $event)"
2043
+ @pointerdown.stop="onGripPointerDown(row.node, $event)"
2044
+ />
2045
+ <span
2046
+ class="row-bullet"
2047
+ :class="{ 'has-children': row.node.children.length > 0, collapsed: row.node.collapsed }"
2048
+ :style="{ left: `${COLLAPSE_LEAD + row.depth * INDENT}px` }"
2049
+ title="点击进入主题"
2050
+ @click="onBulletClick(row.node, $event)"
2051
+ @pointerdown.stop="onGripPointerDown(row.node, $event)"
2052
+ >
2053
+ <i class="bullet-dot" />
2054
+ <span v-if="row.node.collapsed && row.node.children.length > 0" class="bullet-count">{{
2055
+ descendantCount(row.node)
2056
+ }}</span>
2057
+ </span>
2058
+ </span>
2059
+
2060
+ <button
2061
+ v-if="row.node.content.checked !== null"
2062
+ type="button"
2063
+ class="row-checkbox"
2064
+ :class="{ checked: row.node.content.checked }"
2065
+ role="checkbox"
2066
+ :aria-checked="row.node.content.checked"
2067
+ :title="row.node.content.checked ? '标记为未完成' : '标记为已完成'"
2068
+ @click="onCheckboxClick(row.node, $event)"
2069
+ >
2070
+ <svg v-if="row.node.content.checked" viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 8 3 3 6-6" /></svg>
2071
+ </button>
2072
+
2073
+ <RichInlineEditor
2074
+ class="row-input"
2075
+ :class="{
2076
+ 'is-task-done': row.node.content.checked === true,
2077
+ 'is-view-mode': focusedId !== row.node.id,
2078
+ }"
2079
+ :editor-id="row.node.id"
2080
+ :raw="row.node.content.image ? row.node.content.text : row.node.content.raw"
2081
+ :active="focusedId === row.node.id"
2082
+ :done="row.node.content.checked === true"
2083
+ :placeholder="row.node.content.image ? '图片描述' : undefined"
2084
+ paste-mode="outline"
2085
+ :style="{ height: `${row.textHeight - 8}px` }"
2086
+ @click="onInlineDisplayClick(row.node, $event)"
2087
+ @focus="onRowInputFocus(row.node, $event)"
2088
+ @blur="onRowInputBlur(row.node, $event)"
2089
+ @draft-change="onRowDraftChange(row.node, row.depth, $event)"
2090
+ @commit="commitEditorPayload(row.node, $event)"
2091
+ @keydown="onEditKeydown(row.node, $event)"
2092
+ @keyup="onTextSelection(row.node, $event)"
2093
+ @mouseup="onTextSelection(row.node, $event)"
2094
+ @select="onTextSelection(row.node, $event)"
2095
+ @copy="onCopy(row.node, $event)"
2096
+ @cut="onCut(row.node, $event)"
2097
+ @paste-image="onEditorPasteImage(row.node, $event)"
2098
+ @paste-multiline="onEditorPasteMultiline(row.node, $event)"
2099
+ @link-enter="(link, event) => onInlineLinkEnter(row.node, link, event)"
2100
+ @link-leave="closeLinkPopoverSoon"
2101
+ @link-click="onInlineLinkClick"
2102
+ />
2103
+ </div>
2104
+
2105
+ <div
2106
+ v-if="row.node.content.image"
2107
+ class="row-image"
2108
+ :style="{ marginLeft: `${gutterWidth(row.depth)}px` }"
2109
+ >
2110
+ <div class="row-image-frame" :style="{ width: `${displayImageWidth(row.node)}px` }">
2111
+ <img
2112
+ class="row-image-img"
2113
+ :src="resolvedImageSrc(row.node.content.image.src)"
2114
+ :alt="row.node.content.image.alt"
2115
+ draggable="false"
2116
+ @load="onOutlineImageLoad(row.node.content.image.src, $event)"
2117
+ @click="onImageClick(row.node, $event)"
2118
+ />
2119
+ <span
2120
+ class="row-image-handle"
2121
+ title="拖动调节宽度"
2122
+ @pointerdown="onImageResizePointerDown(row.node, $event)"
2123
+ />
2124
+ </div>
2125
+ </div>
2126
+ </div>
2127
+
2128
+ <div
2129
+ v-if="drag?.dragging && drag.indicator"
2130
+ class="drop-line"
2131
+ :data-drop-type="drag.indicator.type"
2132
+ :data-drop-target="drag.indicator.targetId"
2133
+ :data-guide-depth="drag.indicator.guideDepth ?? ''"
2134
+ :style="{
2135
+ top: `${drag.indicator.top}px`,
2136
+ left: `${drag.indicator.left}px`,
2137
+ width: `${drag.indicator.width}px`,
2138
+ }"
2139
+ />
2140
+ </div>
2141
+
2142
+ <div
2143
+ v-if="drag?.dragging"
2144
+ class="outline-drag-widget"
2145
+ :style="{ left: `${drag.pointerX}px`, top: `${drag.pointerY}px` }"
2146
+ >
2147
+ <i class="bullet-dot" />
2148
+ </div>
2149
+
2150
+ <input ref="imagePickerRef" type="file" accept="image/*" hidden @change="onSelectionImagePicked" />
2151
+
2152
+ <SelectionToolbar
2153
+ v-if="textSelection && !linkEditor"
2154
+ mode="text"
2155
+ :position="textSelection.position"
2156
+ :active-formats="selectedTextFormats"
2157
+ @format="applyTextFormat"
2158
+ @task="toggleSelectionTask"
2159
+ @image="requestSelectionImage"
2160
+ @link="openSelectionLinkEditor"
2161
+ @clear="clearTextFormat"
2162
+ @delete="removeToolbarSelection"
2163
+ />
2164
+ <SelectionToolbar
2165
+ v-else-if="multiSelectionPosition && !linkEditor"
2166
+ mode="nodes"
2167
+ :position="multiSelectionPosition"
2168
+ @format="applyNodeFormat"
2169
+ @task="toggleSelectionTask"
2170
+ @copy="copySelectedFromToolbar"
2171
+ @clear="clearNodeFormats"
2172
+ @delete="removeToolbarSelection"
2173
+ />
2174
+ <LinkPopover
2175
+ v-if="linkEditor"
2176
+ ref="linkPopoverRef"
2177
+ :url="linkEditor.url"
2178
+ :position="linkEditor.position"
2179
+ :start-editing="linkEditor.mode === 'selection'"
2180
+ @save="saveLink"
2181
+ @remove="removeLink"
2182
+ @keep="keepLinkPopover"
2183
+ @leave="closeLinkPopoverSoon"
2184
+ @close="linkEditor = null"
2185
+ />
2186
+ </div>
2187
+ </template>
2188
+
2189
+ <style scoped>
2190
+ .outline-view {
2191
+ position: relative;
2192
+ height: 100%;
2193
+ overflow: auto;
2194
+ outline: none;
2195
+ background: var(--mm-panel-bg);
2196
+ padding: 12px 24px 48px;
2197
+ }
2198
+ .outline-view.is-dragging,
2199
+ .outline-view.is-dragging *,
2200
+ .outline-view.is-range-selecting,
2201
+ .outline-view.is-range-selecting * {
2202
+ user-select: none !important;
2203
+ -webkit-user-select: none !important;
2204
+ caret-color: transparent;
2205
+ }
2206
+ .outline-title {
2207
+ position: relative;
2208
+ min-height: 56px;
2209
+ display: flex;
2210
+ flex-direction: column;
2211
+ align-items: stretch;
2212
+ justify-content: flex-start;
2213
+ margin-bottom: 8px;
2214
+ gap: 8px;
2215
+ }
2216
+ .outline-title.has-image {
2217
+ min-height: auto;
2218
+ }
2219
+ .title-input {
2220
+ width: 100%;
2221
+ border: none;
2222
+ outline: none;
2223
+ background: transparent;
2224
+ color: var(--mm-text);
2225
+ font-size: 28px;
2226
+ font-weight: 600;
2227
+ line-height: 1.3;
2228
+ padding: 4px 0;
2229
+ font-family: inherit;
2230
+ resize: none;
2231
+ overflow: hidden;
2232
+ white-space: pre-wrap;
2233
+ word-break: break-word;
2234
+ }
2235
+ .title-input::placeholder {
2236
+ color: var(--mm-text-dim);
2237
+ font-weight: 500;
2238
+ }
2239
+ .title-input.is-view-mode { cursor: text; }
2240
+ .title-image {
2241
+ flex: none;
2242
+ padding: 0 0 8px;
2243
+ }
2244
+ .outline-spacer {
2245
+ position: relative;
2246
+ }
2247
+ .outline-row {
2248
+ position: absolute;
2249
+ left: 0;
2250
+ right: 0;
2251
+ display: flex;
2252
+ flex-direction: column;
2253
+ justify-content: flex-start;
2254
+ gap: 0;
2255
+ padding-right: 12px;
2256
+ font-size: 15px;
2257
+ color: var(--mm-text);
2258
+ overflow: hidden;
2259
+ }
2260
+ .row-main {
2261
+ display: flex;
2262
+ align-items: flex-start;
2263
+ gap: 2px;
2264
+ min-height: 32px;
2265
+ flex: none;
2266
+ }
2267
+ .row-image {
2268
+ flex: none;
2269
+ padding: 0 0 8px;
2270
+ }
2271
+ .row-image-frame {
2272
+ position: relative;
2273
+ max-width: 100%;
2274
+ line-height: 0;
2275
+ border-radius: 6px;
2276
+ overflow: hidden;
2277
+ background: color-mix(in srgb, var(--mm-text-dim) 12%, transparent);
2278
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--mm-text-dim) 25%, transparent);
2279
+ }
2280
+ .row-image-img {
2281
+ display: block;
2282
+ width: 100%;
2283
+ height: auto;
2284
+ max-height: 480px;
2285
+ object-fit: contain;
2286
+ object-position: left top;
2287
+ cursor: zoom-in;
2288
+ user-select: none;
2289
+ }
2290
+ .row-image-handle {
2291
+ position: absolute;
2292
+ right: 0;
2293
+ bottom: 0;
2294
+ width: 14px;
2295
+ height: 14px;
2296
+ cursor: ew-resize;
2297
+ background: linear-gradient(135deg, transparent 50%, var(--mm-accent) 50%);
2298
+ opacity: 0.85;
2299
+ }
2300
+ .row-image-frame:hover .row-image-handle {
2301
+ opacity: 1;
2302
+ }
2303
+ .outline-row.is-selected {
2304
+ background: var(--mm-selected-bg);
2305
+ border-radius: 4px;
2306
+ }
2307
+ .outline-row:hover:not(.is-selected) {
2308
+ background: transparent;
2309
+ }
2310
+ .outline-row.is-matched .row-input {
2311
+ background: rgb(255 213 79 / 0.35);
2312
+ border-radius: 3px;
2313
+ }
2314
+ .row-indents {
2315
+ position: absolute;
2316
+ left: 0;
2317
+ top: 0;
2318
+ bottom: 0;
2319
+ z-index: 0;
2320
+ pointer-events: none;
2321
+ }
2322
+ .row-gutter {
2323
+ position: relative;
2324
+ flex: none;
2325
+ height: 32px;
2326
+ cursor: grab;
2327
+ }
2328
+ .indent-guide {
2329
+ position: absolute;
2330
+ top: 0;
2331
+ bottom: 0;
2332
+ width: 0;
2333
+ border-left: 1px solid color-mix(in srgb, var(--mm-text-dim) 70%, transparent);
2334
+ pointer-events: none;
2335
+ }
2336
+ .indent-guide.is-drop-highlight {
2337
+ border-left-width: 2px;
2338
+ border-left-color: var(--mm-accent);
2339
+ }
2340
+ .outline-row.is-drag-source {
2341
+ opacity: 0.45;
2342
+ }
2343
+ .outline-row.is-drag-source .row-input {
2344
+ pointer-events: none;
2345
+ }
2346
+ .drop-line {
2347
+ position: absolute;
2348
+ height: 2px;
2349
+ border-radius: 1px;
2350
+ background: var(--mm-accent);
2351
+ pointer-events: none;
2352
+ z-index: 10;
2353
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--mm-accent) 35%, transparent);
2354
+ }
2355
+ .outline-drag-widget {
2356
+ position: fixed;
2357
+ z-index: 40;
2358
+ width: 22px;
2359
+ height: 22px;
2360
+ margin: -11px 0 0 -11px;
2361
+ display: flex;
2362
+ align-items: center;
2363
+ justify-content: center;
2364
+ pointer-events: none;
2365
+ border-radius: 4px;
2366
+ background: color-mix(in srgb, var(--mm-panel-bg) 85%, var(--mm-accent));
2367
+ box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
2368
+ }
2369
+ .outline-drag-widget .bullet-dot {
2370
+ width: 6px;
2371
+ height: 6px;
2372
+ border-radius: 50%;
2373
+ background: var(--mm-accent);
2374
+ }
2375
+ .row-collapse {
2376
+ position: absolute;
2377
+ top: 9px;
2378
+ width: 18px;
2379
+ height: 14px;
2380
+ padding: 0;
2381
+ border: none;
2382
+ background: transparent;
2383
+ cursor: pointer;
2384
+ /* Always show for parents — hover-only left an empty gutter that looked
2385
+ like a missing drag handle (dots stay on the bullet column). */
2386
+ opacity: 1;
2387
+ z-index: 1;
2388
+ }
2389
+ .row-collapse.visible,
2390
+ .outline-row:hover .row-collapse {
2391
+ opacity: 1;
2392
+ }
2393
+ .row-collapse::before {
2394
+ content: '';
2395
+ position: absolute;
2396
+ left: 5px;
2397
+ top: 3px;
2398
+ border-style: solid;
2399
+ border-width: 4px 0 4px 6px;
2400
+ border-color: transparent transparent transparent var(--mm-text-dim);
2401
+ transform: rotate(90deg);
2402
+ transform-origin: 3px 4px;
2403
+ transition: transform 0.12s;
2404
+ }
2405
+ .row-collapse.collapsed::before {
2406
+ transform: rotate(0deg);
2407
+ }
2408
+ .row-bullet {
2409
+ position: absolute;
2410
+ top: 5px;
2411
+ display: inline-flex;
2412
+ align-items: center;
2413
+ justify-content: center;
2414
+ gap: 2px;
2415
+ width: 22px;
2416
+ height: 22px;
2417
+ border-radius: 4px;
2418
+ cursor: pointer;
2419
+ color: var(--mm-text-dim);
2420
+ font-size: 11px;
2421
+ z-index: 1;
2422
+ }
2423
+ .row-bullet:hover {
2424
+ background: color-mix(in srgb, var(--mm-text-dim) 22%, transparent);
2425
+ }
2426
+ .bullet-dot {
2427
+ display: block;
2428
+ flex: none;
2429
+ width: 6px;
2430
+ height: 6px;
2431
+ border-radius: 50%;
2432
+ background: var(--mm-text-dim, #9aa0a6);
2433
+ }
2434
+ .row-bullet.has-children .bullet-dot {
2435
+ background: var(--mm-text, #e8eaed);
2436
+ }
2437
+ .bullet-count {
2438
+ font-size: 10px;
2439
+ color: var(--mm-text-dim);
2440
+ line-height: 1;
2441
+ }
2442
+ .row-checkbox {
2443
+ display: inline-flex;
2444
+ width: 17px;
2445
+ height: 17px;
2446
+ flex: none;
2447
+ align-items: center;
2448
+ justify-content: center;
2449
+ margin: 7px 3px 0 0;
2450
+ padding: 0;
2451
+ border: 1.5px solid color-mix(in srgb, var(--mm-text-dim) 86%, transparent);
2452
+ border-radius: 4px;
2453
+ outline: 0;
2454
+ background: transparent;
2455
+ cursor: pointer;
2456
+ color: var(--mm-text-dim);
2457
+ transition: border-color .12s, background .12s, box-shadow .12s;
2458
+ }
2459
+ .row-checkbox.checked {
2460
+ border-color: var(--mm-accent);
2461
+ background: var(--mm-accent);
2462
+ color: white;
2463
+ }
2464
+ .row-checkbox:hover { border-color: var(--mm-accent); }
2465
+ .row-checkbox:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 28%, transparent); }
2466
+ .row-checkbox svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
2467
+ .row-input {
2468
+ flex: 1;
2469
+ min-width: 60px;
2470
+ border: none;
2471
+ outline: none;
2472
+ background: transparent;
2473
+ font-size: inherit;
2474
+ font-family: inherit;
2475
+ color: inherit;
2476
+ padding: 4px 2px;
2477
+ border-radius: 0;
2478
+ caret-color: var(--mm-text);
2479
+ resize: none;
2480
+ overflow: hidden;
2481
+ white-space: pre-wrap;
2482
+ word-break: break-word;
2483
+ line-height: 1.45;
2484
+ }
2485
+ .row-input:focus {
2486
+ background: transparent;
2487
+ box-shadow: none;
2488
+ }
2489
+ .row-input.is-view-mode { cursor: text; }
2490
+ .row-input.is-task-done {
2491
+ text-decoration: line-through;
2492
+ color: var(--mm-text-dim);
2493
+ }
2494
+ </style>