@tnotesjs/ui 0.1.4 → 0.2.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 (67) 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 +28 -13
  7. package/src/browser/clipboard.ts +16 -19
  8. package/src/browser/theme.ts +10 -15
  9. package/src/code/highlight.test.ts +23 -6
  10. package/src/code/highlight.ts +89 -105
  11. package/src/components/Badge/Badge.vue +4 -4
  12. package/src/components/CodeBlock/CodeBlock.vue +43 -53
  13. package/src/components/CodeGroup/CodeGroup.vue +32 -35
  14. package/src/components/Footprints/Footprints.vue +2 -14
  15. package/src/components/Footprints/parse.ts +1 -3
  16. package/src/components/ImagePreview/ImagePreview.vue +101 -126
  17. package/src/components/Mermaid/Mermaid.vue +9 -12
  18. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +40 -30
  19. package/src/components/Mindmap/FocusBreadcrumbs.vue +31 -28
  20. package/src/components/Mindmap/InlineRuns.ts +25 -16
  21. package/src/components/Mindmap/Mindmap.vue +147 -81
  22. package/src/components/Mindmap/MindmapOutlineNode.vue +4 -1
  23. package/src/components/Mindmap/editor/AppIcon.vue +32 -11
  24. package/src/components/Mindmap/editor/CanvasContextMenu.vue +126 -49
  25. package/src/components/Mindmap/editor/LinkPopover.vue +101 -23
  26. package/src/components/Mindmap/editor/MarkdownView.vue +7 -4
  27. package/src/components/Mindmap/editor/MindmapView.vue +11 -3
  28. package/src/components/Mindmap/editor/OutlineView.vue +144 -58
  29. package/src/components/Mindmap/editor/RichInlineEditor.vue +93 -44
  30. package/src/components/Mindmap/editor/SelectionToolbar.vue +133 -41
  31. package/src/components/Mindmap/editor/canvasClipboard.ts +1 -5
  32. package/src/components/Mindmap/editor/imagePaste.ts +1 -1
  33. package/src/components/Mindmap/editor/mindmapClipboard.ts +17 -5
  34. package/src/components/Mindmap/editor/outlineDrag.ts +1 -1
  35. package/src/components/Mindmap/markdown.ts +10 -29
  36. package/src/components/WordList/RightClickMenu.vue +10 -35
  37. package/src/components/WordList/WordList.vue +19 -42
  38. package/src/components/sharedMedia.test.ts +94 -112
  39. package/src/entries/bilibili-video.ts +1 -1
  40. package/src/entries/excalidraw-editor.ts +20 -0
  41. package/src/entries/excalidraw-view.ts +15 -0
  42. package/src/entries/footprints.ts +1 -1
  43. package/src/entries/mermaid.ts +1 -1
  44. package/src/entries/mindmap.ts +1 -1
  45. package/src/entries/notes-table.ts +2 -2
  46. package/src/entries/word-list.ts +1 -1
  47. package/src/excalidraw/ExcalidrawSvg.vue +125 -0
  48. package/src/excalidraw/editorHost.ts +110 -0
  49. package/src/excalidraw/editorSession.test.ts +205 -0
  50. package/src/excalidraw/editorSession.ts +224 -0
  51. package/src/excalidraw/exporter.ts +50 -0
  52. package/src/excalidraw/focus.test.ts +60 -0
  53. package/src/excalidraw/focus.ts +33 -0
  54. package/src/excalidraw/fonts.test.ts +66 -0
  55. package/src/excalidraw/fonts.ts +92 -0
  56. package/src/excalidraw/renderer.test.ts +148 -0
  57. package/src/excalidraw/renderer.ts +105 -0
  58. package/src/excalidraw/scene.ts +93 -0
  59. package/src/index.ts +25 -33
  60. package/src/markdown/image.test.ts +1 -3
  61. package/src/markdown/image.ts +4 -13
  62. package/src/styles/code.css +5 -5
  63. package/src/styles/prose.css +2 -2
  64. package/src/styles/swiper.css +1 -1
  65. package/src/styles/tokens.css +5 -6
  66. package/src/swiper/hydrate.test.ts +37 -41
  67. package/src/swiper/hydrate.ts +91 -102
@@ -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,139 +1,121 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { createApp, defineComponent, h, nextTick, type Component } from "vue";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createApp, defineComponent, h, nextTick, type Component } from 'vue'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
 
