@tnotesjs/ui 0.1.5 → 0.2.1

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 (65) hide show
  1. package/README.md +30 -97
  2. package/dist/code/highlight.js +6 -15
  3. package/dist/components/Footprints/parse.js +1 -3
  4. package/dist/components/Mindmap/markdown.d.ts +1 -7
  5. package/dist/components/Mindmap/markdown.js +1 -15
  6. package/package.json +26 -9
  7. package/src/browser/clipboard.ts +16 -19
  8. package/src/browser/theme.ts +10 -15
  9. package/src/code/highlight.ts +89 -105
  10. package/src/components/Badge/Badge.vue +4 -4
  11. package/src/components/CodeBlock/CodeBlock.vue +43 -53
  12. package/src/components/CodeGroup/CodeGroup.vue +32 -35
  13. package/src/components/Footprints/Footprints.vue +2 -14
  14. package/src/components/Footprints/parse.ts +1 -3
  15. package/src/components/ImagePreview/ImagePreview.vue +101 -126
  16. package/src/components/Mermaid/Mermaid.vue +9 -12
  17. package/src/components/Mindmap/FocusBreadcrumbs.vue +31 -28
  18. package/src/components/Mindmap/InlineRuns.ts +25 -16
  19. package/src/components/Mindmap/Mindmap.vue +147 -81
  20. package/src/components/Mindmap/MindmapOutlineNode.vue +4 -1
  21. package/src/components/Mindmap/editor/AppIcon.vue +32 -11
  22. package/src/components/Mindmap/editor/CanvasContextMenu.vue +126 -49
  23. package/src/components/Mindmap/editor/LinkPopover.vue +101 -23
  24. package/src/components/Mindmap/editor/MarkdownView.vue +7 -4
  25. package/src/components/Mindmap/editor/MindmapView.vue +11 -3
  26. package/src/components/Mindmap/editor/OutlineView.vue +144 -58
  27. package/src/components/Mindmap/editor/RichInlineEditor.vue +93 -44
  28. package/src/components/Mindmap/editor/SelectionToolbar.vue +133 -41
  29. package/src/components/Mindmap/editor/canvasClipboard.ts +1 -5
  30. package/src/components/Mindmap/editor/imagePaste.ts +1 -1
  31. package/src/components/Mindmap/editor/mindmapClipboard.ts +17 -5
  32. package/src/components/Mindmap/editor/outlineDrag.ts +1 -1
  33. package/src/components/Mindmap/markdown.ts +10 -29
  34. package/src/components/WordList/RightClickMenu.vue +10 -35
  35. package/src/components/WordList/WordList.vue +19 -42
  36. package/src/entries/bilibili-video.ts +1 -1
  37. package/src/entries/excalidraw-editor.ts +20 -0
  38. package/src/entries/excalidraw-view.ts +15 -0
  39. package/src/entries/footprints.ts +1 -1
  40. package/src/entries/mermaid.ts +1 -1
  41. package/src/entries/mindmap.ts +1 -1
  42. package/src/entries/notes-table.ts +2 -2
  43. package/src/entries/word-list.ts +1 -1
  44. package/src/excalidraw/ExcalidrawSvg.vue +125 -0
  45. package/src/excalidraw/editorHost.ts +110 -0
  46. package/src/excalidraw/editorSession.ts +224 -0
  47. package/src/excalidraw/exporter.ts +50 -0
  48. package/src/excalidraw/focus.ts +33 -0
  49. package/src/excalidraw/fonts.ts +92 -0
  50. package/src/excalidraw/renderer.ts +105 -0
  51. package/src/excalidraw/scene.ts +93 -0
  52. package/src/index.ts +25 -33
  53. package/src/markdown/image.ts +4 -13
  54. package/src/styles/code.css +5 -5
  55. package/src/styles/prose.css +1 -1
  56. package/src/styles/swiper.css +1 -1
  57. package/src/styles/tokens.css +5 -6
  58. package/src/swiper/hydrate.ts +91 -102
  59. package/src/code/highlight.test.ts +0 -55
  60. package/src/components/Mermaid/Mermaid.test.ts +0 -54
  61. package/src/components/Mermaid/adoptLiveSvg.test.ts +0 -16
  62. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +0 -265
  63. package/src/components/sharedMedia.test.ts +0 -139
  64. package/src/markdown/image.test.ts +0 -88
  65. package/src/swiper/hydrate.test.ts +0 -65
