@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,1210 @@
1
+ <script setup lang="ts">
2
+ import { CanvasEditor, CanvasViewer, MindmapSession } from '@tnotesjs/mindmap-core'
3
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
4
+
5
+ import { applyInitialExpandLevel, normalizeExpandLevel } from './expandLevel'
6
+ import { normalizeMindmapMarkdown } from './markdown'
7
+ import MindmapOutlineNode from './MindmapOutlineNode.vue'
8
+ import MindmapViewIcon from './MindmapViewIcon.vue'
9
+ import FocusBreadcrumbs from './FocusBreadcrumbs.vue'
10
+ import MindmapCanvasEditor from './editor/MindmapView.vue'
11
+ import MindmapOutlineEditor from './editor/OutlineView.vue'
12
+ import MindmapMarkdownEditor from './editor/MarkdownView.vue'
13
+ import { insertImageIntoSource } from './editor/imagePaste'
14
+ import { gateMindmapWheel } from './wheelInteraction'
15
+ import iconFullscreen from './icons/icon__fullscreen.svg?url'
16
+ import iconFullscreenExit from './icons/icon__fullscreen_exit.svg?url'
17
+ import iconZoomFit from './icons/icon__zoom_fit.svg?url'
18
+
19
+ type PreviewView = 'mindmap' | 'outline' | 'source'
20
+
21
+ /** One fullscreen mindmap at a time across preview instances on the page. */
22
+ const FS_GATE_KEY = '__tnotesjs_mindmap_fullscreen_gate__'
23
+ const FS_BODY_ATTR = 'tnMindmapFs'
24
+ const FORCE_EXIT_FULLSCREEN_EVENT = 'tnotes-mindmap-force-exit-fullscreen'
25
+
26
+ type FsGateState = { owners: Map<symbol, () => void> }
27
+
28
+ function fsGateState(): FsGateState {
29
+ const g = globalThis as typeof globalThis & { [FS_GATE_KEY]?: FsGateState }
30
+ if (!g[FS_GATE_KEY]) g[FS_GATE_KEY] = { owners: new Map() }
31
+ return g[FS_GATE_KEY]
32
+ }
33
+
34
+ function registerMindmapFullscreenOwner(id: symbol, exit: () => void): () => void {
35
+ const { owners } = fsGateState()
36
+ owners.set(id, exit)
37
+ return () => {
38
+ owners.delete(id)
39
+ }
40
+ }
41
+
42
+ function claimMindmapFullscreen(id: symbol): void {
43
+ for (const [otherId, exit] of [...fsGateState().owners]) {
44
+ if (otherId === id) continue
45
+ try {
46
+ exit()
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
51
+ }
52
+
53
+ function syncBodyFullscreenAttr(activeRoot: HTMLElement | null): void {
54
+ if (typeof document === 'undefined') return
55
+ if (activeRoot?.classList.contains('is-fullscreen')) {
56
+ document.body.dataset[FS_BODY_ATTR] = '1'
57
+ document.documentElement.dataset[FS_BODY_ATTR] = '1'
58
+ return
59
+ }
60
+ if (!document.querySelector('.mindmap-preview.is-fullscreen')) {
61
+ delete document.body.dataset[FS_BODY_ATTR]
62
+ delete document.documentElement.dataset[FS_BODY_ATTR]
63
+ }
64
+ }
65
+
66
+ function forceExitPeerFullscreen(activeRoot: HTMLElement | null): void {
67
+ if (typeof document === 'undefined') return
68
+ for (const el of document.querySelectorAll('.mindmap-preview')) {
69
+ if (!(el instanceof HTMLElement) || el === activeRoot) continue
70
+ el.dispatchEvent(new CustomEvent(FORCE_EXIT_FULLSCREEN_EVENT))
71
+ el.classList.remove('is-fullscreen', 'is-interaction-active')
72
+ }
73
+ syncBodyFullscreenAttr(activeRoot)
74
+ }
75
+
76
+ const props = withDefaults(defineProps<{
77
+ content?: string
78
+ /** Plain mindmap markdown (preferred over URI-encoded content). */
79
+ source?: string
80
+ initialExpandLevel?: number
81
+ /** When true, canvas uses CanvasEditor and edits emit `change`. */
82
+ editable?: boolean
83
+ /** Show expand-level control on the chrome (Desk). */
84
+ expandLevelControl?: boolean
85
+ /** Explicit dark mode; omit to auto-detect html.dark / data-theme=dark. */
86
+ isDark?: boolean
87
+ /**
88
+ * Resolve markdown image paths for canvas display (Desk: tnotes-asset protocol).
89
+ * Defaults to identity.
90
+ */
91
+ resolveImageSrc?: (src: string) => string
92
+ /**
93
+ * Persist a pasted/dropped image blob and return the markdown-relative path
94
+ * (e.g. `./assets/foo.png`). Required for editable paste-to-assets.
95
+ */
96
+ writeAsset?: (blob: Blob) => Promise<{ relativePath: string; alt?: string }>
97
+ }>(), {
98
+ content: '',
99
+ source: '',
100
+ initialExpandLevel: 3,
101
+ editable: false,
102
+ expandLevelControl: false,
103
+ isDark: undefined,
104
+ resolveImageSrc: undefined,
105
+ writeAsset: undefined,
106
+ })
107
+
108
+ const emit = defineEmits<{
109
+ change: [markdown: string]
110
+ expandLevelChange: [level: number]
111
+ }>()
112
+
113
+ function detectDark(): boolean {
114
+ if (typeof props.isDark === 'boolean') return props.isDark
115
+ const root = document.documentElement
116
+ return root.classList.contains('dark') || root.dataset.theme === 'dark'
117
+ }
118
+
119
+ const dark = ref(detectDark())
120
+ const activeView = ref<PreviewView>('mindmap')
121
+ const previewRoot = ref<HTMLElement | null>(null)
122
+ const canvasHost = ref<HTMLElement | null>(null)
123
+ const outlineEditorRef = ref<{
124
+ selectAllFromHost?: () => void
125
+ undoFromHost?: () => void
126
+ redoFromHost?: () => void
127
+ } | null>(null)
128
+ const sourceEditorRef = ref<{ selectAllFromHost?: () => void } | null>(null)
129
+ const session = shallowRef<MindmapSession | null>(null)
130
+ const renderVersion = ref(0)
131
+ const isFullscreen = ref(false)
132
+ /** How fullscreen was entered — Electron usually needs the CSS overlay path. */
133
+ let fullscreenMode: 'native' | 'css' | null = null
134
+ /** Stable id for exclusive fullscreen coordination across preview instances. */
135
+ const fullscreenOwnerId = Symbol('mindmap-fullscreen')
136
+ let unregisterFullscreenOwner: (() => void) | null = null
137
+ const isCanvasActive = ref(false)
138
+ const expandLevel = ref(normalizeExpandLevel(props.initialExpandLevel))
139
+ /** Readonly CanvasViewer host; editable mode uses MindmapCanvasEditor instead. */
140
+ let viewer: CanvasViewer | null = null
141
+ let editorRef: CanvasEditor | null = null
142
+ let mounted = false
143
+ let suppressChangeEmit = false
144
+ const sessionEpoch = ref(0)
145
+
146
+ const viewOptions = [
147
+ { value: 'mindmap', label: '脑图' },
148
+ { value: 'outline', label: '大纲' },
149
+ { value: 'source', label: '源码' },
150
+ ] as const
151
+
152
+ function decodeContent(value: string): string {
153
+ try {
154
+ return decodeURIComponent(value)
155
+ } catch {
156
+ return value
157
+ }
158
+ }
159
+
160
+ const rawContent = computed(() => props.source || decodeContent(props.content || ''))
161
+ const normalizedContent = computed(() => normalizeMindmapMarkdown(rawContent.value))
162
+
163
+ function destroyViewer(): void {
164
+ viewer?.destroy()
165
+ viewer = null
166
+ }
167
+
168
+ async function handlePasteImage(anchorId: string, blob: Blob): Promise<void> {
169
+ if (!props.editable || !session.value || !props.writeAsset) return
170
+ try {
171
+ const asset = await props.writeAsset(blob)
172
+ const prepared = session.value.prepareImageInsertion(
173
+ anchorId,
174
+ asset.relativePath,
175
+ asset.alt ?? '截图'
176
+ )
177
+ if (!prepared) return
178
+ prepared.commit()
179
+ } catch {
180
+ /* Host surfaces upload errors; keep canvas usable. */
181
+ }
182
+ }
183
+
184
+ function createViewer(): void {
185
+ // Editable canvas is owned by MindmapCanvasEditor (toolbar / context menu).
186
+ if (props.editable) return
187
+ if (!mounted || !canvasHost.value || !session.value || viewer) return
188
+ const theme = dark.value ? 'dark' : 'light'
189
+ viewer = new CanvasViewer(canvasHost.value, session.value, {
190
+ theme,
191
+ resolveImageSrc: (src) => props.resolveImageSrc?.(src) ?? src,
192
+ })
193
+ }
194
+
195
+ function onEditorReady(editor: CanvasEditor): void {
196
+ editorRef = editor
197
+ editor.setTheme(dark.value ? 'dark' : 'light')
198
+ if (activeView.value === 'mindmap') {
199
+ requestAnimationFrame(() => editor.zoomToFit())
200
+ }
201
+ }
202
+
203
+ function resolveImageSrcProp(src: string): string {
204
+ return props.resolveImageSrc?.(src) ?? src
205
+ }
206
+
207
+ function rebuildSession(): void {
208
+ destroyViewer()
209
+ editorRef = null
210
+ suppressChangeEmit = true
211
+ const next = new MindmapSession({
212
+ markdown: normalizedContent.value,
213
+ fileName: 'mindmap-preview.tn-mindmap.md',
214
+ })
215
+ applyInitialExpandLevel(next, expandLevel.value)
216
+ const invalidate = () => { renderVersion.value += 1 }
217
+ next.on('collapseChange', invalidate)
218
+ next.on('focusChange', invalidate)
219
+ next.on('selectionChange', invalidate)
220
+ next.on('change', (markdown) => {
221
+ invalidate()
222
+ if (suppressChangeEmit || !props.editable) return
223
+ emit('change', markdown)
224
+ })
225
+ session.value = next
226
+ sessionEpoch.value += 1
227
+ renderVersion.value += 1
228
+ void nextTick(() => {
229
+ createViewer()
230
+ suppressChangeEmit = false
231
+ })
232
+ }
233
+
234
+ function onSourceMarkdown(value: string): void {
235
+ if (!session.value || !props.editable) return
236
+ if (value === session.value.getMarkdown()) return
237
+ session.value.setMarkdown(value)
238
+ }
239
+
240
+ async function onSourcePasteImage(
241
+ blob: Blob,
242
+ selectionStart: number,
243
+ selectionEnd: number,
244
+ ): Promise<void> {
245
+ if (!props.editable || !session.value || !props.writeAsset) return
246
+ try {
247
+ const asset = await props.writeAsset(blob)
248
+ const next = insertImageIntoSource(
249
+ session.value.getMarkdown(),
250
+ selectionStart,
251
+ selectionEnd,
252
+ asset.relativePath,
253
+ asset.alt ?? '截图',
254
+ )
255
+ onSourceMarkdown(next)
256
+ } catch {
257
+ /* Host surfaces upload errors. */
258
+ }
259
+ }
260
+
261
+ function setExpandLevel(raw: number): void {
262
+ const level = normalizeExpandLevel(raw)
263
+ if (level === expandLevel.value) return
264
+ expandLevel.value = level
265
+ if (session.value) applyInitialExpandLevel(session.value, level)
266
+ renderVersion.value += 1
267
+ emit('expandLevelChange', level)
268
+ }
269
+
270
+ function onExpandLevelInput(event: Event): void {
271
+ const value = Number((event.target as HTMLInputElement).value)
272
+ setExpandLevel(value)
273
+ }
274
+
275
+ function setView(view: PreviewView): void {
276
+ if (activeView.value === view) return
277
+ if (activeView.value === 'mindmap' && view !== 'mindmap') {
278
+ editorRef = null
279
+ destroyViewer()
280
+ }
281
+ activeView.value = view
282
+ if (view !== 'mindmap') isCanvasActive.value = false
283
+ if (view === 'mindmap') {
284
+ // Host remounts via v-if; recreate readonly viewer then fit.
285
+ void nextTick(() => {
286
+ createViewer()
287
+ zoomToFit()
288
+ requestAnimationFrame(() => zoomToFit())
289
+ window.setTimeout(() => zoomToFit(), 80)
290
+ })
291
+ }
292
+ }
293
+
294
+ /** Switch on pointerdown so Desk/PM cannot steal the gesture before click. */
295
+ function onViewTabPointerDown(view: PreviewView, event: PointerEvent): void {
296
+ event.stopPropagation()
297
+ setView(view)
298
+ }
299
+
300
+ function toggleNode(id: string): void {
301
+ session.value?.toggleCollapse(id)
302
+ }
303
+
304
+ function exitFullscreenOverlay(): void {
305
+ // no-op when idle — claimMindmapFullscreen() invokes exit on peers only.
306
+ if (!isFullscreen.value && fullscreenMode == null) return
307
+
308
+ const root = previewRoot.value
309
+ if (fullscreenMode === 'native' && root && document.fullscreenElement === root) {
310
+ void document.exitFullscreen().catch(() => {
311
+ /* ignore */
312
+ })
313
+ }
314
+ fullscreenMode = null
315
+ isFullscreen.value = false
316
+ root?.classList.remove('is-fullscreen', 'is-interaction-active')
317
+ syncBodyFullscreenAttr(null)
318
+ }
319
+
320
+ /** Peer claim / DOM scrub — clear Vue state so is-fullscreen does not come back. */
321
+ function onForceExitFullscreen(): void {
322
+ fullscreenMode = null
323
+ isFullscreen.value = false
324
+ previewRoot.value?.classList.remove('is-fullscreen', 'is-interaction-active')
325
+ }
326
+
327
+ async function toggleFullscreen(): Promise<void> {
328
+ const root = previewRoot.value
329
+ if (!root) return
330
+
331
+ if (isFullscreen.value) {
332
+ exitFullscreenOverlay()
333
+ void nextTick(() => zoomToFit())
334
+ return
335
+ }
336
+
337
+ claimMindmapFullscreen(fullscreenOwnerId)
338
+ forceExitPeerFullscreen(null)
339
+
340
+ // CSS overlay first — Electron often rejects/hangs on requestFullscreen.
341
+ fullscreenMode = 'css'
342
+ isFullscreen.value = true
343
+ root.classList.add('is-fullscreen')
344
+ forceExitPeerFullscreen(root)
345
+ void nextTick(() => zoomToFit())
346
+
347
+ // Best-effort native fullscreen for browser / VitePress (non-blocking).
348
+ if (!document.fullscreenEnabled || typeof root.requestFullscreen !== 'function') return
349
+ if (/Electron/i.test(navigator.userAgent)) return
350
+ try {
351
+ await Promise.race([
352
+ root.requestFullscreen(),
353
+ new Promise<never>((_, reject) => {
354
+ window.setTimeout(() => reject(new Error('fullscreen-timeout')), 500)
355
+ }),
356
+ ])
357
+ if (document.fullscreenElement === root) fullscreenMode = 'native'
358
+ } catch {
359
+ /* keep CSS overlay */
360
+ }
361
+ }
362
+
363
+ function handleFullscreenChange(): void {
364
+ const root = previewRoot.value
365
+ if (root && document.fullscreenElement === root) {
366
+ claimMindmapFullscreen(fullscreenOwnerId)
367
+ fullscreenMode = 'native'
368
+ isFullscreen.value = true
369
+ root.classList.add('is-fullscreen')
370
+ forceExitPeerFullscreen(root)
371
+ } else if (fullscreenMode === 'native') {
372
+ fullscreenMode = null
373
+ isFullscreen.value = false
374
+ syncBodyFullscreenAttr(null)
375
+ }
376
+ if (activeView.value === 'mindmap') void nextTick(() => zoomToFit())
377
+ }
378
+
379
+ function zoomToFit(): void {
380
+ editorRef?.zoomToFit()
381
+ viewer?.zoomToFit()
382
+ }
383
+
384
+ function activateCanvas(): void {
385
+ isCanvasActive.value = true
386
+ if (!props.editable) return
387
+ const editorEl = canvasHost.value?.querySelector<HTMLElement>('.mm-editor')
388
+ editorEl?.focus({ preventScroll: true })
389
+ }
390
+
391
+ function handleCanvasWheelCapture(event: WheelEvent): void {
392
+ gateMindmapWheel(event, isCanvasActive.value)
393
+ }
394
+
395
+ /** After the canvas handles paste, stop bubbling into Milkdown's uploader. */
396
+ function handlePreviewPasteBubble(event: ClipboardEvent): void {
397
+ if (!props.editable) return
398
+ const hasImage = [...(event.clipboardData?.items ?? [])].some(
399
+ (item) => item.kind === 'file' && item.type.startsWith('image/'),
400
+ )
401
+ if (!hasImage) return
402
+ event.stopPropagation()
403
+ }
404
+
405
+ function handleDocumentPointerDown(event: PointerEvent): void {
406
+ if (event.target instanceof Node && !previewRoot.value?.contains(event.target)) {
407
+ isCanvasActive.value = false
408
+ }
409
+ }
410
+
411
+ function mindmapIslandOwnsKeyboard(): boolean {
412
+ const root = previewRoot.value
413
+ if (!root || !props.editable) return false
414
+ const active = document.activeElement
415
+ const activeInside = active instanceof Node && root.contains(active)
416
+ const hostBlock = root.closest('.desk-raw-block--mindmap')
417
+ return (
418
+ isCanvasActive.value ||
419
+ activeInside ||
420
+ root.matches(':focus-within') ||
421
+ Boolean(hostBlock?.classList.contains('is-mindmap-island-active')) ||
422
+ Boolean(hostBlock?.classList.contains('ProseMirror-selectednode'))
423
+ )
424
+ }
425
+
426
+ function handleDocumentKeydown(event: KeyboardEvent): void {
427
+ if (event.key === 'Escape' && isFullscreen.value && fullscreenMode === 'css') {
428
+ event.preventDefault()
429
+ exitFullscreenOverlay()
430
+ void nextTick(() => zoomToFit())
431
+ return
432
+ }
433
+
434
+ // Keep Mod+A / Mod+Z / Mod+Y / Mod+C / Mod+X / Mod+V inside the mindmap island.
435
+ if (props.editable && (event.metaKey || event.ctrlKey) && !event.altKey) {
436
+ const key = event.key.toLowerCase()
437
+ const isSelectAll = key === 'a' && !event.shiftKey
438
+ const isUndo = key === 'z' && !event.shiftKey
439
+ const isRedo = key === 'y' || (key === 'z' && event.shiftKey)
440
+ const isCopy = key === 'c' && !event.shiftKey
441
+ const isCut = key === 'x' && !event.shiftKey
442
+ const isPaste = key === 'v' && !event.shiftKey
443
+ if (
444
+ (isSelectAll || isUndo || isRedo || isCopy || isCut || isPaste) &&
445
+ mindmapIslandOwnsKeyboard()
446
+ ) {
447
+ const root = previewRoot.value
448
+ const target = event.target
449
+
450
+ // Source view textarea: keep native clipboard / history; only block ProseMirror.
451
+ if (activeView.value === 'source' && (isUndo || isRedo || isCopy || isCut || isPaste)) {
452
+ const inSource =
453
+ target instanceof Element
454
+ ? target.closest('.md-textarea, textarea, .markdown-view')
455
+ : target instanceof Node
456
+ ? (target.parentElement?.closest('.md-textarea, textarea, .markdown-view') ?? null)
457
+ : null
458
+ if (inSource && root?.contains(inSource)) {
459
+ event.stopPropagation()
460
+ return
461
+ }
462
+ }
463
+
464
+ // Outline / canvas text editing: keep native clipboard into the caret.
465
+ if (isCopy || isCut || isPaste) {
466
+ const inTextEdit =
467
+ target instanceof Element
468
+ ? target.closest(
469
+ '.mm-edit-input, .rich-inline-editor, [contenteditable="true"], textarea, input',
470
+ )
471
+ : null
472
+ if (inTextEdit && root?.contains(inTextEdit)) return
473
+ if (activeView.value !== 'mindmap') return
474
+ event.preventDefault()
475
+ event.stopPropagation()
476
+ if (isCopy) editorRef?.copyFromHost?.()
477
+ else if (isCut) editorRef?.cutFromHost?.()
478
+ else editorRef?.pasteFromHost?.()
479
+ isCanvasActive.value = true
480
+ return
481
+ }
482
+
483
+ event.preventDefault()
484
+ event.stopPropagation()
485
+
486
+ if (isSelectAll) {
487
+ if (activeView.value === 'outline') {
488
+ outlineEditorRef.value?.selectAllFromHost?.()
489
+ } else if (activeView.value === 'source') {
490
+ sourceEditorRef.value?.selectAllFromHost?.()
491
+ } else {
492
+ editorRef?.selectAllFromHost?.()
493
+ isCanvasActive.value = true
494
+ }
495
+ return
496
+ }
497
+
498
+ if (activeView.value === 'outline') {
499
+ if (isUndo) outlineEditorRef.value?.undoFromHost?.()
500
+ else outlineEditorRef.value?.redoFromHost?.()
501
+ } else if (activeView.value === 'source') {
502
+ if (isUndo) session.value?.undo()
503
+ else session.value?.redo()
504
+ } else if (isUndo) {
505
+ if (typeof editorRef?.undoFromHost === 'function') editorRef.undoFromHost()
506
+ else session.value?.undo()
507
+ isCanvasActive.value = true
508
+ } else {
509
+ if (typeof editorRef?.redoFromHost === 'function') editorRef.redoFromHost()
510
+ else session.value?.redo()
511
+ isCanvasActive.value = true
512
+ }
513
+ return
514
+ }
515
+ }
516
+
517
+ if (event.key !== 'Escape' || !isCanvasActive.value) return
518
+ isCanvasActive.value = false
519
+ canvasHost.value?.blur()
520
+ }
521
+
522
+ watch(
523
+ normalizedContent,
524
+ (value) => {
525
+ if (
526
+ session.value &&
527
+ normalizeMindmapMarkdown(session.value.getMarkdown()) === value
528
+ ) {
529
+ return
530
+ }
531
+ rebuildSession()
532
+ },
533
+ { immediate: true },
534
+ )
535
+ watch(
536
+ () => props.initialExpandLevel,
537
+ (value) => {
538
+ const level = normalizeExpandLevel(value)
539
+ if (level === expandLevel.value) return
540
+ expandLevel.value = level
541
+ if (session.value) {
542
+ applyInitialExpandLevel(session.value, level)
543
+ renderVersion.value += 1
544
+ }
545
+ },
546
+ )
547
+ watch(
548
+ () => props.editable,
549
+ () => {
550
+ destroyViewer()
551
+ editorRef = null
552
+ void nextTick(createViewer)
553
+ },
554
+ )
555
+ watch(dark, (value) => {
556
+ const theme = value ? 'dark' : 'light'
557
+ viewer?.setTheme(theme)
558
+ editorRef?.setTheme(theme)
559
+ })
560
+
561
+ function observeTheme() {
562
+ const observer = new MutationObserver(() => {
563
+ const next = detectDark()
564
+ if (next !== dark.value) dark.value = next
565
+ })
566
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
567
+ return observer
568
+ }
569
+ let themeObserver: MutationObserver | null = null
570
+
571
+ onMounted(() => {
572
+ mounted = true
573
+ themeObserver = observeTheme()
574
+ unregisterFullscreenOwner = registerMindmapFullscreenOwner(
575
+ fullscreenOwnerId,
576
+ exitFullscreenOverlay,
577
+ )
578
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
579
+ document.addEventListener('pointerdown', handleDocumentPointerDown, true)
580
+ document.addEventListener('keydown', handleDocumentKeydown, true)
581
+ void nextTick(() => {
582
+ previewRoot.value?.addEventListener(FORCE_EXIT_FULLSCREEN_EVENT, onForceExitFullscreen)
583
+ })
584
+ createViewer()
585
+ })
586
+
587
+ onBeforeUnmount(() => {
588
+ mounted = false
589
+ exitFullscreenOverlay()
590
+ unregisterFullscreenOwner?.()
591
+ unregisterFullscreenOwner = null
592
+ themeObserver?.disconnect()
593
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
594
+ document.removeEventListener('pointerdown', handleDocumentPointerDown, true)
595
+ document.removeEventListener('keydown', handleDocumentKeydown, true)
596
+ previewRoot.value?.removeEventListener(FORCE_EXIT_FULLSCREEN_EVENT, onForceExitFullscreen)
597
+ destroyViewer()
598
+ })
599
+ </script>
600
+
601
+ <template>
602
+ <section
603
+ ref="previewRoot"
604
+ class="mindmap-preview"
605
+ :class="{
606
+ 'is-dark': dark,
607
+ 'is-fullscreen': isFullscreen,
608
+ 'is-editable': editable,
609
+ 'is-interaction-active': isCanvasActive,
610
+ }"
611
+ :data-view="activeView"
612
+ :data-version="renderVersion"
613
+ @paste="handlePreviewPasteBubble"
614
+ >
615
+ <div class="mindmap-preview-actions">
616
+ <nav class="mindmap-preview-tabs" aria-label="脑图预览视图">
617
+ <button
618
+ v-for="item in viewOptions"
619
+ :key="item.value"
620
+ type="button"
621
+ class="mindmap-preview-action"
622
+ :class="{ 'is-active': activeView === item.value }"
623
+ :data-view-tab="item.value"
624
+ :aria-label="item.label"
625
+ :aria-pressed="activeView === item.value"
626
+ :title="item.label"
627
+ @pointerdown="onViewTabPointerDown(item.value, $event)"
628
+ >
629
+ <MindmapViewIcon :view="item.value" />
630
+ </button>
631
+ </nav>
632
+ <span class="mindmap-preview-action-divider" aria-hidden="true" />
633
+ <label
634
+ v-if="expandLevelControl"
635
+ class="mindmap-preview-expand"
636
+ title="默认展开层级"
637
+ >
638
+ <span class="mindmap-preview-expand-label">层</span>
639
+ <input
640
+ class="mindmap-preview-expand-input"
641
+ type="number"
642
+ min="1"
643
+ max="20"
644
+ :value="expandLevel"
645
+ aria-label="默认展开层级"
646
+ @change="onExpandLevelInput"
647
+ />
648
+ </label>
649
+ <button
650
+ v-if="activeView === 'mindmap'"
651
+ type="button"
652
+ class="mindmap-preview-action"
653
+ aria-label="适应视口"
654
+ title="适应视口"
655
+ @click="zoomToFit"
656
+ >
657
+ <img :src="iconZoomFit" alt="" />
658
+ </button>
659
+ <button
660
+ type="button"
661
+ class="mindmap-preview-action"
662
+ :aria-label="isFullscreen ? '退出全屏' : '全屏查看'"
663
+ :title="isFullscreen ? '退出全屏' : '全屏查看'"
664
+ @click="toggleFullscreen"
665
+ >
666
+ <img :src="isFullscreen ? iconFullscreenExit : iconFullscreen" alt="" />
667
+ </button>
668
+ </div>
669
+
670
+ <FocusBreadcrumbs
671
+ v-if="session && session.focusPath.length > 0"
672
+ :session="session"
673
+ :version="renderVersion"
674
+ />
675
+
676
+ <!-- Exclusive panes: destroy canvas so it cannot linger over outline/source. -->
677
+ <div
678
+ v-if="activeView === 'mindmap'"
679
+ ref="canvasHost"
680
+ class="mindmap-canvas-host mindmap-pane"
681
+ :class="{ 'is-interaction-active': isCanvasActive }"
682
+ @pointerdown.capture="activateCanvas"
683
+ @wheel.capture="handleCanvasWheelCapture"
684
+ >
685
+ <MindmapCanvasEditor
686
+ v-if="editable && session"
687
+ :key="sessionEpoch"
688
+ :session="session"
689
+ :resolve-image-src="resolveImageSrcProp"
690
+ @ready="onEditorReady"
691
+ @paste-image="handlePasteImage"
692
+ />
693
+ </div>
694
+
695
+ <div
696
+ v-else-if="activeView === 'outline' && session"
697
+ class="mindmap-outline mindmap-pane"
698
+ :class="{ 'is-editable': editable }"
699
+ :data-version="renderVersion"
700
+ >
701
+ <MindmapOutlineEditor
702
+ v-if="editable"
703
+ ref="outlineEditorRef"
704
+ :session="session"
705
+ :version="renderVersion"
706
+ :resolve-image-src="resolveImageSrcProp"
707
+ @paste-image="handlePasteImage"
708
+ />
709
+ <ul v-else class="mindmap-outline-root">
710
+ <MindmapOutlineNode
711
+ :node="session.focusRootNode"
712
+ :version="renderVersion"
713
+ root
714
+ @toggle="toggleNode"
715
+ />
716
+ </ul>
717
+ </div>
718
+
719
+ <div
720
+ v-else-if="activeView === 'source'"
721
+ class="mindmap-source-wrap mindmap-pane"
722
+ :class="{ 'is-editable': editable }"
723
+ >
724
+ <MindmapMarkdownEditor
725
+ v-if="editable && session"
726
+ ref="sourceEditorRef"
727
+ :model-value="session.getMarkdown()"
728
+ :diagnostics="session.diagnostics"
729
+ @update:model-value="onSourceMarkdown"
730
+ @paste-image="onSourcePasteImage"
731
+ />
732
+ <pre v-else class="mindmap-source"><code>{{ normalizedContent }}</code></pre>
733
+ </div>
734
+ </section>
735
+ </template>
736
+
737
+ <style scoped lang="scss">
738
+ .mindmap-preview {
739
+ --mindmap-panel: var(--tn-c-bg-soft);
740
+ --mindmap-border: var(--tn-c-divider);
741
+ position: relative;
742
+ margin: 1.5rem 0;
743
+ overflow: hidden;
744
+ border: 1px solid var(--mindmap-border);
745
+ border-radius: 10px;
746
+ background: var(--tn-c-bg);
747
+ }
748
+
749
+ .mindmap-preview.is-editable {
750
+ /* Shared with fixed chrome (toolbar / context menu) via inheritance. */
751
+ --mm-canvas-bg: var(--tn-c-bg);
752
+ --mm-panel-bg: var(--tn-c-bg-soft, var(--tn-c-bg));
753
+ --mm-text: var(--tn-c-text);
754
+ --mm-text-dim: var(--tn-c-text-2, var(--tn-c-text));
755
+ --mm-accent: var(--tn-c-brand);
756
+ --mm-border: var(--tn-c-divider, var(--mindmap-border));
757
+ --mm-hover: var(--tn-c-default-soft, color-mix(in srgb, var(--tn-c-text) 8%, transparent));
758
+ --mm-selected-bg: color-mix(in srgb, var(--tn-c-brand) 22%, transparent);
759
+ --mm-edit-bg: var(--tn-c-bg-elv, var(--tn-c-bg));
760
+ }
761
+
762
+ .mindmap-preview.is-editable.is-interaction-active {
763
+ outline: 1px solid color-mix(in srgb, var(--tn-c-brand, #3b82f6) 55%, transparent);
764
+ outline-offset: 0;
765
+ }
766
+
767
+ .mindmap-preview-actions {
768
+ position: absolute;
769
+ top: 8px;
770
+ right: 8px;
771
+ /* Above SelectionToolbar / LinkPopover (fixed ~120) so view tabs stay clickable. */
772
+ z-index: 200;
773
+ display: flex;
774
+ align-items: center;
775
+ gap: 4px;
776
+ padding: 2px;
777
+ border: .1px solid var(--tn-c-divider);
778
+ border-radius: 7px;
779
+ background-color: color-mix(in srgb, var(--tn-c-bg-elv) 92%, transparent);
780
+ box-shadow: var(--tn-shadow-2);
781
+ opacity: 0;
782
+ pointer-events: none;
783
+ transition: opacity .2s;
784
+ }
785
+
786
+ /* Editable Desk island: chrome must stay hittable. */
787
+ .mindmap-preview.is-editable > .mindmap-preview-actions {
788
+ opacity: 1;
789
+ pointer-events: auto;
790
+ }
791
+
792
+ .mindmap-preview-tabs {
793
+ display: flex;
794
+ gap: 4px;
795
+ }
796
+
797
+ .mindmap-preview:hover > .mindmap-preview-actions,
798
+ .mindmap-preview-actions:focus-within,
799
+ .mindmap-preview.is-fullscreen > .mindmap-preview-actions,
800
+ .mindmap-preview:fullscreen > .mindmap-preview-actions {
801
+ opacity: 1;
802
+ pointer-events: auto;
803
+ }
804
+
805
+ .mindmap-preview-action {
806
+ display: inline-flex;
807
+ align-items: center;
808
+ justify-content: center;
809
+ width: 32px;
810
+ height: 32px;
811
+ padding: 0;
812
+ border: 0;
813
+ border-radius: 6px;
814
+ background: transparent;
815
+ color: var(--tn-c-brand);
816
+ cursor: pointer;
817
+ transition: background-color .2s, transform .2s;
818
+
819
+ svg,
820
+ img {
821
+ width: 18px;
822
+ height: 18px;
823
+ pointer-events: none;
824
+ }
825
+
826
+ &:hover {
827
+ background-color: var(--tn-c-default-soft);
828
+ }
829
+
830
+ &.is-active {
831
+ background-color: color-mix(in srgb, var(--tn-c-brand) 28%, transparent);
832
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tn-c-brand) 55%, transparent);
833
+ }
834
+
835
+ &:hover { transform: scale(1.05); }
836
+ &:active { transform: scale(.95); }
837
+ }
838
+
839
+ .mindmap-preview-action-divider {
840
+ width: 1px;
841
+ height: 20px;
842
+ margin: 0 1px;
843
+ background: var(--tn-c-divider);
844
+ }
845
+
846
+ .mindmap-preview-expand {
847
+ display: inline-flex;
848
+ align-items: center;
849
+ gap: 4px;
850
+ height: 32px;
851
+ padding: 0 6px;
852
+ border-radius: 6px;
853
+ color: var(--tn-c-brand);
854
+ font-size: 12px;
855
+ cursor: default;
856
+ }
857
+
858
+ .mindmap-preview-expand-label {
859
+ opacity: 0.85;
860
+ }
861
+
862
+ .mindmap-preview-expand-input {
863
+ width: 2.4rem;
864
+ height: 22px;
865
+ padding: 0 4px;
866
+ border: 1px solid var(--tn-c-divider);
867
+ border-radius: 4px;
868
+ background: var(--tn-c-bg);
869
+ color: var(--tn-c-text);
870
+ font-size: 12px;
871
+ line-height: 22px;
872
+ }
873
+
874
+ .mindmap-canvas-host {
875
+ position: relative;
876
+ width: 100%;
877
+ height: 440px;
878
+ overflow: hidden;
879
+ background: var(--tn-c-bg);
880
+ touch-action: none;
881
+ user-select: none;
882
+
883
+ /* CanvasEditor overlay + chrome tokens (aligned with mindmap-web). */
884
+ --mm-canvas-bg: var(--tn-c-bg);
885
+ --mm-panel-bg: var(--tn-c-bg-soft, var(--tn-c-bg));
886
+ --mm-text: var(--tn-c-text);
887
+ --mm-text-dim: var(--tn-c-text-2, var(--tn-c-text));
888
+ --mm-accent: var(--tn-c-brand);
889
+ --mm-border: var(--tn-c-divider, var(--mindmap-border));
890
+ --mm-hover: var(--tn-c-default-soft, color-mix(in srgb, var(--tn-c-text) 8%, transparent));
891
+ --mm-edit-bg: var(--tn-c-bg-elv, var(--tn-c-bg));
892
+
893
+ &.is-interaction-active {
894
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tn-c-brand) 38%, transparent);
895
+ }
896
+ }
897
+
898
+ .mindmap-canvas-host :deep(.mindmap-view-host) {
899
+ width: 100%;
900
+ height: 100%;
901
+ }
902
+
903
+ .mindmap-canvas-host:deep(.mm-editor) {
904
+ position: relative;
905
+ width: 100%;
906
+ height: 100%;
907
+ overflow: hidden;
908
+ outline: none;
909
+ }
910
+
911
+ .mindmap-canvas-host:deep(.mm-canvas),
912
+ .mindmap-canvas-host:deep(.mm-overlay) {
913
+ position: absolute;
914
+ inset: 0;
915
+ width: 100%;
916
+ height: 100%;
917
+ }
918
+
919
+ .mindmap-canvas-host:deep(.mm-overlay) {
920
+ pointer-events: none;
921
+ }
922
+
923
+ /* Marquee box — required for box-select feedback (aligned with mindmap-web). */
924
+ .mindmap-canvas-host:deep(.mm-selection-box) {
925
+ position: absolute;
926
+ z-index: 8;
927
+ pointer-events: none;
928
+ border: 1px solid color-mix(in srgb, var(--mm-accent) 88%, white);
929
+ border-radius: 3px;
930
+ background: color-mix(in srgb, var(--mm-accent) 16%, transparent);
931
+ }
932
+
933
+ .mindmap-canvas-host:deep(.mm-edit-input) {
934
+ position: absolute;
935
+ z-index: 10;
936
+ pointer-events: auto;
937
+ box-sizing: border-box;
938
+ padding: 4px 8px;
939
+ overflow: hidden;
940
+ border: 2px solid var(--mm-accent);
941
+ border-radius: 6px;
942
+ outline: none;
943
+ background: var(--mm-edit-bg);
944
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
945
+ color: var(--mm-text);
946
+ /* Desk ProseMirror sets caret-color: transparent for virtual cursor — restore. */
947
+ caret-color: var(--mm-accent, #3b82f6);
948
+ font-family: inherit;
949
+ line-height: 1.4;
950
+ white-space: pre-wrap;
951
+ word-break: normal;
952
+ overflow-wrap: anywhere;
953
+ resize: none;
954
+ }
955
+
956
+ .mindmap-canvas-host:deep(.mm-edit-input.is-root) {
957
+ border-radius: 9px;
958
+ background: #2b3139;
959
+ color: #fff;
960
+ }
961
+
962
+ .mindmap-canvas-host:deep(.mm-edit-input.is-primary) {
963
+ background: #e7eaf0;
964
+ color: #2b3139;
965
+ }
966
+
967
+ .mindmap-canvas-host:deep(.mm-edit-input.is-secondary),
968
+ .mindmap-canvas-host:deep(.mm-edit-input.is-tertiary) {
969
+ background: color-mix(in srgb, var(--mm-canvas-bg) 90%, var(--mm-accent) 10%);
970
+ }
971
+
972
+ .mindmap-preview.is-dark .mindmap-canvas-host:deep(.mm-edit-input.is-root) {
973
+ background: #dedede;
974
+ color: #1d1d1f;
975
+ }
976
+
977
+ .mindmap-preview.is-dark .mindmap-canvas-host:deep(.mm-edit-input.is-primary) {
978
+ background: #3b3b3d;
979
+ color: #f0f0f2;
980
+ }
981
+
982
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.bold) { font-weight: 700; }
983
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.italic) { font-style: italic; }
984
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.underline) {
985
+ text-decoration-line: underline;
986
+ text-underline-offset: 3px;
987
+ }
988
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.strike) { text-decoration-line: line-through; }
989
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.underline.strike) {
990
+ text-decoration-line: underline line-through;
991
+ }
992
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.highlight:not(.code)) {
993
+ padding: 0 1px;
994
+ border-radius: 2px;
995
+ background: #fff36a;
996
+ color: #242424;
997
+ }
998
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.code) {
999
+ padding: 0 4px;
1000
+ border-radius: 4px;
1001
+ background: color-mix(in srgb, var(--mm-canvas-bg) 82%, var(--mm-text) 18%);
1002
+ color: var(--tn-c-danger, #e85d5d);
1003
+ font-family: var(--tn-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1004
+ font-size: 0.92em;
1005
+ }
1006
+ .mindmap-canvas-host:deep(.mm-edit-input .inline-run.link) {
1007
+ color: var(--mm-accent);
1008
+ text-decoration: underline;
1009
+ text-underline-offset: 3px;
1010
+ }
1011
+
1012
+ .mindmap-canvas-host:deep(.mm-edit-add-button) {
1013
+ position: absolute;
1014
+ z-index: 12;
1015
+ display: inline-flex;
1016
+ align-items: center;
1017
+ justify-content: center;
1018
+ width: 20px;
1019
+ height: 20px;
1020
+ padding: 0;
1021
+ border: 0;
1022
+ border-radius: 50%;
1023
+ outline: none;
1024
+ background: var(--mm-text);
1025
+ box-shadow: 0 0 0 2px var(--mm-canvas-bg);
1026
+ color: var(--mm-canvas-bg);
1027
+ font: 600 18px/1 sans-serif;
1028
+ pointer-events: auto;
1029
+ cursor: pointer;
1030
+ }
1031
+
1032
+ .mindmap-canvas-host:deep(.mm-edit-add-button:hover),
1033
+ .mindmap-canvas-host:deep(.mm-edit-add-button:focus-visible) {
1034
+ background: var(--mm-accent);
1035
+ color: #fff;
1036
+ }
1037
+
1038
+ .mindmap-outline {
1039
+ max-height: 560px;
1040
+ padding: 18px 22px 22px;
1041
+ overflow: auto;
1042
+ }
1043
+
1044
+ .mindmap-outline.is-editable {
1045
+ height: 440px;
1046
+ max-height: none;
1047
+ padding: 0;
1048
+ overflow: hidden;
1049
+ display: flex;
1050
+ flex-direction: column;
1051
+ }
1052
+
1053
+ .mindmap-source-wrap.is-editable {
1054
+ height: 440px;
1055
+ overflow: hidden;
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ }
1059
+
1060
+ .mindmap-outline.is-editable :deep(.outline-view),
1061
+ .mindmap-source-wrap.is-editable :deep(.markdown-view) {
1062
+ flex: 1 1 0;
1063
+ min-height: 0;
1064
+ height: 100%;
1065
+ }
1066
+
1067
+ .mindmap-outline-root,
1068
+ .mindmap-outline :deep(ul) {
1069
+ margin: 0;
1070
+ padding: 0;
1071
+ list-style: none;
1072
+ }
1073
+
1074
+ .mindmap-outline :deep(.mindmap-outline-children) {
1075
+ margin-left: 10px;
1076
+ padding-left: 19px;
1077
+ border-left: 1px solid var(--tn-c-divider);
1078
+ }
1079
+
1080
+ .mindmap-outline :deep(.mindmap-outline-row) {
1081
+ display: flex;
1082
+ align-items: flex-start;
1083
+ gap: 7px;
1084
+ min-height: 30px;
1085
+ padding: 3px 0;
1086
+ color: var(--tn-c-text);
1087
+ line-height: 24px;
1088
+ }
1089
+
1090
+ .mindmap-outline :deep(.mindmap-outline-toggle),
1091
+ .mindmap-outline :deep(.mindmap-outline-leaf) {
1092
+ flex: 0 0 18px;
1093
+ width: 18px;
1094
+ color: var(--tn-c-text-2);
1095
+ text-align: center;
1096
+ }
1097
+
1098
+ .mindmap-outline :deep(.mindmap-outline-toggle:hover) { color: var(--tn-c-brand); }
1099
+ .mindmap-outline :deep(.mindmap-outline-checkbox) { margin-top: 5px; }
1100
+ .mindmap-outline :deep(.mindmap-outline-label) { min-width: 0; overflow-wrap: anywhere; }
1101
+ .mindmap-outline :deep(.mindmap-outline-node.is-root > .mindmap-outline-row) { font-size: 18px; font-weight: 700; }
1102
+ .mindmap-outline :deep(.mindmap-outline-node.is-done > .mindmap-outline-row .mindmap-outline-label) { opacity: .58; text-decoration: line-through; }
1103
+ .mindmap-outline :deep(.mindmap-outline-image) { display: block; max-width: min(100%, 560px); max-height: 360px; margin: 5px 0 12px 25px; border-radius: 6px; }
1104
+ .mindmap-outline :deep(.is-bold) { font-weight: 700; }
1105
+ .mindmap-outline :deep(.is-italic) { font-style: italic; }
1106
+ .mindmap-outline :deep(.is-underline) { text-decoration: underline; }
1107
+ .mindmap-outline :deep(.is-strike) { text-decoration: line-through; }
1108
+ .mindmap-outline :deep(.is-highlight) { padding: 0 2px; border-radius: 2px; background: #ffe56b; color: #252525; }
1109
+ .mindmap-outline :deep(.is-code) { padding: 1px 5px; border-radius: 4px; background: var(--tn-c-bg-soft); color: var(--tn-c-danger); font-family: var(--tn-font-mono); }
1110
+ .mindmap-outline :deep(.is-link) { color: var(--tn-c-brand); text-decoration: underline; text-underline-offset: 3px; }
1111
+
1112
+ .mindmap-source {
1113
+ max-height: 560px;
1114
+ margin: 0;
1115
+ padding: 18px 22px;
1116
+ overflow: auto;
1117
+ border-radius: 0;
1118
+ background: var(--tn-c-bg-elv);
1119
+ color: var(--tn-c-text);
1120
+ font-size: 13px;
1121
+ line-height: 1.65;
1122
+ white-space: pre;
1123
+ }
1124
+
1125
+ .mindmap-preview:fullscreen,
1126
+ .mindmap-preview.is-fullscreen {
1127
+ /* Desk frameless titlebar is ~42px with -webkit-app-region:drag. */
1128
+ --mm-fs-chrome-top: 48px;
1129
+ position: fixed;
1130
+ inset: 0;
1131
+ z-index: 200000;
1132
+ display: flex;
1133
+ box-sizing: border-box;
1134
+ width: 100%;
1135
+ height: 100%;
1136
+ margin: 0;
1137
+ padding-top: var(--mm-fs-chrome-top);
1138
+ border: 0;
1139
+ border-radius: 0;
1140
+ background: var(--tn-c-bg);
1141
+ flex-direction: column;
1142
+ overflow: hidden;
1143
+
1144
+ > .mindmap-preview-actions {
1145
+ top: var(--mm-fs-chrome-top);
1146
+ right: 12px;
1147
+ z-index: 10;
1148
+ opacity: 1;
1149
+ pointer-events: auto;
1150
+ -webkit-app-region: no-drag;
1151
+ app-region: no-drag;
1152
+ }
1153
+
1154
+ > .focus-breadcrumbs {
1155
+ -webkit-app-region: no-drag;
1156
+ app-region: no-drag;
1157
+ }
1158
+
1159
+ /*
1160
+ * Only one .mindmap-pane exists (v-if). Use flex-basis:0 + grow — do NOT set
1161
+ * height:0 as a separate property (locks the pane at 0px in WebKit/Electron).
1162
+ */
1163
+ .mindmap-pane {
1164
+ flex: 1 1 0%;
1165
+ width: 100%;
1166
+ min-height: 0;
1167
+ max-height: none;
1168
+ }
1169
+
1170
+ .mindmap-canvas-host,
1171
+ .mindmap-outline,
1172
+ .mindmap-outline.is-editable,
1173
+ .mindmap-source-wrap,
1174
+ .mindmap-source-wrap.is-editable {
1175
+ height: auto;
1176
+ }
1177
+
1178
+ .mindmap-outline.is-editable,
1179
+ .mindmap-source-wrap.is-editable {
1180
+ display: flex;
1181
+ flex-direction: column;
1182
+ overflow: hidden;
1183
+ }
1184
+
1185
+ .mindmap-outline.is-editable :deep(.outline-view),
1186
+ .mindmap-source-wrap.is-editable :deep(.markdown-view) {
1187
+ flex: 1 1 0%;
1188
+ min-height: 0;
1189
+ height: auto;
1190
+ }
1191
+ }
1192
+
1193
+ @media (max-width: 768px) {
1194
+ .mindmap-canvas-host { height: 360px; }
1195
+ .mindmap-preview-actions { top: 6px; right: 6px; }
1196
+ .mindmap-preview-action { width: 28px; height: 28px; }
1197
+ .mindmap-outline { padding-inline: 12px; }
1198
+ }
1199
+ </style>
1200
+
1201
+ <!-- Unscoped: while one mindmap owns fullscreen, hide chrome on all others. -->
1202
+ <style lang="scss">
1203
+ html[data-tn-mindmap-fs] .mindmap-preview:not(.is-fullscreen) .mindmap-preview-actions,
1204
+ body[data-tn-mindmap-fs] .mindmap-preview:not(.is-fullscreen) .mindmap-preview-actions {
1205
+ display: none !important;
1206
+ opacity: 0 !important;
1207
+ pointer-events: none !important;
1208
+ visibility: hidden !important;
1209
+ }
1210
+ </style>