6
- import CodeBlock from "./CodeBlock/CodeBlock.vue";
7
- import CodeGroup from "./CodeGroup/CodeGroup.vue";
8
- import ImagePreview from "./ImagePreview/ImagePreview.vue";
6
+ import CodeBlock from './CodeBlock/CodeBlock.vue'
7
+ import CodeGroup from './CodeGroup/CodeGroup.vue'
8
+ import ImagePreview from './ImagePreview/ImagePreview.vue'
9
9
 
10
- const cleanups: Array<() => void> = [];
10
+ const cleanups: Array<() => void> = []
11
11
 
12
- function mount(
13
- component: Component,
14
- props: Record<string, unknown> = {},
15
- ): HTMLElement {
16
- const host = document.createElement("div");
17
- document.body.append(host);
18
- const app = createApp(defineComponent({ render: () => h(component, props) }));
19
- app.mount(host);
12
+ function mount(component: Component, props: Record<string, unknown> = {}): HTMLElement {
13
+ const host = document.createElement('div')
14
+ document.body.append(host)
15
+ const app = createApp(defineComponent({ render: () => h(component, props) }))
16
+ app.mount(host)
20
17
  cleanups.push(() => {
21
- app.unmount();
22
- host.remove();
23
- });
24
- return host;
18
+ app.unmount()
19
+ host.remove()
20
+ })
21
+ return host
25
22
  }
26
23
 
27
24
  afterEach(() => {
28
25
  cleanups
29
26
  .splice(0)
30
27
  .reverse()
31
- .forEach((cleanup) => cleanup());
32
- document.body.innerHTML = "";
33
- document.documentElement.style.overflow = "";
34
- vi.restoreAllMocks();
35
- });
28
+ .forEach((cleanup) => cleanup())
29
+ document.body.innerHTML = ''
30
+ document.documentElement.style.overflow = ''
31
+ vi.restoreAllMocks()
32
+ })
36
33
 
37
- describe("shared code and media components", () => {
38
- it("copies raw code and closes its fullscreen dialog with Escape", async () => {
39
- const writeText = vi.fn().mockResolvedValue(undefined);
40
- Object.defineProperty(navigator, "clipboard", {
34
+ describe('shared code and media components', () => {
35
+ it('copies raw code and closes its fullscreen dialog with Escape', async () => {
36
+ const writeText = vi.fn().mockResolvedValue(undefined)
37
+ Object.defineProperty(navigator, 'clipboard', {
41
38
  configurable: true,
42
- value: { writeText },
43
- });
39
+ value: { writeText }
40
+ })
44
41
  const host = mount(CodeBlock, {
45
- code: "const value = 1\n",
46
- info: "ts [demo.ts]",
47
- highlightedHtml: '<pre class="tn-code-highlight"><code>ok</code></pre>',
48
- });
42
+ code: 'const value = 1\n',
43
+ info: 'ts [demo.ts]',
44
+ highlightedHtml: '<pre class="tn-code-highlight"><code>ok</code></pre>'
45
+ })
49
46
 
50
- const buttons = host.querySelectorAll<HTMLButtonElement>("button");
51
- buttons[0].click();
52
- await nextTick();
53
- expect(writeText).toHaveBeenCalledWith("const value = 1");
47
+ const buttons = host.querySelectorAll<HTMLButtonElement>('button')
48
+ buttons[0].click()
49
+ await nextTick()
50
+ expect(writeText).toHaveBeenCalledWith('const value = 1')
54
51
 
55
- buttons[1].click();
56
- await nextTick();
57
- expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
52
+ buttons[1].click()
53
+ await nextTick()
54
+ expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
58
55
  document.body
59
56
  .querySelector<HTMLElement>('[role="dialog"]')!
60
- .dispatchEvent(
61
- new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
62
- );
63
- await nextTick();
64
- expect(document.body.querySelector('[role="dialog"]')).toBeNull();
65
- });
57
+ .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
58
+ await nextTick()
59
+ expect(document.body.querySelector('[role="dialog"]')).toBeNull()
60
+ })
66
61
 
67
- it("switches code-group tabs with click and arrow keys", async () => {
62
+ it('switches code-group tabs with click and arrow keys', async () => {
68
63
  const host = mount(CodeGroup, {
69
64
  items: [
70
- { code: "one", info: "text [one.txt]" },
71
- { code: "two", info: "text [two.txt]" },
72
- ],
73
- });
74
- const tabs = host.querySelectorAll<HTMLButtonElement>('[role="tab"]');
75
- tabs[0].dispatchEvent(
76
- new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
77
- );
78
- await nextTick();
79
- await nextTick();
80
- expect(tabs[1].getAttribute("aria-selected")).toBe("true");
81
- expect(
82
- host.querySelectorAll<HTMLElement>('[role="tabpanel"]')[1].hidden,
83
- ).toBe(false);
84
- });
65
+ { code: 'one', info: 'text [one.txt]' },
66
+ { code: 'two', info: 'text [two.txt]' }
67
+ ]
68
+ })
69
+ const tabs = host.querySelectorAll<HTMLButtonElement>('[role="tab"]')
70
+ tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
71
+ await nextTick()
72
+ await nextTick()
73
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true')
74
+ expect(host.querySelectorAll<HTMLElement>('[role="tabpanel"]')[1].hidden).toBe(false)
75
+ })
85
76
 
