@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.
Files changed (65) hide show
  1. package/README.md +30 -97
  2. package/dist/code/highlight.js +6 -15
  3. package/dist/components/Footprints/parse.js +1 -3
  4. package/dist/components/Mindmap/markdown.d.ts +1 -7
  5. package/dist/components/Mindmap/markdown.js +1 -15
  6. package/package.json +26 -9
  7. package/src/browser/clipboard.ts +16 -19
  8. package/src/browser/theme.ts +10 -15
  9. package/src/code/highlight.ts +89 -105
  10. package/src/components/Badge/Badge.vue +4 -4
  11. package/src/components/CodeBlock/CodeBlock.vue +43 -53
  12. package/src/components/CodeGroup/CodeGroup.vue +32 -35
  13. package/src/components/Footprints/Footprints.vue +2 -14
  14. package/src/components/Footprints/parse.ts +1 -3
  15. package/src/components/ImagePreview/ImagePreview.vue +101 -126
  16. package/src/components/Mermaid/Mermaid.vue +9 -12
  17. package/src/components/Mindmap/FocusBreadcrumbs.vue +31 -28
  18. package/src/components/Mindmap/InlineRuns.ts +25 -16
  19. package/src/components/Mindmap/Mindmap.vue +147 -81
  20. package/src/components/Mindmap/MindmapOutlineNode.vue +4 -1
  21. package/src/components/Mindmap/editor/AppIcon.vue +32 -11
  22. package/src/components/Mindmap/editor/CanvasContextMenu.vue +126 -49
  23. package/src/components/Mindmap/editor/LinkPopover.vue +101 -23
  24. package/src/components/Mindmap/editor/MarkdownView.vue +7 -4
  25. package/src/components/Mindmap/editor/MindmapView.vue +11 -3
  26. package/src/components/Mindmap/editor/OutlineView.vue +144 -58
  27. package/src/components/Mindmap/editor/RichInlineEditor.vue +93 -44
  28. package/src/components/Mindmap/editor/SelectionToolbar.vue +133 -41
  29. package/src/components/Mindmap/editor/canvasClipboard.ts +1 -5
  30. package/src/components/Mindmap/editor/imagePaste.ts +1 -1
  31. package/src/components/Mindmap/editor/mindmapClipboard.ts +17 -5
  32. package/src/components/Mindmap/editor/outlineDrag.ts +1 -1
  33. package/src/components/Mindmap/markdown.ts +10 -29
  34. package/src/components/WordList/RightClickMenu.vue +10 -35
  35. package/src/components/WordList/WordList.vue +19 -42
  36. package/src/entries/bilibili-video.ts +1 -1
  37. package/src/entries/excalidraw-editor.ts +20 -0
  38. package/src/entries/excalidraw-view.ts +15 -0
  39. package/src/entries/footprints.ts +1 -1
  40. package/src/entries/mermaid.ts +1 -1
  41. package/src/entries/mindmap.ts +1 -1
  42. package/src/entries/notes-table.ts +2 -2
  43. package/src/entries/word-list.ts +1 -1
  44. package/src/excalidraw/ExcalidrawSvg.vue +125 -0
  45. package/src/excalidraw/editorHost.ts +110 -0
  46. package/src/excalidraw/editorSession.ts +224 -0
  47. package/src/excalidraw/exporter.ts +50 -0
  48. package/src/excalidraw/focus.ts +33 -0
  49. package/src/excalidraw/fonts.ts +92 -0
  50. package/src/excalidraw/renderer.ts +105 -0
  51. package/src/excalidraw/scene.ts +93 -0
  52. package/src/index.ts +25 -33
  53. package/src/markdown/image.ts +4 -13
  54. package/src/styles/code.css +5 -5
  55. package/src/styles/prose.css +1 -1
  56. package/src/styles/swiper.css +1 -1
  57. package/src/styles/tokens.css +5 -6
  58. package/src/swiper/hydrate.ts +91 -102
  59. package/src/code/highlight.test.ts +0 -55
  60. package/src/components/Mermaid/Mermaid.test.ts +0 -54
  61. package/src/components/Mermaid/adoptLiveSvg.test.ts +0 -16
  62. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +0 -265
  63. package/src/components/sharedMedia.test.ts +0 -139
  64. package/src/markdown/image.test.ts +0 -88
  65. package/src/swiper/hydrate.test.ts +0 -65
