@tnotesjs/ui 0.1.1 → 0.1.3

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 (41) hide show
  1. package/README.md +52 -29
  2. package/dist/code/highlight.d.ts +42 -0
  3. package/dist/code/highlight.js +138 -0
  4. package/dist/components/Footprints/parse.d.ts +17 -0
  5. package/dist/components/Footprints/parse.js +105 -0
  6. package/dist/components/Mindmap/markdown.d.ts +20 -0
  7. package/dist/components/Mindmap/markdown.js +60 -0
  8. package/dist/markdown/image.d.ts +33 -0
  9. package/dist/markdown/image.js +83 -0
  10. package/package.json +46 -6
  11. package/src/browser/clipboard.ts +32 -0
  12. package/src/browser/theme.ts +22 -0
  13. package/src/code/highlight.test.ts +55 -0
  14. package/src/code/highlight.ts +171 -0
  15. package/src/components/Badge/Badge.vue +43 -0
  16. package/src/components/CodeBlock/CodeBlock.vue +172 -0
  17. package/src/components/CodeGroup/CodeGroup.vue +96 -0
  18. package/src/components/ImagePreview/ImagePreview.vue +315 -0
  19. package/src/components/Mermaid/Mermaid.test.ts +54 -0
  20. package/src/components/Mermaid/Mermaid.vue +229 -56
  21. package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
  22. package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
  23. package/src/components/Mindmap/Mindmap.vue +2 -0
  24. package/src/components/sharedMedia.test.ts +139 -0
  25. package/src/entries/bilibili-video.ts +1 -0
  26. package/src/entries/footprints.ts +1 -0
  27. package/src/entries/mermaid.ts +1 -0
  28. package/src/entries/mindmap.ts +1 -0
  29. package/src/entries/notes-table.ts +2 -0
  30. package/src/entries/word-list.ts +1 -0
  31. package/src/icons/check.svg +3 -0
  32. package/src/icons/copy.svg +4 -0
  33. package/src/index.ts +31 -18
  34. package/src/markdown/image.test.ts +88 -0
  35. package/src/markdown/image.ts +115 -0
  36. package/src/styles/code.css +184 -0
  37. package/src/styles/prose.css +211 -0
  38. package/src/styles/swiper.css +151 -0
  39. package/src/styles/tokens.css +54 -18
  40. package/src/swiper/hydrate.test.ts +65 -0
  41. package/src/swiper/hydrate.ts +164 -0
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <div ref="rootRef" class="tn-mermaid" :class="{ 'is-centered': centered }">
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': loading || empty || !!error }"
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
- navigator.clipboard
201
- .writeText(text)
202
- .then(() => {
203
- copyState.value = 'copied'
204
- })
205
- .catch(() => {
206
- copyState.value = 'failed'
207
- })
208
- .finally(() => {
209
- if (copyResetTimer) clearTimeout(copyResetTimer)
210
- copyResetTimer = setTimeout(() => {
211
- copyState.value = 'idle'
212
- }, COPY_RESET_DELAY)
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 toggleFullscreen() {
261
- if (!rootRef.value) return
262
- if (!isFullscreen.value) {
263
- rootRef.value.requestFullscreen?.()
264
- } else {
265
- document.exitFullscreen?.()
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
- isFullscreen.value = !!document.fullscreenElement
271
- if (!isFullscreen.value) {
272
- scale.value = 1
273
- panX.value = 0
274
- panY.value = 0
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
- loading.value = true
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
- diagramRef.value.innerHTML = svg
308
- if (bindFunctions) bindFunctions(diagramRef.value)
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
- &.is-fullscreen {
478
- position: fixed;
479
- inset: 0;
480
- z-index: 9999;
481
- padding: 40px;
482
- overflow: hidden;
483
- cursor: grab;
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
- &:active {
487
- cursor: grabbing;
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,2 @@
1
+ export { default } from "../components/NotesTable/NotesTable.vue";
2
+ export type { NotesTableRow } from "../components/NotesTable/types";
@@ -0,0 +1 @@
1
+ export { default } from "../components/WordList/WordList.vue";
@@ -0,0 +1,3 @@
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
+ <polyline points="20 6 9 17 4 12"></polyline>
3
+ </svg>
@@ -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 './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'
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 './components/WordList/wordListFeatures'
13
- export type { WordListFeatures } from './components/WordList/wordListFeatures'
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 './components/Mindmap/markdown'
17
+ parseMindmapReference,
18
+ } from "./components/Mindmap/markdown";
19
19
  export type {
20
20
  MindmapFenceOptions,
21
21
  MindmapReference,
22
- NormalizeMindmapOptions
23
- } from './components/Mindmap/markdown'
24
- export type { NotesTableRow } from './components/NotesTable/types'
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 './components/Footprints/parse'
30
- export type { FootprintsPayload } from './components/Footprints/parse'
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";