@@ -1,33 +1,15 @@
1
1
  <template>
2
- <div
3
- v-if="show"
4
- class="rightClickMenu"
5
- :style="{ left: x + 'px', top: y + 'px' }"
6
- >
2
+ <div v-if="show" class="rightClickMenu" :style="{ left: x + 'px', top: y + 'px' }">
7
3
  <div v-if="showPin" class="menuItem" @click="handlePin">📌 Pin</div>
8
- <div class="menuItem" @click="(e) => handlePronounce(e, 'en-GB')">
9
- 📢 Pronounce(英)
10
- </div>
11
- <div class="menuItem" @click="(e) => handlePronounce(e, 'en-US')">
12
- 📢 Pronounce(美)
13
- </div>
14
- <div
15
- class="menuItem"
16
- @click="(e) => handlePronounceAll(e, 'en-GB')"
17
- >
4
+ <div class="menuItem" @click="(e) => handlePronounce(e, 'en-GB')">📢 Pronounce(英)</div>
5
+ <div class="menuItem" @click="(e) => handlePronounce(e, 'en-US')">📢 Pronounce(美)</div>
6
+ <div class="menuItem" @click="(e) => handlePronounceAll(e, 'en-GB')">
18
7
  📢 Pronounce All(英)
19
8
  </div>
20
- <div
21
- class="menuItem"
22
- @click="(e) => handlePronounceAll(e, 'en-US')"
23
- >
9
+ <div class="menuItem" @click="(e) => handlePronounceAll(e, 'en-US')">
24
10
  📢 Pronounce All(美)
25
11
  </div>
26
- <div
27
- v-if="showAutoShowCard"
28
- class="menuItem"
29
- @click="handleAutoShowCard"
30
- >
12
+ <div v-if="showAutoShowCard" class="menuItem" @click="handleAutoShowCard">
31
13
  🔍 Auto Show Card({{ isAutoShowCard ? '关' : '开' }})
32
14
  </div>
33
15
  <div class="menuItem" @click="handleCheckAll">✅ Check All</div>
@@ -43,22 +25,15 @@ defineProps({
43
25
  y: Number,
44
26
  showPin: {
45
27
  type: Boolean,
46
- default: true,
28
+ default: true
47
29
  },
48
30
  showAutoShowCard: {
49
31
  type: Boolean,
50
- default: true,
51
- },
32
+ default: true
33
+ }
52
34
  })
53
35
 