@@ -1,265 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { createApp, defineComponent, h, nextTick, ref } from 'vue'
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
- import type { MindmapNode } from '@tnotesjs/mindmap-core'
5
- import { MindmapSession, resetNodeIdCounter } from '@tnotesjs/mindmap-core'
6
- import FocusBreadcrumbs from './FocusBreadcrumbs.vue'
7
-
8
- interface DeepFixture {
9
- session: MindmapSession
10
- current: MindmapNode[]
11
- other: MindmapNode[]
12
- }
13
-
14
- let disposeMounted: (() => void) | null = null
15
- let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView | undefined
16
-
17
- function deepMarkdown(depth: number): string {
18
- const lines = ['# 根主题', '']
19
- const appendLevel = (level: number) => {
20
- const indent = ' '.repeat(level - 1)
21
- lines.push(`${indent}- 当前-${level}`)
22
- if (level < depth) appendLevel(level + 1)
23
- lines.push(`${indent}- 其它-${level}`)
24
- }
25
- appendLevel(1)
26
- return `${lines.join('\n')}\n`
27
- }
28
-
29
- function makeDeepFixture(depth: number): DeepFixture {
30
- const session = new MindmapSession({ markdown: deepMarkdown(depth) })
31
- const current: MindmapNode[] = []
32
- const other: MindmapNode[] = []
33
- let parent = session.document.root
34
- for (let level = 0; level < depth; level++) {
35
- current.push(parent.children[0])
36
- other.push(parent.children[1])
37
- parent = parent.children[0]
38
- }
39
- session.focusNode(current[current.length - 1].id)
40
- return { session, current, other }
41
- }
42
-
43
- function mountBreadcrumbs(session: MindmapSession) {
44
- const version = ref(0)
45
- const bump = () => version.value++
46
- session.on('focusChange', bump)
47
- session.on('selectionChange', bump)
48
- session.on('change', bump)
49
- const Host = defineComponent({
50
- setup() {
51
- return () => h(FocusBreadcrumbs, { session, version: version.value })
52
- },
53
- })
54
- const host = document.createElement('div')
55
- document.body.append(host)
56
- const app = createApp(Host)
57
- app.mount(host)
58
- disposeMounted = () => {
59
- app.unmount()
60
- session.off('focusChange', bump)
61
- session.off('selectionChange', bump)
62
- session.off('change', bump)
63
- host.remove()
64
- }
65
- return host
66
- }
67
-
68
- async function settle() {
69
- await nextTick()
70
- await nextTick()
71
- }
72
-
73
- async function hoverOpen(button: HTMLButtonElement) {
74
- button.dispatchEvent(new MouseEvent('mouseenter'))
75
- await vi.advanceTimersByTimeAsync(181)
76
- await settle()
77
- return document.body.querySelector<HTMLElement>('.focus-sibling-menu')
78
- }
79
-
80
- beforeEach(() => {
81
- resetNodeIdCounter()
82
- vi.useFakeTimers()
83
- originalScrollIntoView = HTMLElement.prototype.scrollIntoView
84
- HTMLElement.prototype.scrollIntoView = vi.fn()
85
- })
86
-
87
- afterEach(() => {
88
- disposeMounted?.()
89
- disposeMounted = null
90
- document.body.innerHTML = ''
91
- if (originalScrollIntoView) HTMLElement.prototype.scrollIntoView = originalScrollIntoView
92
- else delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView
93
- vi.useRealTimers()
94
- })
95
-
96
- describe('FocusBreadcrumbs', () => {
97
- it('完整渲染 12 层路径并自动把当前项滚入可视区域', async () => {
98
- const { session, current } = makeDeepFixture(12)
99
- const host = mountBreadcrumbs(session)
100
- await settle()
101
-
102
- const nav = host.querySelector('[aria-label="主题导航"]')
103
- expect(nav).not.toBeNull()
104
- const crumbs = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')]
105
- expect(crumbs).toHaveLength(13)
106
- expect(crumbs.map((item) => item.textContent?.trim())).toEqual([
107
- '全部',
108
- ...current.map((node) => node.content.text),
109
- ])
110
- expect(crumbs.map((item) => item.dataset.nodeId)).toEqual([
111
- session.document.root.id,
112
- ...current.map((node) => node.id),
113
- ])
114
- expect(crumbs[crumbs.length - 1]?.getAttribute('aria-current')).toBe('page')
115
- expect(crumbs.slice(0, -1).every((item) => !item.hasAttribute('aria-current'))).toBe(true)
116
- expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
117
- block: 'nearest',
118
- inline: 'nearest',
119
- })
120
- })
121
-
122
- it('每一层 hover 都展示该节点的同层主题,并正确标记当前项', async () => {
123
- const { session, current } = makeDeepFixture(4)
124
- const host = mountBreadcrumbs(session)
125
- await settle()
126
- const crumbs = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')].slice(1)
127
-
128
- for (let depth = 0; depth < current.length; depth++) {
129
- const menu = await hoverOpen(crumbs[depth])
130
- expect(menu).not.toBeNull()
131
- const expected = current[depth].parent!.children
132
- const items = [...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
133
- expect(items.map((item) => item.dataset.nodeId)).toEqual(expected.map((node) => node.id))
134
- expect(items.map((item) => item.textContent?.replace('✓', '').trim())).toEqual(
135
- expected.map((node) => node.content.text),
136
- )
137
- expect(items.find((item) => item.getAttribute('aria-checked') === 'true')?.dataset.nodeId)
138
- .toBe(current[depth].id)
139
- document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
140
- await settle()
141
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
142
- }
143
- })
144
-
145
- it('重名同层主题按节点 id 快速切换,并收敛聚焦路径和选择', async () => {
146
- const { session, current, other } = makeDeepFixture(3)
147
- session.updateNodeRaw(other[1].id, current[1].content.raw)
148
- const host = mountBreadcrumbs(session)
149
- await settle()
150
- const secondLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][2]
151
- const menu = await hoverOpen(secondLevel)
152
- const duplicateItems = [...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
153
- .filter((item) => item.textContent?.replace('✓', '').trim() === current[1].content.text)
154
-
155
- expect(duplicateItems).toHaveLength(2)
156
- menu!.querySelector<HTMLButtonElement>(`[data-node-id="${other[1].id}"]`)!.click()
157
- await settle()
158
-
159
- expect(session.focusRootNode.id).toBe(other[1].id)
160
- expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id, other[1].id])
161
- expect([...session.selectionIds]).toEqual([other[1].id])
162
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
163
- const updated = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')]
164
- expect(updated.map((item) => item.dataset.nodeId)).toEqual([
165
- session.document.root.id,
166
- current[0].id,
167
- other[1].id,
168
- ])
169
- })
170
-
171
- it('点击祖先会选中该主题,点击全部会退出聚焦并清空选择', async () => {
172
- const { session, current } = makeDeepFixture(3)
173
- session.select(current[2].id)
174
- const host = mountBreadcrumbs(session)
175
- await settle()
176
-
177
- const firstLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
178
- firstLevel.click()
179
- await settle()
180
- expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id])
181
- expect(session.focusRootNode.id).toBe(current[0].id)
182
- expect([...session.selectionIds]).toEqual([current[0].id])
183
-
184
- host.querySelector<HTMLButtonElement>('.focus-crumb-root')!.click()
185
- await settle()
186
- expect(session.focusPath).toEqual([])
187
- expect(session.focusRootNode).toBe(session.document.root)
188
- expect(session.selectionIds.size).toBe(0)
189
- expect(host.querySelector('[aria-label="主题导航"]')).toBeNull()
190
- })
191
-
192
- it('允许鼠标从主题跨入菜单,真正离开或点击外部后才关闭', async () => {
193
- const { session } = makeDeepFixture(2)
194
- const host = mountBreadcrumbs(session)
195
- await settle()
196
- const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
197
-
198
- trigger.dispatchEvent(new MouseEvent('mouseenter'))
199
- await vi.advanceTimersByTimeAsync(179)
200
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
201
- await vi.advanceTimersByTimeAsync(2)
202
- await settle()
203
- const menu = document.body.querySelector<HTMLElement>('.focus-sibling-menu')!
204
- expect(menu).not.toBeNull()
205
- expect(trigger.getAttribute('aria-expanded')).toBe('true')
206
-
207
- trigger.dispatchEvent(new MouseEvent('mouseleave'))
208
- menu.dispatchEvent(new MouseEvent('mouseenter'))
209
- await vi.advanceTimersByTimeAsync(300)
210
- expect(document.body.querySelector('.focus-sibling-menu')).toBe(menu)
211
-
212
- menu.dispatchEvent(new MouseEvent('mouseleave'))
213
- await vi.advanceTimersByTimeAsync(181)
214
- await settle()
215
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
216
- expect(trigger.getAttribute('aria-expanded')).toBe('false')
217
-
218
- await hoverOpen(trigger)
219
- document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
220
- await settle()
221
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
222
- })
223
-
224
- it('支持键盘打开和遍历菜单,Escape 关闭后把焦点还给主题', async () => {
225
- const { session } = makeDeepFixture(2)
226
- const host = mountBreadcrumbs(session)
227
- await settle()
228
- const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
229
- trigger.focus()
230
- trigger.dispatchEvent(new KeyboardEvent('keydown', {
231
- key: 'ArrowDown',
232
- bubbles: true,
233
- cancelable: true,
234
- }))
235
- await settle()
236
-
237
- const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
238
- const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
239
- expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
240
- expect(trigger.getAttribute('aria-expanded')).toBe('true')
241
- expect(document.activeElement).toBe(items[0])
242
-
243
- items[0].dispatchEvent(new KeyboardEvent('keydown', {
244
- key: 'End',
245
- bubbles: true,
246
- cancelable: true,
247
- }))
248
- expect(document.activeElement).toBe(items[items.length - 1])
249
- items[items.length - 1]!.dispatchEvent(new KeyboardEvent('keydown', {
250
- key: 'ArrowDown',
251
- bubbles: true,
252
- cancelable: true,
253
- }))
254
- expect(document.activeElement).toBe(items[0])
255
-
256
- items[0].dispatchEvent(new KeyboardEvent('keydown', {
257
- key: 'Escape',
258
- bubbles: true,
259
- cancelable: true,
260
- }))
261
- await settle()
262
- expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
263
- expect(document.activeElement).toBe(trigger)
264
- })
265
- })
@@ -1,139 +0,0 @@
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
- });
@@ -1,88 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import {
4
- formatImageAttrs,
5
- formatImageWidthAttr,
6
- normalizeImageAlign,
7
- normalizeImageWidth,
8
- parseImageAttrs,
9
- parseImageWidthAttr,
10
- pasteDisplayWidthPx,
11
- serializeImageMarkdown
12
- } from './image'
13
-
14
- describe('image markdown helpers', () => {
15
- it('accepts w and width, with or without a percent sign', () => {
16
- expect(parseImageWidthAttr(' {w=50%} ')).toEqual({ width: '50%', rest: '' })
17
- expect(parseImageWidthAttr('{width=33}')).toEqual({ width: '33%', rest: '' })
18
- expect(parseImageWidthAttr('not-an-attr')).toEqual({
19
- width: '',
20
- rest: 'not-an-attr'
21
- })
22
- })
23
-
24
- it('folds pasted bitmap width by device pixel ratio', () => {
25
- expect(pasteDisplayWidthPx(800, 2)).toBe('400px')
26
- expect(pasteDisplayWidthPx(400, 1)).toBe('400px')
27
- expect(pasteDisplayWidthPx(20, 2)).toBe('')
28
- expect(pasteDisplayWidthPx(0, 2)).toBe('')
29
- })
30
-
31
- it('accepts pixel widths from drag-resize', () => {
32
- expect(parseImageWidthAttr('{w=400px}')).toEqual({ width: '400px', rest: '' })
33
- expect(normalizeImageWidth('400px')).toBe('400px')
34
- expect(normalizeImageWidth('8px')).toBe('')
35
- })
36
-
37
- it('parses alignment beside width in one brace group', () => {
38
- expect(parseImageAttrs('{w=50% align=center}')).toEqual({
39
- width: '50%',
40
- align: 'center',
41
- rest: ''
42
- })
43
- expect(parseImageAttrs('{align=right}')).toEqual({
44
- width: '',
45
- align: 'right',
46
- rest: ''
47
- })
48
- expect(parseImageAttrs('{w=400px,align=center}')).toEqual({
49
- width: '400px',
50
- align: 'center',
51
- rest: ''
52
- })
53
- expect(normalizeImageAlign('middle')).toBe('center')
54
- expect(formatImageAttrs({ width: '400px', align: 'center' })).toBe(
55
- '{w=400px align=center}'
56
- )
57
- expect(formatImageAttrs({ align: 'left' })).toBe('')
58
- })
59
-
60
- it('rejects empty, zero, and over-wide values', () => {
61
- expect(normalizeImageWidth('')).toBe('')
62
- expect(normalizeImageWidth('0%')).toBe('')
63
- expect(normalizeImageWidth('120%')).toBe('')
64
- expect(normalizeImageWidth('auto')).toBe('')
65
- })
66
-
67
- it('serializes caption as alt and omits natural width', () => {
68
- expect(
69
- serializeImageMarkdown({
70
- alt: '知识库资源示意',
71
- src: '../assets/pixel.svg',
72
- width: '50%'
73
- })
74
- ).toBe('![知识库资源示意](../assets/pixel.svg) {w=50%}')
75
- expect(serializeImageMarkdown({ alt: '', src: '../assets/pixel.svg' })).toBe(
76
- '![](../assets/pixel.svg)'
77
- )
78
- expect(formatImageWidthAttr('100')).toBe('{w=100%}')
79
- expect(
80
- serializeImageMarkdown({
81
- alt: '',
82
- src: '../assets/shot.png',
83
- width: '400px',
84
- align: 'right'
85
- })
86
- ).toBe('![](../assets/shot.png) {w=400px align=right}')
87
- })
88
- })
@@ -1,65 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import { describe, expect, it } from "vitest";
4
-
5
- import { hydrateTnSwipers } from "./hydrate";
6
-
7
- function swiperShell(titles: string[]): HTMLElement {
8
- const root = document.createElement("div");
9
- root.innerHTML = `
10
- <div class="tn-swiper">
11
- <div class="tn-swiper-tabs"></div>
12
- <div class="swiper-container">
13
- <div class="swiper-wrapper">
14
- ${titles
15
- .map(
16
- (title) =>
17
- `<div class="swiper-slide" data-title="${title}"><img alt="${title}" src="/${title}.svg"></div>`,
18
- )
19
- .join("")}
20
- </div>
21
- </div>
22
- </div>
23
- `;
24
- document.body.append(root);
25
- return root;
26
- }
27
-
28
- describe("hydrateTnSwipers", () => {
29
- it("builds tabs and shows only the active slide", () => {
30
- const root = swiperShell(["A", "B", "C"]);
31
- expect(hydrateTnSwipers(root)).toBe(1);
32
-
33
- const host = root.querySelector(".tn-swiper")!;
34
- const tabs = [...host.querySelectorAll(".tn-tab")].map(
35
- (tab) => tab.textContent,
36
- );
37
- expect(tabs).toEqual(["A", "B", "C"]);
38
- expect(host.querySelectorAll(".tn-tab-nav")).toHaveLength(3); // < / >
39
-
40
- const slides = [...host.querySelectorAll(".swiper-slide")] as HTMLElement[];
41
- expect(slides[0]!.classList.contains("is-active")).toBe(true);
42
- expect(slides[0]!.hidden).toBe(false);
43
- expect(slides[1]!.hidden).toBe(true);
44
- expect(slides[2]!.hidden).toBe(true);
45
-
46
- ;(host.querySelectorAll(".tn-tab")[1] as HTMLButtonElement).click();
47
- expect(slides[0]!.hidden).toBe(true);
48
- expect(slides[1]!.hidden).toBe(false);
49
- expect(slides[1]!.classList.contains("is-active")).toBe(true);
50
-
51
- // Idempotent
52
- expect(hydrateTnSwipers(root)).toBe(0);
53
- root.remove();
54
- });
55
-
56
- it("omits the tab bar for a single slide", () => {
57
- const root = swiperShell(["solo"]);
58
- hydrateTnSwipers(root);
59
- expect(root.querySelector(".tn-swiper-tabs")).toBeNull();
60
- expect(root.querySelector(".swiper-slide")!.classList.contains("is-active")).toBe(
61
- true,
62
- );
63
- root.remove();
64
- });
65
- });