86
- it("previews prose images, navigates, and restores document overflow", async () => {
87
- const prose = document.createElement("div");
88
- prose.className = "tn-prose";
89
- prose.innerHTML = '<img src="/one.png"><img src="/two.png">';
90
- document.body.append(prose);
91
- cleanups.push(() => prose.remove());
92
- mount(ImagePreview);
77
+ it('previews prose images, navigates, and restores document overflow', async () => {
78
+ const prose = document.createElement('div')
79
+ prose.className = 'tn-prose'
80
+ prose.innerHTML = '<img src="/one.png"><img src="/two.png">'
81
+ document.body.append(prose)
82
+ cleanups.push(() => prose.remove())
83
+ mount(ImagePreview)
93
84
 
94
- prose.querySelector<HTMLImageElement>("img")!.click();
95
- await nextTick();
96
- expect(document.documentElement.style.overflow).toBe("hidden");
97
- expect(
98
- document.body.querySelector<HTMLImageElement>(".tn-image-preview > img")
99
- ?.src,
100
- ).toContain("/one.png");
101
- document.body
102
- .querySelector<HTMLButtonElement>('[aria-label="下一张"]')!
103
- .click();
104
- await nextTick();
105
- expect(
106
- document.body.querySelector<HTMLImageElement>(".tn-image-preview > img")
107
- ?.src,
108
- ).toContain("/two.png");
109
- document.body
110
- .querySelector<HTMLButtonElement>('[aria-label="关闭"]')!
111
- .click();
112
- await nextTick();
113
- expect(document.documentElement.style.overflow).toBe("");
114
- });
85
+ prose.querySelector<HTMLImageElement>('img')!.click()
86
+ await nextTick()
87
+ expect(document.documentElement.style.overflow).toBe('hidden')
88
+ expect(document.body.querySelector<HTMLImageElement>('.tn-image-preview > img')?.src).toContain(
89
+ '/one.png'
90
+ )
91
+ document.body.querySelector<HTMLButtonElement>('[aria-label="下一张"]')!.click()
92
+ await nextTick()
93
+ expect(document.body.querySelector<HTMLImageElement>('.tn-image-preview > img')?.src).toContain(
94
+ '/two.png'
95
+ )
96
+ document.body.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click()
97
+ await nextTick()
98
+ expect(document.documentElement.style.overflow).toBe('')
99
+ })
115
100
 
116
- it("opens from a programmatic preview request", async () => {
117
- const prose = document.createElement("div");
118
- prose.className = "tn-prose tn-preview-ignore";
119
- prose.innerHTML = '<img src="/requested.png">';
120
- document.body.append(prose);
121
- cleanups.push(() => prose.remove());
122
- mount(ImagePreview);
101
+ it('opens from a programmatic preview request', async () => {
102
+ const prose = document.createElement('div')
103
+ prose.className = 'tn-prose tn-preview-ignore'
104
+ prose.innerHTML = '<img src="/requested.png">'
105
+ document.body.append(prose)
106
+ cleanups.push(() => prose.remove())
107
+ mount(ImagePreview)
123
108
 
124
109
  document.dispatchEvent(
125
- new CustomEvent("tn:preview-image", {
126
- detail: prose.querySelector("img"),
127
- }),
128
- );
129
- await nextTick();
130
- expect(
131
- document.body.querySelector<HTMLImageElement>(".tn-image-preview > img")
132
- ?.src,
133
- ).toContain("/requested.png");
134
- document.body
135
- .querySelector<HTMLButtonElement>('[aria-label="关闭"]')!
136
- .click();
137
- await nextTick();
138
- });
139
- });
110
+ new CustomEvent('tn:preview-image', {
111
+ detail: prose.querySelector('img')
112
+ })
113
+ )
114
+ await nextTick()
115
+ expect(document.body.querySelector<HTMLImageElement>('.tn-image-preview > img')?.src).toContain(
116
+ '/requested.png'
117
+ )
118
+ document.body.querySelector<HTMLButtonElement>('[aria-label="关闭"]')!.click()
119
+ await nextTick()
120
+ })
121
+ })
@@ -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>