@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.
- package/README.md +30 -97
- package/dist/code/highlight.js +6 -15
- package/dist/components/Footprints/parse.js +1 -3
- package/dist/components/Mindmap/markdown.d.ts +1 -7
- package/dist/components/Mindmap/markdown.js +1 -15
- package/package.json +26 -9
- package/src/browser/clipboard.ts +16 -19
- package/src/browser/theme.ts +10 -15
- package/src/code/highlight.ts +89 -105
- package/src/components/Badge/Badge.vue +4 -4
- package/src/components/CodeBlock/CodeBlock.vue +43 -53
- package/src/components/CodeGroup/CodeGroup.vue +32 -35
- package/src/components/Footprints/Footprints.vue +2 -14
- package/src/components/Footprints/parse.ts +1 -3
- package/src/components/ImagePreview/ImagePreview.vue +101 -126
- package/src/components/Mermaid/Mermaid.vue +9 -12
- package/src/components/Mindmap/FocusBreadcrumbs.vue +31 -28
- package/src/components/Mindmap/InlineRuns.ts +25 -16
- package/src/components/Mindmap/Mindmap.vue +147 -81
- package/src/components/Mindmap/MindmapOutlineNode.vue +4 -1
- package/src/components/Mindmap/editor/AppIcon.vue +32 -11
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +126 -49
- package/src/components/Mindmap/editor/LinkPopover.vue +101 -23
- package/src/components/Mindmap/editor/MarkdownView.vue +7 -4
- package/src/components/Mindmap/editor/MindmapView.vue +11 -3
- package/src/components/Mindmap/editor/OutlineView.vue +144 -58
- package/src/components/Mindmap/editor/RichInlineEditor.vue +93 -44
- package/src/components/Mindmap/editor/SelectionToolbar.vue +133 -41
- package/src/components/Mindmap/editor/canvasClipboard.ts +1 -5
- package/src/components/Mindmap/editor/imagePaste.ts +1 -1
- package/src/components/Mindmap/editor/mindmapClipboard.ts +17 -5
- package/src/components/Mindmap/editor/outlineDrag.ts +1 -1
- package/src/components/Mindmap/markdown.ts +10 -29
- package/src/components/WordList/RightClickMenu.vue +10 -35
- package/src/components/WordList/WordList.vue +19 -42
- package/src/entries/bilibili-video.ts +1 -1
- package/src/entries/excalidraw-editor.ts +20 -0
- package/src/entries/excalidraw-view.ts +15 -0
- package/src/entries/footprints.ts +1 -1
- package/src/entries/mermaid.ts +1 -1
- package/src/entries/mindmap.ts +1 -1
- package/src/entries/notes-table.ts +2 -2
- package/src/entries/word-list.ts +1 -1
- package/src/excalidraw/ExcalidrawSvg.vue +125 -0
- package/src/excalidraw/editorHost.ts +110 -0
- package/src/excalidraw/editorSession.ts +224 -0
- package/src/excalidraw/exporter.ts +50 -0
- package/src/excalidraw/focus.ts +33 -0
- package/src/excalidraw/fonts.ts +92 -0
- package/src/excalidraw/renderer.ts +105 -0
- package/src/excalidraw/scene.ts +93 -0
- package/src/index.ts +25 -33
- package/src/markdown/image.ts +4 -13
- package/src/styles/code.css +5 -5
- package/src/styles/prose.css +1 -1
- package/src/styles/swiper.css +1 -1
- package/src/styles/tokens.css +5 -6
- package/src/swiper/hydrate.ts +91 -102
- package/src/code/highlight.test.ts +0 -55
- package/src/components/Mermaid/Mermaid.test.ts +0 -54
- package/src/components/Mermaid/adoptLiveSvg.test.ts +0 -16
- package/src/components/Mindmap/FocusBreadcrumbs.test.ts +0 -265
- package/src/components/sharedMedia.test.ts +0 -139
- package/src/markdown/image.test.ts +0 -88
- 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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
1
|
+
export { default } from '../components/Footprints/Footprints.vue'
|
package/src/entries/mermaid.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from
|
|
1
|
+
export { default } from '../components/Mermaid/Mermaid.vue'
|
package/src/entries/mindmap.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from
|
|
1
|
+
export { default } from '../components/Mindmap/Mindmap.vue'
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { default } from
|
|
2
|
-
export type { NotesTableRow } from
|
|
1
|
+
export { default } from '../components/NotesTable/NotesTable.vue'
|
|
2
|
+
export type { NotesTableRow } from '../components/NotesTable/types'
|
package/src/entries/word-list.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from
|
|
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
|
+
}
|