@tnotesjs/ui 0.1.0 → 0.1.2
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 +52 -29
- package/dist/code/highlight.d.ts +42 -0
- package/dist/code/highlight.js +137 -0
- package/dist/components/Footprints/parse.d.ts +17 -0
- package/dist/components/Footprints/parse.js +105 -0
- package/dist/components/Mindmap/markdown.d.ts +20 -0
- package/dist/components/Mindmap/markdown.js +60 -0
- package/dist/markdown/image.d.ts +33 -0
- package/dist/markdown/image.js +83 -0
- package/package.json +49 -7
- package/src/browser/clipboard.ts +32 -0
- package/src/browser/theme.ts +22 -0
- package/src/code/highlight.test.ts +55 -0
- package/src/code/highlight.ts +170 -0
- package/src/components/Badge/Badge.vue +43 -0
- package/src/components/CodeBlock/CodeBlock.vue +172 -0
- package/src/components/CodeGroup/CodeGroup.vue +96 -0
- package/src/components/Footprints/Footprints.vue +1 -1
- package/src/components/ImagePreview/ImagePreview.vue +315 -0
- package/src/components/Mermaid/Mermaid.test.ts +54 -0
- package/src/components/Mermaid/Mermaid.vue +229 -56
- package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
- package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
- package/src/components/Mindmap/Mindmap.vue +2 -0
- package/src/components/sharedMedia.test.ts +139 -0
- package/src/entries/bilibili-video.ts +1 -0
- package/src/entries/footprints.ts +1 -0
- package/src/entries/mermaid.ts +1 -0
- package/src/entries/mindmap.ts +1 -0
- package/src/entries/notes-table.ts +2 -0
- package/src/entries/word-list.ts +1 -0
- package/src/icons/check.svg +3 -0
- package/src/icons/copy.svg +4 -0
- package/src/index.ts +31 -18
- package/src/markdown/image.test.ts +88 -0
- package/src/markdown/image.ts +115 -0
- package/src/styles/code.css +184 -0
- package/src/styles/prose.css +211 -0
- package/src/styles/swiper.css +151 -0
- package/src/styles/tokens.css +54 -18
- package/src/swiper/hydrate.test.ts +65 -0
- package/src/swiper/hydrate.ts +164 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
ref="rootRef"
|
|
4
|
+
class="tn-mermaid"
|
|
5
|
+
:class="{ 'is-centered': centered, 'is-fullscreen': isFullscreen }"
|
|
6
|
+
>
|
|
3
7
|
<div
|
|
4
8
|
v-if="showActions"
|
|
5
9
|
class="tn-mermaid__actions"
|
|
@@ -51,7 +55,6 @@
|
|
|
51
55
|
|
|
52
56
|
<div
|
|
53
57
|
class="tn-mermaid__stage"
|
|
54
|
-
:class="{ 'is-fullscreen': isFullscreen }"
|
|
55
58
|
@mousedown="startDrag"
|
|
56
59
|
@mousemove="onDrag"
|
|
57
60
|
@mouseup="endDrag"
|
|
@@ -61,7 +64,7 @@
|
|
|
61
64
|
<div
|
|
62
65
|
ref="diagramRef"
|
|
63
66
|
class="tn-mermaid__diagram"
|
|
64
|
-
:class="{ 'is-centered': centered, 'is-obscured':
|
|
67
|
+
:class="{ 'is-centered': centered, 'is-obscured': empty || !!error }"
|
|
65
68
|
:style="diagramTransform ? { transform: diagramTransform, transformOrigin: 'top left' } : undefined"
|
|
66
69
|
@wheel="handleWheel"
|
|
67
70
|
/>
|
|
@@ -83,6 +86,7 @@
|
|
|
83
86
|
import mermaid from 'mermaid'
|
|
84
87
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
85
88
|
|
|
89
|
+
import { adoptLiveSvg } from './adoptLiveSvg'
|
|
86
90
|
import iconCenterOff from './icons/icon__center_off.svg?url'
|
|
87
91
|
import iconCenterOn from './icons/icon__center_on.svg?url'
|
|
88
92
|
import iconCheck from './icons/icon__check.svg?url'
|
|
@@ -92,6 +96,56 @@ import iconFullscreenExit from './icons/icon__fullscreen_exit.svg?url'
|
|
|
92
96
|
|
|
93
97
|
let idSeq = 0
|
|
94
98
|
|
|
99
|
+
/** Mermaid's renderer is not safe for overlapping `render()` calls. */
|
|
100
|
+
let renderChain = Promise.resolve()
|
|
101
|
+
/** Re-init wipes layout engines mid-flight when several diagrams mount at once. */
|
|
102
|
+
let initializedKey = null
|
|
103
|
+
|
|
104
|
+
function enqueueRender(task) {
|
|
105
|
+
const run = renderChain.then(task, task)
|
|
106
|
+
renderChain = run.then(
|
|
107
|
+
() => undefined,
|
|
108
|
+
() => undefined
|
|
109
|
+
)
|
|
110
|
+
return run
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureMermaidInitialized(dark, securityLevel) {
|
|
114
|
+
const key = `${dark ? 'dark' : 'light'}:${securityLevel}`
|
|
115
|
+
if (initializedKey === key) return
|
|
116
|
+
mermaid.initialize({
|
|
117
|
+
startOnLoad: false,
|
|
118
|
+
theme: dark ? 'dark' : 'default',
|
|
119
|
+
securityLevel,
|
|
120
|
+
fontFamily: 'inherit'
|
|
121
|
+
})
|
|
122
|
+
initializedKey = key
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function waitForLayoutBox(el) {
|
|
126
|
+
if (!el || el.clientWidth > 0) return Promise.resolve()
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
let settled = false
|
|
129
|
+
const finish = () => {
|
|
130
|
+
if (settled) return
|
|
131
|
+
settled = true
|
|
132
|
+
observer?.disconnect()
|
|
133
|
+
resolve()
|
|
134
|
+
}
|
|
135
|
+
const observer =
|
|
136
|
+
typeof ResizeObserver === 'function'
|
|
137
|
+
? new ResizeObserver(() => {
|
|
138
|
+
if (el.clientWidth > 0) finish()
|
|
139
|
+
})
|
|
140
|
+
: null
|
|
141
|
+
observer?.observe(el)
|
|
142
|
+
requestAnimationFrame(() => {
|
|
143
|
+
if (el.clientWidth > 0) finish()
|
|
144
|
+
})
|
|
145
|
+
setTimeout(finish, 1200)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
95
149
|
const props = defineProps({
|
|
96
150
|
/** Plain Mermaid source (preferred). */
|
|
97
151
|
source: {
|
|
@@ -179,7 +233,9 @@ function toggleCenter() {
|
|
|
179
233
|
|
|
180
234
|
function detectDark() {
|
|
181
235
|
if (typeof props.isDark === 'boolean') return props.isDark
|
|
236
|
+
if (typeof document === 'undefined') return false
|
|
182
237
|
const root = document.documentElement
|
|
238
|
+
if (root.dataset.theme === 'light') return false
|
|
183
239
|
return root.classList.contains('dark') || root.dataset.theme === 'dark'
|
|
184
240
|
}
|
|
185
241
|
|
|
@@ -194,23 +250,47 @@ const copyTitle = computed(() => {
|
|
|
194
250
|
return '复制源码'
|
|
195
251
|
})
|
|
196
252
|
let copyResetTimer = null
|
|
253
|
+
/** How fullscreen was entered — Electron usually needs the CSS overlay path. */
|
|
254
|
+
let fullscreenMode = null
|
|
255
|
+
|
|
256
|
+
/** Sync write that survives Electron's missing clipboard permission. */
|
|
257
|
+
function writeTextFallback(text) {
|
|
258
|
+
const textarea = document.createElement('textarea')
|
|
259
|
+
textarea.value = text
|
|
260
|
+
textarea.setAttribute('readonly', '')
|
|
261
|
+
textarea.style.position = 'fixed'
|
|
262
|
+
textarea.style.left = '-9999px'
|
|
263
|
+
textarea.style.opacity = '0'
|
|
264
|
+
document.body.append(textarea)
|
|
265
|
+
textarea.select()
|
|
266
|
+
let ok = false
|
|
267
|
+
try {
|
|
268
|
+
ok = document.execCommand('copy')
|
|
269
|
+
} catch {
|
|
270
|
+
ok = false
|
|
271
|
+
}
|
|
272
|
+
textarea.remove()
|
|
273
|
+
return ok
|
|
274
|
+
}
|
|
197
275
|
|
|
198
|
-
function copySource() {
|
|
276
|
+
async function copySource() {
|
|
199
277
|
const text = decodedSource.value
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
278
|
+
let ok = false
|
|
279
|
+
if (navigator.clipboard?.writeText) {
|
|
280
|
+
try {
|
|
281
|
+
await navigator.clipboard.writeText(text)
|
|
282
|
+
ok = true
|
|
283
|
+
} catch {
|
|
284
|
+
// Electron can expose Clipboard without granting the renderer's async
|
|
285
|
+
// Clipboard permission. Fall through to the synchronous user-gesture path.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!ok) ok = writeTextFallback(text)
|
|
289
|
+
copyState.value = ok ? 'copied' : 'failed'
|
|
290
|
+
if (copyResetTimer) clearTimeout(copyResetTimer)
|
|
291
|
+
copyResetTimer = setTimeout(() => {
|
|
292
|
+
copyState.value = 'idle'
|
|
293
|
+
}, COPY_RESET_DELAY)
|
|
214
294
|
}
|
|
215
295
|
|
|
216
296
|
const scale = ref(1)
|
|
@@ -257,21 +337,70 @@ function handleWheel(e) {
|
|
|
257
337
|
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale.value + delta))
|
|
258
338
|
}
|
|
259
339
|
|
|
260
|
-
function
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
340
|
+
function resetPanZoom() {
|
|
341
|
+
scale.value = 1
|
|
342
|
+
panX.value = 0
|
|
343
|
+
panY.value = 0
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function exitFullscreenOverlay() {
|
|
347
|
+
if (!isFullscreen.value && fullscreenMode == null) return
|
|
348
|
+
const root = rootRef.value
|
|
349
|
+
if (fullscreenMode === 'native' && root && document.fullscreenElement === root) {
|
|
350
|
+
void document.exitFullscreen().catch(() => {
|
|
351
|
+
/* ignore */
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
fullscreenMode = null
|
|
355
|
+
isFullscreen.value = false
|
|
356
|
+
resetPanZoom()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function toggleFullscreen() {
|
|
360
|
+
const root = rootRef.value
|
|
361
|
+
if (!root) return
|
|
362
|
+
|
|
363
|
+
if (isFullscreen.value) {
|
|
364
|
+
exitFullscreenOverlay()
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// CSS overlay first — Electron often rejects/hangs on requestFullscreen.
|
|
369
|
+
fullscreenMode = 'css'
|
|
370
|
+
isFullscreen.value = true
|
|
371
|
+
|
|
372
|
+
// Best-effort native fullscreen for browser / VitePress (non-blocking).
|
|
373
|
+
if (!document.fullscreenEnabled || typeof root.requestFullscreen !== 'function') return
|
|
374
|
+
if (/Electron/i.test(navigator.userAgent)) return
|
|
375
|
+
try {
|
|
376
|
+
await Promise.race([
|
|
377
|
+
root.requestFullscreen(),
|
|
378
|
+
new Promise((_, reject) => {
|
|
379
|
+
window.setTimeout(() => reject(new Error('fullscreen-timeout')), 500)
|
|
380
|
+
})
|
|
381
|
+
])
|
|
382
|
+
if (document.fullscreenElement === root) fullscreenMode = 'native'
|
|
383
|
+
} catch {
|
|
384
|
+
/* keep CSS overlay */
|
|
266
385
|
}
|
|
267
386
|
}
|
|
268
387
|
|
|
269
388
|
function handleFullscreenChange() {
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
389
|
+
const root = rootRef.value
|
|
390
|
+
if (root && document.fullscreenElement === root) {
|
|
391
|
+
fullscreenMode = 'native'
|
|
392
|
+
isFullscreen.value = true
|
|
393
|
+
} else if (fullscreenMode === 'native') {
|
|
394
|
+
fullscreenMode = null
|
|
395
|
+
isFullscreen.value = false
|
|
396
|
+
resetPanZoom()
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleDocumentKeydown(event) {
|
|
401
|
+
if (event.key === 'Escape' && isFullscreen.value && fullscreenMode === 'css') {
|
|
402
|
+
event.preventDefault()
|
|
403
|
+
exitFullscreenOverlay()
|
|
275
404
|
}
|
|
276
405
|
}
|
|
277
406
|
|
|
@@ -290,22 +419,25 @@ async function renderDiagram() {
|
|
|
290
419
|
const dark = detectDark()
|
|
291
420
|
lastThemeDark = dark
|
|
292
421
|
|
|
422
|
+
loading.value = true
|
|
423
|
+
error.value = null
|
|
424
|
+
|
|
293
425
|
try {
|
|
294
|
-
|
|
295
|
-
error.value = null
|
|
296
|
-
mermaid.initialize({
|
|
297
|
-
startOnLoad: false,
|
|
298
|
-
theme: dark ? 'dark' : 'default',
|
|
299
|
-
securityLevel: props.securityLevel,
|
|
300
|
-
fontFamily: 'inherit'
|
|
301
|
-
})
|
|
302
|
-
// Mermaid requires a unique DOM id per render; reusing the same id on
|
|
303
|
-
// theme re-render (common on hard refresh) clears/fails the second pass.
|
|
304
|
-
const renderId = `${idPrefix}-${++idSeq}`
|
|
305
|
-
const { svg, bindFunctions } = await mermaid.render(renderId, decodedSource.value)
|
|
426
|
+
await waitForLayoutBox(rootRef.value || diagramRef.value)
|
|
306
427
|
if (token !== renderToken || !diagramRef.value) return
|
|
307
|
-
|
|
308
|
-
|
|
428
|
+
|
|
429
|
+
await enqueueRender(async () => {
|
|
430
|
+
if (token !== renderToken || !diagramRef.value) return
|
|
431
|
+
ensureMermaidInitialized(dark, props.securityLevel)
|
|
432
|
+
// Unique render id (mermaid deletes getElementById(id) before the next
|
|
433
|
+
// draw). After insert, adoptLiveSvg renames the root so a later render
|
|
434
|
+
// cannot yank this live SVG out of Desk.
|
|
435
|
+
const renderId = `${idPrefix}-${++idSeq}`
|
|
436
|
+
const { svg, bindFunctions } = await mermaid.render(renderId, decodedSource.value)
|
|
437
|
+
if (token !== renderToken || !diagramRef.value) return
|
|
438
|
+
diagramRef.value.innerHTML = adoptLiveSvg(svg, renderId)
|
|
439
|
+
if (bindFunctions) bindFunctions(diagramRef.value)
|
|
440
|
+
})
|
|
309
441
|
} catch (err) {
|
|
310
442
|
if (token !== renderToken) return
|
|
311
443
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -330,17 +462,35 @@ function observeTheme() {
|
|
|
330
462
|
}
|
|
331
463
|
|
|
332
464
|
let themeObserver = null
|
|
465
|
+
let layoutObserver = null
|
|
333
466
|
|
|
334
467
|
onMounted(() => {
|
|
335
468
|
void renderDiagram()
|
|
336
469
|
themeObserver = observeTheme()
|
|
337
470
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
471
|
+
document.addEventListener('keydown', handleDocumentKeydown, true)
|
|
472
|
+
|
|
473
|
+
// Desk NodeViews often mount at width 0. Wait once for a real box — do not
|
|
474
|
+
// re-render on later width flicker (that cancels in-flight mermaid.render).
|
|
475
|
+
const root = rootRef.value
|
|
476
|
+
if (root && root.clientWidth === 0 && typeof ResizeObserver === 'function') {
|
|
477
|
+
layoutObserver = new ResizeObserver(() => {
|
|
478
|
+
if (root.clientWidth <= 0) return
|
|
479
|
+
layoutObserver?.disconnect()
|
|
480
|
+
layoutObserver = null
|
|
481
|
+
void renderDiagram()
|
|
482
|
+
})
|
|
483
|
+
layoutObserver.observe(root)
|
|
484
|
+
}
|
|
338
485
|
})
|
|
339
486
|
|
|
340
487
|
onBeforeUnmount(() => {
|
|
341
488
|
themeObserver?.disconnect()
|
|
489
|
+
layoutObserver?.disconnect()
|
|
342
490
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
491
|
+
document.removeEventListener('keydown', handleDocumentKeydown, true)
|
|
343
492
|
if (copyResetTimer) clearTimeout(copyResetTimer)
|
|
493
|
+
if (isFullscreen.value) exitFullscreenOverlay()
|
|
344
494
|
})
|
|
345
495
|
|
|
346
496
|
watch(
|
|
@@ -362,13 +512,23 @@ watch(
|
|
|
362
512
|
overflow: hidden;
|
|
363
513
|
color: var(--tn-c-text);
|
|
364
514
|
transition: border-color 0.2s ease;
|
|
515
|
+
|
|
516
|
+
&.is-fullscreen {
|
|
517
|
+
position: fixed;
|
|
518
|
+
inset: 0;
|
|
519
|
+
z-index: 9999;
|
|
520
|
+
margin: 0;
|
|
521
|
+
border-radius: 0;
|
|
522
|
+
border-color: var(--tn-c-divider);
|
|
523
|
+
}
|
|
365
524
|
}
|
|
366
525
|
|
|
367
526
|
.tn-mermaid:hover,
|
|
368
527
|
.tn-mermaid:has(.tn-mermaid__actions:hover),
|
|
369
528
|
.tn-mermaid:has(.tn-mermaid__actions:focus-within),
|
|
370
529
|
.tn-mermaid:has(.tn-mermaid__actions[data-copy-state='copied']),
|
|
371
|
-
.tn-mermaid:has(.tn-mermaid__actions[data-copy-state='failed'])
|
|
530
|
+
.tn-mermaid:has(.tn-mermaid__actions[data-copy-state='failed']),
|
|
531
|
+
.tn-mermaid.is-fullscreen {
|
|
372
532
|
border-color: var(--tn-c-divider);
|
|
373
533
|
}
|
|
374
534
|
|
|
@@ -392,7 +552,8 @@ watch(
|
|
|
392
552
|
.tn-mermaid__actions:hover,
|
|
393
553
|
.tn-mermaid__actions:focus-within,
|
|
394
554
|
.tn-mermaid__actions[data-copy-state='copied'],
|
|
395
|
-
.tn-mermaid__actions[data-copy-state='failed']
|
|
555
|
+
.tn-mermaid__actions[data-copy-state='failed'],
|
|
556
|
+
.tn-mermaid.is-fullscreen .tn-mermaid__actions {
|
|
396
557
|
opacity: 1;
|
|
397
558
|
}
|
|
398
559
|
|
|
@@ -473,22 +634,34 @@ watch(
|
|
|
473
634
|
min-height: 160px;
|
|
474
635
|
padding: 20px;
|
|
475
636
|
background: transparent;
|
|
637
|
+
}
|
|
476
638
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
background: var(--tn-c-bg);
|
|
639
|
+
.tn-mermaid.is-fullscreen .tn-mermaid__stage {
|
|
640
|
+
min-height: 100%;
|
|
641
|
+
height: 100%;
|
|
642
|
+
padding: 40px;
|
|
643
|
+
overflow: hidden;
|
|
644
|
+
cursor: grab;
|
|
645
|
+
background: var(--tn-c-bg);
|
|
485
646
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
647
|
+
&:active {
|
|
648
|
+
cursor: grabbing;
|
|
489
649
|
}
|
|
490
650
|
}
|
|
491
651
|
|
|
652
|
+
.tn-mermaid.is-fullscreen.is-centered .tn-mermaid__stage {
|
|
653
|
+
display: flex;
|
|
654
|
+
align-items: center;
|
|
655
|
+
justify-content: center;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.tn-mermaid.is-fullscreen.is-centered .tn-mermaid__diagram {
|
|
659
|
+
width: 100%;
|
|
660
|
+
height: 100%;
|
|
661
|
+
align-items: center;
|
|
662
|
+
justify-content: center;
|
|
663
|
+
}
|
|
664
|
+
|
|
492
665
|
.tn-mermaid__status {
|
|
493
666
|
position: absolute;
|
|
494
667
|
inset: 0;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { adoptLiveSvg } from './adoptLiveSvg'
|
|
4
|
+
|
|
5
|
+
describe('adoptLiveSvg', () => {
|
|
6
|
+
it('renames the root svg id and scoped theme selectors', () => {
|
|
7
|
+
const svg = `<svg id="tn-mermaid-1" xmlns="http://www.w3.org/2000/svg"><style>#tn-mermaid-1{fill:#ccc;}#tn-mermaid-1 .node{stroke:#fff;}#tn-mermaid-1_flowchart-v2-pointEnd{fill:red;}</style><path id="tn-mermaid-1_flowchart-v2-pointEnd"/></svg>`
|
|
8
|
+
const out = adoptLiveSvg(svg, 'tn-mermaid-1')
|
|
9
|
+
expect(out).toContain('id="tn-mermaid-1-live"')
|
|
10
|
+
expect(out).toContain('#tn-mermaid-1-live{fill:#ccc;}')
|
|
11
|
+
expect(out).toContain('#tn-mermaid-1-live .node{stroke:#fff;}')
|
|
12
|
+
expect(out).toContain('id="tn-mermaid-1_flowchart-v2-pointEnd"')
|
|
13
|
+
expect(out).toContain('#tn-mermaid-1_flowchart-v2-pointEnd{fill:red;}')
|
|
14
|
+
expect(out).not.toMatch(/id="tn-mermaid-1"/)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid's next `render(id)` deletes `document.getElementById(id)` (and
|
|
3
|
+
* `d${id}` / `i${id}` temps). Keep the theme CSS (`#id …`) working by renaming
|
|
4
|
+
* only the root svg id after we take ownership of the markup.
|
|
5
|
+
*/
|
|
6
|
+
export function adoptLiveSvg(svg: string, renderId: string): string {
|
|
7
|
+
if (!renderId || !svg.includes(renderId)) return svg
|
|
8
|
+
const liveId = `${renderId}-live`
|
|
9
|
+
const withRootId = svg.replace(/(<svg\b[^>]*?)\sid="([^"]*)"/i, (full, prefix, id) =>
|
|
10
|
+
id === renderId ? `${prefix} id="${liveId}"` : full
|
|
11
|
+
)
|
|
12
|
+
const escaped = renderId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
13
|
+
// `#tn-mermaid-1{` / `#tn-mermaid-1 .node` — not `#tn-mermaid-1_flowchart-…`
|
|
14
|
+
return withRootId.replace(new RegExp(`#${escaped}(?![\\w-])`, 'g'), `#${liveId}`)
|
|
15
|
+
}
|
|
@@ -112,7 +112,9 @@ const emit = defineEmits<{
|
|
|
112
112
|
|
|
113
113
|
function detectDark(): boolean {
|
|
114
114
|
if (typeof props.isDark === 'boolean') return props.isDark
|
|
115
|
+
if (typeof document === 'undefined') return false
|
|
115
116
|
const root = document.documentElement
|
|
117
|
+
if (root.dataset.theme === 'light') return false
|
|
116
118
|
return root.classList.contains('dark') || root.dataset.theme === 'dark'
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import CodeBlock from "./CodeBlock/CodeBlock.vue";
|
|
7
|
+
import CodeGroup from "./CodeGroup/CodeGroup.vue";
|
|
8
|
+
import ImagePreview from "./ImagePreview/ImagePreview.vue";
|
|
9
|
+
|
|
10
|
+
const cleanups: Array<() => void> = [];
|
|
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);
|
|
20
|
+
cleanups.push(() => {
|
|
21
|
+
app.unmount();
|
|
22
|
+
host.remove();
|
|
23
|
+
});
|
|
24
|
+
return host;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanups
|
|
29
|
+
.splice(0)
|
|
30
|
+
.reverse()
|
|
31
|
+
.forEach((cleanup) => cleanup());
|
|
32
|
+
document.body.innerHTML = "";
|
|
33
|
+
document.documentElement.style.overflow = "";
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
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", {
|
|
41
|
+
configurable: true,
|
|
42
|
+
value: { writeText },
|
|
43
|
+
});
|
|
44
|
+
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
|
+
});
|
|
49
|
+
|
|
50
|
+
const buttons = host.querySelectorAll<HTMLButtonElement>("button");
|
|
51
|
+
buttons[0].click();
|
|
52
|
+
await nextTick();
|
|
53
|
+
expect(writeText).toHaveBeenCalledWith("const value = 1");
|
|
54
|
+
|
|
55
|
+
buttons[1].click();
|
|
56
|
+
await nextTick();
|
|
57
|
+
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
|
58
|
+
document.body
|
|
59
|
+
.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
|
+
});
|
|
66
|
+
|
|
67
|
+
it("switches code-group tabs with click and arrow keys", async () => {
|
|
68
|
+
const host = mount(CodeGroup, {
|
|
69
|
+
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
|
+
});
|
|
85
|
+
|
|
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);
|
|
93
|
+
|
|
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
|
+
});
|
|
115
|
+
|
|
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);
|
|
123
|
+
|
|
124
|
+
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../components/BilibiliVideo/BilibiliVideo.vue";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../components/Footprints/Footprints.vue";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../components/Mermaid/Mermaid.vue";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../components/Mindmap/Mindmap.vue";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../components/WordList/WordList.vue";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
3
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
4
|
+
</svg>
|
package/src/index.ts
CHANGED
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
export { default as BilibiliVideo } from
|
|
2
|
-
export { default as WordList } from
|
|
3
|
-
export { default as Mermaid } from
|
|
4
|
-
export { default as Mindmap } from
|
|
5
|
-
export { default as FocusBreadcrumbs } from
|
|
6
|
-
export { default as NotesTable } from
|
|
7
|
-
export { default as Footprints } from
|
|
1
|
+
export { default as BilibiliVideo } from "./components/BilibiliVideo/BilibiliVideo.vue";
|
|
2
|
+
export { default as WordList } from "./components/WordList/WordList.vue";
|
|
3
|
+
export { default as Mermaid } from "./components/Mermaid/Mermaid.vue";
|
|
4
|
+
export { default as Mindmap } from "./components/Mindmap/Mindmap.vue";
|
|
5
|
+
export { default as FocusBreadcrumbs } from "./components/Mindmap/FocusBreadcrumbs.vue";
|
|
6
|
+
export { default as NotesTable } from "./components/NotesTable/NotesTable.vue";
|
|
7
|
+
export { default as Footprints } from "./components/Footprints/Footprints.vue";
|
|
8
8
|
export {
|
|
9
9
|
WORD_LIST_FEATURES_FULL,
|
|
10
10
|
WORD_LIST_FEATURES_STATIC,
|
|
11
|
-
resolveWordListFeatures
|
|
12
|
-
} from
|
|
13
|
-
export type { WordListFeatures } from
|
|
11
|
+
resolveWordListFeatures,
|
|
12
|
+
} from "./components/WordList/wordListFeatures";
|
|
13
|
+
export type { WordListFeatures } from "./components/WordList/wordListFeatures";
|
|
14
14
|
export {
|
|
15
15
|
normalizeMindmapMarkdown,
|
|
16
16
|
parseMindmapFence,
|
|
17
|
-
parseMindmapReference
|
|
18
|
-
} from
|
|
17
|
+
parseMindmapReference,
|
|
18
|
+
} from "./components/Mindmap/markdown";
|
|
19
19
|
export type {
|
|
20
20
|
MindmapFenceOptions,
|
|
21
21
|
MindmapReference,
|
|
22
|
-
NormalizeMindmapOptions
|
|
23
|
-
} from
|
|
24
|
-
export type { NotesTableRow } from
|
|
22
|
+
NormalizeMindmapOptions,
|
|
23
|
+
} from "./components/Mindmap/markdown";
|
|
24
|
+
export type { NotesTableRow } from "./components/NotesTable/types";
|
|
25
25
|
export {
|
|
26
26
|
parseFootprintsSource,
|
|
27
27
|
rebuildFootprintsSource,
|
|
28
|
-
parseFootprintsDatetime
|
|
29
|
-
} from
|
|
30
|
-
export type { FootprintsPayload } from
|
|
28
|
+
parseFootprintsDatetime,
|
|
29
|
+
} from "./components/Footprints/parse";
|
|
30
|
+
export type { FootprintsPayload } from "./components/Footprints/parse";
|
|
31
|
+
export { default as Badge } from "./components/Badge/Badge.vue";
|
|
32
|
+
export { default as CodeBlock } from "./components/CodeBlock/CodeBlock.vue";
|
|
33
|
+
export { default as CodeGroup } from "./components/CodeGroup/CodeGroup.vue";
|
|
34
|
+
export { default as ImagePreview } from "./components/ImagePreview/ImagePreview.vue";
|
|
35
|
+
export * from "./browser/clipboard";
|
|
36
|
+
export * from "./browser/theme";
|
|
37
|
+
export * from "./code/highlight";
|
|
38
|
+
export {
|
|
39
|
+
hydrateTnSwipers,
|
|
40
|
+
applySwiperTabsPadding,
|
|
41
|
+
createSwiperTabNav,
|
|
42
|
+
wrapSlideIndex,
|
|
43
|
+
} from "./swiper/hydrate";
|