@tnotesjs/ui 0.1.5 → 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.
- 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 +24 -8
- package/src/browser/clipboard.ts +16 -19
- package/src/browser/theme.ts +10 -15
- package/src/code/highlight.test.ts +23 -6
- 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.test.ts +40 -30
- 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/components/sharedMedia.test.ts +94 -112
- 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.test.ts +205 -0
- package/src/excalidraw/editorSession.ts +224 -0
- package/src/excalidraw/exporter.ts +50 -0
- package/src/excalidraw/focus.test.ts +60 -0
- package/src/excalidraw/focus.ts +33 -0
- package/src/excalidraw/fonts.test.ts +66 -0
- package/src/excalidraw/fonts.ts +92 -0
- package/src/excalidraw/renderer.test.ts +148 -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.test.ts +1 -3
- 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.test.ts +37 -41
- 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
|
-
|
|
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,139 +1,121 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { createApp, defineComponent, h, nextTick, type Component } from
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from
|
|
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
|
|
7
|
-
import CodeGroup from
|
|
8
|
-
import ImagePreview from
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
38
|
-
it(
|
|
39
|
-
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
40
|
-
Object.defineProperty(navigator,
|
|
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:
|
|
46
|
-
info:
|
|
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>(
|
|
51
|
-
buttons[0].click()
|
|
52
|
-
await nextTick()
|
|
53
|
-
expect(writeText).toHaveBeenCalledWith(
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
62
|
+
it('switches code-group tabs with click and arrow keys', async () => {
|
|
68
63
|
const host = mount(CodeGroup, {
|
|
69
64
|
items: [
|
|
70
|
-
{ code:
|
|
71
|
-
{ code:
|
|
72
|
-
]
|
|
73
|
-
})
|
|
74
|
-
const tabs = host.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
|
75
|
-
tabs[0].dispatchEvent(
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
87
|
-
const prose = document.createElement(
|
|
88
|
-
prose.className =
|
|
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>(
|
|
95
|
-
await nextTick()
|
|
96
|
-
expect(document.documentElement.style.overflow).toBe(
|
|
97
|
-
expect(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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(
|
|
117
|
-
const prose = document.createElement(
|
|
118
|
-
prose.className =
|
|
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(
|
|
126
|
-
detail: prose.querySelector(
|
|
127
|
-
})
|
|
128
|
-
)
|
|
129
|
-
await nextTick()
|
|
130
|
-
expect(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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>
|