54
- const emit = defineEmits([
55
- 'pin',
56
- 'pronounce',
57
- 'pronounceAll',
58
- 'autoShowCard',
59
- 'checkAll',
60
- 'reset',
61
- ])
36
+ const emit = defineEmits(['pin', 'pronounce', 'pronounceAll', 'autoShowCard', 'checkAll', 'reset'])
62
37
  const handlePin = (e) => {
63
38
  emit('pin')
64
39
  e.preventDefault()
@@ -3,10 +3,7 @@ import { marked } from 'marked'
3
3
  import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
4
4
 
5
5
  import RightClickMenu from './RightClickMenu.vue'
6
- import {
7
- WORD_LIST_FEATURES_FULL,
8
- resolveWordListFeatures
9
- } from './wordListFeatures'
6
+ import { WORD_LIST_FEATURES_FULL, resolveWordListFeatures } from './wordListFeatures'
10
7
 
11
8
  const DEFAULT_WORDS_BASE_URL = 'https://github.com/tnotesjs/en-words/blob/main/'
12
9
  const DEFAULT_WORDS_RAW_BASE_URL =
@@ -15,34 +12,32 @@ const DEFAULT_WORDS_RAW_BASE_URL =
15
12
  const props = defineProps({
16
13
  words: {
17
14
  type: Array,
18
- default: () => [],
15
+ default: () => []
19
16
  },
20
17
  needSort: {
21
18
  type: Boolean,
22
- default: false,
19
+ default: false
23
20
  },
24
21
  wordsBaseUrl: {
25
22
  type: String,
26
- default: DEFAULT_WORDS_BASE_URL,
23
+ default: DEFAULT_WORDS_BASE_URL
27
24
  },
28
25
  wordsRawBaseUrl: {
29
26
  type: String,
30
- default: DEFAULT_WORDS_RAW_BASE_URL,
27
+ default: DEFAULT_WORDS_RAW_BASE_URL
31
28
  },
32
29
  /** Capability overrides; omit for full web/core behavior. */
33
30
  features: {
34
31
  type: Object,
35
- default: () => ({ ...WORD_LIST_FEATURES_FULL }),
36
- },
32
+ default: () => ({ ...WORD_LIST_FEATURES_FULL })
33
+ }
37
34
  })
38
35
 
39
36
  const features = computed(() => resolveWordListFeatures(props.features))
40
37
 
41
38
  const isMobile = computed(() => {
42
39
  if (typeof navigator === 'undefined') return false
43
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
44
- navigator.userAgent
45
- )
40
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
46
41
  })
47
42
 
48
43
  // checkbox ---------------------------------------------------
@@ -51,9 +46,7 @@ const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
51
46
  const sortedWords = computed(() => {
52
47
  const unique = [...new Set(props.words ?? [])]
53
48
  if (!props.needSort) return unique
54
- return unique.sort(
55
- (a, b) => a.toLowerCase().charCodeAt(0) - b.toLowerCase().charCodeAt(0)
56
- )
49
+ return unique.sort((a, b) => a.toLowerCase().charCodeAt(0) - b.toLowerCase().charCodeAt(0))
57
50
  })
58
51
  const checkedStates = ref({})
59
52
 
@@ -256,7 +249,7 @@ const pinCard = (word) => {
256
249
  content: cardContent.value,
257
250
  width: CARD_DEFAULT_WIDTH,
258
251
  height: CARD_DEFAULT_HEIGHT,
259
- zIndex: topZIndex.value++,
252
+ zIndex: topZIndex.value++
260
253
  })
261
254
  }
262
255
 
@@ -266,7 +259,7 @@ const bringToFront = (card) => {
266
259
  pinnedCards.value = [
267
260
  ...pinnedCards.value.slice(0, index),
268
261
  ...pinnedCards.value.slice(index + 1),
269
- { ...card, zIndex: topZIndex.value++ },
262
+ { ...card, zIndex: topZIndex.value++ }
270
263
  ]
271
264
  }
272
265
  }
@@ -315,10 +308,7 @@ const handleContextMenuPin = () => {
315
308
  if (currentWordForContextMenu) {
316
309
  const word = currentWordForContextMenu
317
310
  // 提前加载内容
318
- showWordCard(
319
- { clientX: contextMenuX.value, clientY: contextMenuY.value },
320
- word
321
- ).then(() => {
311
+ showWordCard({ clientX: contextMenuX.value, clientY: contextMenuY.value }, word).then(() => {
322
312
  pinCard(word)
323
313
  showCard.value = false
324
314
  })
@@ -390,10 +380,7 @@ const handlePronounceAll = (lang) => {
390
380
  isPronouncingAll.value = true
391
381
 
392
382
  const speakNext = async () => {
393
- if (
394
- !isPronouncingAll.value ||
395
- currentPronounceAllIndex.value >= wordsToSpeak.length
396
- ) {
383
+ if (!isPronouncingAll.value || currentPronounceAllIndex.value >= wordsToSpeak.length) {
397
384
  stopPronounceAll()
398
385
  return
399
386
  }
@@ -474,7 +461,7 @@ onUnmounted(() => {
474
461
  v-for="(word, index) in sortedWords"
475
462
  :key="word"
476
463
  :class="{
477
- pronounced: isPronouncingAll && currentPronounceAllIndex === index + 1,
464
+ pronounced: isPronouncingAll && currentPronounceAllIndex === index + 1
478
465
  }"
479
466
  >
480
467
  <span class="index">{{ index + 1 }}.</span>
@@ -491,14 +478,9 @@ onUnmounted(() => {
491
478
  )}.md`"
492
479
  :class="{
493
480
  lineThrough: checkedStates[word],
494
- textRed: failedWords[word],
481
+ textRed: failedWords[word]
495
482
  }"
496
- @mouseenter="
497
- (e) =>
498
- features.enableCards &&
499
- isAutoShowCard &&
500
- showWordCard(e, word)
501
- "
483
+ @mouseenter="(e) => features.enableCards && isAutoShowCard && showWordCard(e, word)"
502
484
  @mouseleave="handleMouseLeave"
503
485
  @contextmenu="(e) => showContextMenu(e, word)"
504
486
  @click.ctrl.exact="(e) => handlePronounce(word)"
@@ -528,7 +510,7 @@ onUnmounted(() => {
528
510
  top: card.y + 'px',
529
511
  width: card.width + 'px',
530
512
  height: card.height + 'px',
531
- zIndex: card.zIndex,
513
+ zIndex: card.zIndex
532
514
  }"
533
515
  @mousedown="(e) => startDrag(card, e)"
534
516
  @click="bringToFront(card)"
@@ -536,13 +518,8 @@ onUnmounted(() => {
536
518
  <div class="wordCardContentWrapper">
537
519
  <div class="wordCardContent" v-html="card.content"></div>
538
520
  </div>
539
- <button class="closeBtn" @click.stop="removeCard(card.id)">
540
- ✖
541
- </button>
542
- <div
543
- class="resizeHandle"
544
- @mousedown.stop="startResize(card, $event)"
545
- ></div>
521
+ <button class="closeBtn" @click.stop="removeCard(card.id)">✖</button>
522
+ <div class="resizeHandle" @mousedown.stop="startResize(card, $event)"></div>
546
523
  </div>
547
524
  </template>
548
525
  </div>
@@ -1 +1 @@
1
- export { default } from "../components/BilibiliVideo/BilibiliVideo.vue";
1
+ export { default } from '../components/BilibiliVideo/BilibiliVideo.vue'
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 画布编辑入口(Desk 专用;SSG 只用 excalidraw-view)。
3
+ *
4
+ * 目前导出宿主无关的编辑会话状态机;后续在同一入口补 React 编辑器宿主组件
5
+ * (稳定实例 + DOM 承载位置交接 + 撤销历史)。
6
+ */
7
+ export { createExcalidrawSession, persistedSceneSignature } from '../excalidraw/editorSession'
8
+ /**
9
+ * 编辑器宿主(React + Excalidraw 本体)通过动态 import 暴露,
10
+ * 保证 `excalidraw-editor` 入口本身不会把编辑器拖进静态依赖图。
11
+ */
12
+ export const loadExcalidrawHost = () => import('../excalidraw/editorHost')
13
+ export type { ExcalidrawHostHandle, MountExcalidrawHostOptions } from '../excalidraw/editorHost'
14
+ export type {
15
+ ExcalidrawSaveInput,
16
+ ExcalidrawSaveResult,
17
+ ExcalidrawSession,
18
+ ExcalidrawSessionOptions,
19
+ ExcalidrawWriteState
20
+ } from '../excalidraw/editorSession'
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 只读画布入口:Desk 笔记卡片与 SSG 站点共用同一套渲染逻辑。
3
+ * 导出能力(Excalidraw 本体)由渲染器内部动态 import,首屏不会引入编辑器。
4
+ */
5
+ export { default as ExcalidrawSvg } from '../excalidraw/ExcalidrawSvg.vue'
6
+ export { createExcalidrawSvgRenderer, toSvgDataUrl } from '../excalidraw/renderer'
7
+ export type {
8
+ ExcalidrawSvgRenderer,
9
+ ExcalidrawSvgRendererOptions,
10
+ ExcalidrawViewState
11
+ } from '../excalidraw/renderer'
12
+ export { parseExcalidrawScene, sceneCacheKey, svgCache, SvgCache } from '../excalidraw/scene'
13
+ /** 部署站点需要把官方字体指向站点内的目录(离线自包含) */
14
+ export { setExcalidrawAssetPath, getExcalidrawAssetPath } from '../excalidraw/fonts'
15
+ export type { ExcalidrawScene, SceneParseResult } from '../excalidraw/scene'
@@ -1 +1 @@
1
- export { default } from "../components/Footprints/Footprints.vue";
1
+ export { default } from '../components/Footprints/Footprints.vue'
@@ -1 +1 @@
1
- export { default } from "../components/Mermaid/Mermaid.vue";
1
+ export { default } from '../components/Mermaid/Mermaid.vue'
@@ -1 +1 @@
1
- export { default } from "../components/Mindmap/Mindmap.vue";
1
+ export { default } from '../components/Mindmap/Mindmap.vue'
@@ -1,2 +1,2 @@
1
- export { default } from "../components/NotesTable/NotesTable.vue";
2
- export type { NotesTableRow } from "../components/NotesTable/types";
1
+ export { default } from '../components/NotesTable/NotesTable.vue'
2
+ export type { NotesTableRow } from '../components/NotesTable/types'
@@ -1 +1 @@
1
- export { default } from "../components/WordList/WordList.vue";
1
+ export { default } from '../components/WordList/WordList.vue'
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 共享只读画布:Desk 笔记卡片与 SSG 站点共用。
4
+ *
5
+ * 约束(计划 E2):
6
+ * - 只读:不挂载可编辑实例、不写任何文件
7
+ * - 明暗跟随宿主,但只影响显示,不改磁盘 appState
8
+ * - 导出能力懒加载:首屏不引入 Excalidraw
9
+ * - 过期结果丢弃;卸载时释放 data URL 与观察器
10
+ */
11
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
12
+
13
+ import { createExcalidrawSvgRenderer } from './renderer'
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** 磁盘上的原始 JSON(唯一真相源) */
18
+ content: string
19
+ /** 不传则跟随宿主的 .dark 类 */
20
+ theme?: 'light' | 'dark'
21
+ height?: number | string
22
+ /** 无障碍描述 */
23
+ label?: string
24
+ }>(),
25
+ { theme: undefined, height: 480, label: 'Excalidraw 绘图' }
26
+ )
27
+
28
+ const resolvedTheme = ref<'light' | 'dark'>(props.theme ?? 'light')
29
+ let observer: MutationObserver | null = null
30
+
31
+ const renderer = createExcalidrawSvgRenderer({
32
+ getContent: () => props.content,
33
+ getTheme: () => resolvedTheme.value
34
+ })
35
+
36
+ const { state, errorReason, dataUrl } = renderer
37
+ const heightStyle = computed(() =>
38
+ typeof props.height === 'number' ? `${props.height}px` : props.height
39
+ )
40
+
41
+ function readHostTheme(): 'light' | 'dark' {
42
+ if (props.theme) return props.theme
43
+ if (typeof document === 'undefined') return 'light'
44
+ return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
45
+ }
46
+
47
+ onMounted(() => {
48
+ resolvedTheme.value = readHostTheme()
49
+ void renderer.render()
50
+ // SSG 通过切换 <html class="dark"> 换主题;这里只跟随显示,不写任何文件
51
+ if (!props.theme && typeof MutationObserver !== 'undefined') {
52
+ observer = new MutationObserver(() => {
53
+ const next = readHostTheme()
54
+ if (next !== resolvedTheme.value) resolvedTheme.value = next
55
+ })
56
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
57
+ }
58
+ })
59
+
60
+ onBeforeUnmount(() => {
61
+ observer?.disconnect()
62
+ observer = null
63
+ renderer.dispose()
64
+ })
65
+
66
+ watch(
67
+ () => props.content,
68
+ () => void renderer.render()
69
+ )
70
+ watch(resolvedTheme, () => void renderer.render())
71
+
72
+ defineExpose({ state, errorReason })
73
+ </script>
74
+
75
+ <template>
76
+ <div class="tn-excalidraw-view" :style="{ height: heightStyle }" :data-state="state">
77
+ <img
78
+ v-if="state === 'ready'"
79
+ class="tn-excalidraw-view__image"
80
+ :src="dataUrl"
81
+ :alt="label"
82
+ loading="lazy"
83
+ decoding="async"
84
+ />
85
+ <div
86
+ v-else-if="state === 'error'"
87
+ class="tn-excalidraw-view__placeholder"
88
+ role="img"
89
+ :aria-label="label"
90
+ >
91
+ <span>无法显示画布</span>
92
+ <small>{{ errorReason }}</small>
93
+ </div>
94
+ <div v-else class="tn-excalidraw-view__placeholder" :aria-busy="state === 'loading'">
95
+ <span>画布加载中…</span>
96
+ </div>
97
+ </div>
98
+ </template>
99
+
100
+ <style scoped>
101
+ .tn-excalidraw-view {
102
+ position: relative;
103
+ width: 100%;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ overflow: hidden;
108
+ border-radius: 8px;
109
+ background: var(--tn-excalidraw-bg, transparent);
110
+ }
111
+ .tn-excalidraw-view__image {
112
+ max-width: 100%;
113
+ max-height: 100%;
114
+ object-fit: contain;
115
+ }
116
+ .tn-excalidraw-view__placeholder {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 4px;
120
+ align-items: center;
121
+ justify-content: center;
122
+ color: var(--tn-muted, #8a8a8a);
123
+ font-size: 13px;
124
+ }
125
+ </style>
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Excalidraw 编辑器宿主(Desk 专用)。
3
+ *
4
+ * 关键行为:
5
+ * - 稳定实例:切换卡片/全屏/标签页时只**移动承载 DOM 节点**,不重建 React 树,
6
+ * 否则场景与原生撤销历史都会丢(E0 已实证)
7
+ * - 承载位置交接后必须重建键盘焦点:E0 实测「blur 当前焦点 + 给交互画布加
8
+ * tabindex + canvas.focus()」是唯一有效路径;不重建则快捷键(撤销等)失效
9
+ * - 内容变化只在需要持久化时回调(宿主用 persistedSceneSignature 过滤)
10
+ */
11
+ import { createElement } from 'react'
12
+ import { createRoot, type Root } from 'react-dom/client'
13
+ import { Excalidraw, serializeAsJSON } from '@excalidraw/excalidraw'
14
+ // 官方样式表必须由宿主 import 一次:缺它的容器高度会算成 2^24,画布不可见且点不到
15
+ import '@excalidraw/excalidraw/index.css'
16
+
17
+ import { setExcalidrawAssetPath } from './fonts'
18
+ import { restoreCanvasKeyboardFocusWhenReady } from './focus'
19
+
20
+ import type {
21
+ ExcalidrawImperativeAPI,
22
+ ExcalidrawInitialDataState
23
+ } from '@excalidraw/excalidraw/types'
24
+
25
+ export interface MountExcalidrawHostOptions {
26
+ host: HTMLElement
27
+ /** 初始磁盘内容(唯一真相源) */
28
+ content: string
29
+ theme: 'light' | 'dark'
30
+ /** 本地字体基址;离线必须设置 */
31
+ fontBase?: string
32
+ /**
33
+ * 场景序列化回调。
34
+ *
35
+ * 注意:Excalidraw 载入磁盘场景时会补齐自己的字段(groupIds/roundness/gridSize…),
36
+ * 挂载后的第一次回调就是这份「规范化」结果,**不是用户编辑**。调用方要把它当作
37
+ * 新的持久化基线(`session.adopt`),否则只读打开也会回写一份规范化副本。
38
+ */
39
+ onChange: (content: string) => void
40
+ onReady?: (api: ExcalidrawImperativeAPI) => void
41
+ }
42
+
43
+ export interface ExcalidrawHostHandle {
44
+ /** 只移动承载节点,保持实例与撤销历史 */
45
+ transferTo(target: HTMLElement): void
46
+ /** 重建键盘焦点(交接后必须调用) */
47
+ focus(): void
48
+ /** 跟随宿主主题切换;不重建实例,只改 appState.theme */
49
+ setTheme(theme: 'light' | 'dark'): void
50
+ getApi(): ExcalidrawImperativeAPI | null
51
+ destroy(): void
52
+ }
53
+
54
+ /** 磁盘 JSON 的形状由 kb 层保证;这里只做最必要的兜底。 */
55
+ function parseInitial(content: string): ExcalidrawInitialDataState {
56
+ try {
57
+ const parsed = JSON.parse(content) as ExcalidrawInitialDataState
58
+ return {
59
+ elements: Array.isArray(parsed.elements) ? parsed.elements : [],
60
+ appState: parsed.appState ?? {},
61
+ files: parsed.files ?? {}
62
+ }
63
+ } catch {
64
+ return { elements: [], appState: {}, files: {} }
65
+ }
66
+ }
67
+
68
+ export { restoreCanvasKeyboardFocus, restoreCanvasKeyboardFocusWhenReady } from './focus'
69
+
70
+ export function mountExcalidrawHost(options: MountExcalidrawHostOptions): ExcalidrawHostHandle {
71
+ if (options.fontBase) setExcalidrawAssetPath(options.fontBase)
72
+ const initial = parseInitial(options.content)
73
+ let api: ExcalidrawImperativeAPI | null = null
74
+ const appState = {
75
+ ...initial.appState,
76
+ theme: options.theme,
77
+ // 不做「整篇替换」的展示状态,交给 Excalidraw 自己维护
78
+ collaborators: new Map()
79
+ }
80
+
81
+ const root: Root = createRoot(options.host)
82
+ root.render(
83
+ createElement(Excalidraw, {
84
+ initialData: { elements: initial.elements, appState, files: initial.files },
85
+ theme: options.theme,
86
+ excalidrawAPI: (next: ExcalidrawImperativeAPI) => {
87
+ api = next
88
+ options.onReady?.(next)
89
+ },
90
+ onChange: (elements, nextAppState, files) => {
91
+ options.onChange(serializeAsJSON(elements, nextAppState, files, 'local'))
92
+ }
93
+ })
94
+ )
95
+
96
+ return {
97
+ transferTo: (target: HTMLElement) => {
98
+ if (options.host.parentElement !== target) target.append(options.host)
99
+ // 换了承载容器:画布尺寸与命中区域都要重算,否则指针事件落在旧几何上
100
+ api?.refresh?.()
101
+ restoreCanvasKeyboardFocusWhenReady(options.host)
102
+ },
103
+ focus: () => restoreCanvasKeyboardFocusWhenReady(options.host),
104
+ setTheme: (theme: 'light' | 'dark') => {
105
+ api?.updateScene({ appState: { theme } })
106
+ },
107
+ getApi: () => api,
108
+ destroy: () => root.unmount()
109
+ }
110
+ }