@tnotesjs/ui 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/code/highlight.test.ts +0 -72
- package/src/components/Mermaid/Mermaid.test.ts +0 -54
- package/src/components/Mermaid/adoptLiveSvg.test.ts +0 -16
- package/src/components/Mindmap/FocusBreadcrumbs.test.ts +0 -275
- package/src/components/sharedMedia.test.ts +0 -121
- package/src/excalidraw/editorSession.test.ts +0 -205
- package/src/excalidraw/focus.test.ts +0 -60
- package/src/excalidraw/fonts.test.ts +0 -66
- package/src/excalidraw/renderer.test.ts +0 -148
- package/src/markdown/image.test.ts +0 -86
- package/src/swiper/hydrate.test.ts +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tnotesjs/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shared Vue UI for TNotes built-in blocks — consumed by the TNotes SSG and Desk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -66,7 +66,8 @@
|
|
|
66
66
|
"dist",
|
|
67
67
|
"src",
|
|
68
68
|
"README.md",
|
|
69
|
-
"LICENSE"
|
|
69
|
+
"LICENSE",
|
|
70
|
+
"!src/**/*.test.ts"
|
|
70
71
|
],
|
|
71
72
|
"sideEffects": [
|
|
72
73
|
"**/*.css"
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
highlightCode,
|
|
4
|
-
highlightCodeSync,
|
|
5
|
-
parseCodeMeta,
|
|
6
|
-
prepareCodeHighlighter
|
|
7
|
-
} from './highlight'
|
|
8
|
-
|
|
9
|
-
describe('shared code rendering', () => {
|
|
10
|
-
it('parses titles, line offsets, highlight ranges and per-block overrides', () => {
|
|
11
|
-
expect(parseCodeMeta('ts:line-numbers=12 {1,3-5} [demo.ts]')).toEqual({
|
|
12
|
-
language: 'ts',
|
|
13
|
-
title: 'demo.ts',
|
|
14
|
-
startLine: 12,
|
|
15
|
-
lineNumbers: true,
|
|
16
|
-
highlightedLines: [1, 3, 4, 5]
|
|
17
|
-
})
|
|
18
|
-
expect(parseCodeMeta('js {1-2} [setup.js]')).toEqual({
|
|
19
|
-
language: 'js',
|
|
20
|
-
title: 'setup.js',
|
|
21
|
-
startLine: 1,
|
|
22
|
-
lineNumbers: true,
|
|
23
|
-
highlightedLines: [1, 2]
|
|
24
|
-
})
|
|
25
|
-
expect(parseCodeMeta('txt {3}')).toEqual({
|
|
26
|
-
language: 'txt',
|
|
27
|
-
title: '',
|
|
28
|
-
startLine: 1,
|
|
29
|
-
lineNumbers: true,
|
|
30
|
-
highlightedLines: [3]
|
|
31
|
-
})
|
|
32
|
-
expect(parseCodeMeta('text:no-line-numbers').lineNumbers).toBe(false)
|
|
33
|
-
expect(parseCodeMeta('text {0,-1,8-3,1-9999999}').highlightedLines).toEqual([])
|
|
34
|
-
})
|
|
35
|
-
it('shares one lazy engine and matches build-time/browser output byte for byte', async () => {
|
|
36
|
-
const [a, b] = await Promise.all([
|
|
37
|
-
prepareCodeHighlighter(['js']),
|
|
38
|
-
prepareCodeHighlighter(['typescript'])
|
|
39
|
-
])
|
|
40
|
-
expect(a).toBe(b)
|
|
41
|
-
const source = 'const answer = 42\nconsole.log(answer)\n'
|
|
42
|
-
const runtime = await highlightCode(source, 'js {2}')
|
|
43
|
-
expect(runtime).toBe(highlightCodeSync(source, 'js {2}'))
|
|
44
|
-
expect(runtime).toContain('--shiki-light:')
|
|
45
|
-
expect(runtime).toContain('--shiki-dark:')
|
|
46
|
-
expect(runtime).toContain('class="line highlighted"')
|
|
47
|
-
expect(runtime).toContain('data-line="2"')
|
|
48
|
-
expect(a.getLoadedLanguages()).not.toContain('cobol')
|
|
49
|
-
})
|
|
50
|
-
it('escapes unknown-language code instead of executing HTML', async () => {
|
|
51
|
-
const html = await highlightCode('<script>alert(1)</script> {{ injected }}', 'unknown-label')
|
|
52
|
-
expect(html).not.toContain('<script>')
|
|
53
|
-
const host = document.createElement('div')
|
|
54
|
-
host.innerHTML = html
|
|
55
|
-
expect(host.querySelector('script')).toBeNull()
|
|
56
|
-
expect(host.textContent).toBe('<script>alert(1)</script> {{ injected }}')
|
|
57
|
-
expect(html).toContain('{{ injected }}')
|
|
58
|
-
})
|
|
59
|
-
it('applies fence line highlights and offsets without Shiki notation', async () => {
|
|
60
|
-
const withNotation = await highlightCode(
|
|
61
|
-
'const x = 1 // [!code ++]\nconst y = 2 // [!code focus]',
|
|
62
|
-
'js:line-numbers=5 {2}'
|
|
63
|
-
)
|
|
64
|
-
// Magic comments stay as source text; Desk has no annotation write path.
|
|
65
|
-
expect(withNotation).toContain('[!code ++]')
|
|
66
|
-
expect(withNotation).not.toContain('diff add')
|
|
67
|
-
expect(withNotation).not.toContain('focused')
|
|
68
|
-
expect(withNotation).toContain('data-line="5"')
|
|
69
|
-
expect(withNotation).toContain('data-line="6"')
|
|
70
|
-
expect(withNotation).toContain('class="line highlighted"')
|
|
71
|
-
})
|
|
72
|
-
})
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import { createApp, defineComponent, h, nextTick, type Component } from 'vue'
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
-
|
|
6
|
-
import Mermaid from './Mermaid.vue'
|
|
7
|
-
|
|
8
|
-
const cleanups: Array<() => void> = []
|
|
9
|
-
|
|
10
|
-
function mount(component: Component, props: Record<string, unknown> = {}): HTMLElement {
|
|
11
|
-
const host = document.createElement('div')
|
|
12
|
-
document.body.append(host)
|
|
13
|
-
const app = createApp(defineComponent({ render: () => h(component, props) }))
|
|
14
|
-
app.mount(host)
|
|
15
|
-
cleanups.push(() => {
|
|
16
|
-
app.unmount()
|
|
17
|
-
host.remove()
|
|
18
|
-
})
|
|
19
|
-
return host
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
cleanups
|
|
24
|
-
.splice(0)
|
|
25
|
-
.reverse()
|
|
26
|
-
.forEach((cleanup) => cleanup())
|
|
27
|
-
document.body.innerHTML = ''
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
describe('Mermaid preview host', () => {
|
|
31
|
-
it('keeps the diagram host laid out while loading (note 0020 diagrams)', async () => {
|
|
32
|
-
const sources = [
|
|
33
|
-
`flowchart LR\n Desk --> UI`,
|
|
34
|
-
`sequenceDiagram\n participant Desk\n participant Site\n Desk->>Site: hi`,
|
|
35
|
-
`graph TD\n A[Start] --> B{ok}`
|
|
36
|
-
]
|
|
37
|
-
for (const source of sources) {
|
|
38
|
-
const host = mount(Mermaid, { source })
|
|
39
|
-
await nextTick()
|
|
40
|
-
const diagram = host.querySelector('.tn-mermaid__diagram')
|
|
41
|
-
expect(diagram).not.toBeNull()
|
|
42
|
-
// Mermaid 11 needs layout during mermaid.render(); do not visibility:hidden the host.
|
|
43
|
-
expect(diagram!.classList.contains('is-obscured')).toBe(false)
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('only obscures the host for empty or error states', async () => {
|
|
48
|
-
const emptyHost = mount(Mermaid, { source: '' })
|
|
49
|
-
await nextTick()
|
|
50
|
-
expect(emptyHost.querySelector('.tn-mermaid__diagram')!.classList.contains('is-obscured')).toBe(
|
|
51
|
-
true
|
|
52
|
-
)
|
|
53
|
-
})
|
|
54
|
-
})
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,275 +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(
|
|
138
|
-
items.find((item) => item.getAttribute('aria-checked') === 'true')?.dataset.nodeId
|
|
139
|
-
).toBe(current[depth].id)
|
|
140
|
-
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
|
141
|
-
await settle()
|
|
142
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('重名同层主题按节点 id 快速切换,并收敛聚焦路径和选择', async () => {
|
|
147
|
-
const { session, current, other } = makeDeepFixture(3)
|
|
148
|
-
session.updateNodeRaw(other[1].id, current[1].content.raw)
|
|
149
|
-
const host = mountBreadcrumbs(session)
|
|
150
|
-
await settle()
|
|
151
|
-
const secondLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][2]
|
|
152
|
-
const menu = await hoverOpen(secondLevel)
|
|
153
|
-
const duplicateItems = [
|
|
154
|
-
...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
|
155
|
-
].filter((item) => item.textContent?.replace('✓', '').trim() === current[1].content.text)
|
|
156
|
-
|
|
157
|
-
expect(duplicateItems).toHaveLength(2)
|
|
158
|
-
menu!.querySelector<HTMLButtonElement>(`[data-node-id="${other[1].id}"]`)!.click()
|
|
159
|
-
await settle()
|
|
160
|
-
|
|
161
|
-
expect(session.focusRootNode.id).toBe(other[1].id)
|
|
162
|
-
expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id, other[1].id])
|
|
163
|
-
expect([...session.selectionIds]).toEqual([other[1].id])
|
|
164
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
165
|
-
const updated = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')]
|
|
166
|
-
expect(updated.map((item) => item.dataset.nodeId)).toEqual([
|
|
167
|
-
session.document.root.id,
|
|
168
|
-
current[0].id,
|
|
169
|
-
other[1].id
|
|
170
|
-
])
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
it('点击祖先会选中该主题,点击全部会退出聚焦并清空选择', async () => {
|
|
174
|
-
const { session, current } = makeDeepFixture(3)
|
|
175
|
-
session.select(current[2].id)
|
|
176
|
-
const host = mountBreadcrumbs(session)
|
|
177
|
-
await settle()
|
|
178
|
-
|
|
179
|
-
const firstLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
|
|
180
|
-
firstLevel.click()
|
|
181
|
-
await settle()
|
|
182
|
-
expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id])
|
|
183
|
-
expect(session.focusRootNode.id).toBe(current[0].id)
|
|
184
|
-
expect([...session.selectionIds]).toEqual([current[0].id])
|
|
185
|
-
|
|
186
|
-
host.querySelector<HTMLButtonElement>('.focus-crumb-root')!.click()
|
|
187
|
-
await settle()
|
|
188
|
-
expect(session.focusPath).toEqual([])
|
|
189
|
-
expect(session.focusRootNode).toBe(session.document.root)
|
|
190
|
-
expect(session.selectionIds.size).toBe(0)
|
|
191
|
-
expect(host.querySelector('[aria-label="主题导航"]')).toBeNull()
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('允许鼠标从主题跨入菜单,真正离开或点击外部后才关闭', async () => {
|
|
195
|
-
const { session } = makeDeepFixture(2)
|
|
196
|
-
const host = mountBreadcrumbs(session)
|
|
197
|
-
await settle()
|
|
198
|
-
const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
|
|
199
|
-
|
|
200
|
-
trigger.dispatchEvent(new MouseEvent('mouseenter'))
|
|
201
|
-
await vi.advanceTimersByTimeAsync(179)
|
|
202
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
203
|
-
await vi.advanceTimersByTimeAsync(2)
|
|
204
|
-
await settle()
|
|
205
|
-
const menu = document.body.querySelector<HTMLElement>('.focus-sibling-menu')!
|
|
206
|
-
expect(menu).not.toBeNull()
|
|
207
|
-
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
208
|
-
|
|
209
|
-
trigger.dispatchEvent(new MouseEvent('mouseleave'))
|
|
210
|
-
menu.dispatchEvent(new MouseEvent('mouseenter'))
|
|
211
|
-
await vi.advanceTimersByTimeAsync(300)
|
|
212
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBe(menu)
|
|
213
|
-
|
|
214
|
-
menu.dispatchEvent(new MouseEvent('mouseleave'))
|
|
215
|
-
await vi.advanceTimersByTimeAsync(181)
|
|
216
|
-
await settle()
|
|
217
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
218
|
-
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
219
|
-
|
|
220
|
-
await hoverOpen(trigger)
|
|
221
|
-
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
|
222
|
-
await settle()
|
|
223
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('支持键盘打开和遍历菜单,Escape 关闭后把焦点还给主题', async () => {
|
|
227
|
-
const { session } = makeDeepFixture(2)
|
|
228
|
-
const host = mountBreadcrumbs(session)
|
|
229
|
-
await settle()
|
|
230
|
-
const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
|
|
231
|
-
trigger.focus()
|
|
232
|
-
trigger.dispatchEvent(
|
|
233
|
-
new KeyboardEvent('keydown', {
|
|
234
|
-
key: 'ArrowDown',
|
|
235
|
-
bubbles: true,
|
|
236
|
-
cancelable: true
|
|
237
|
-
})
|
|
238
|
-
)
|
|
239
|
-
await settle()
|
|
240
|
-
|
|
241
|
-
const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
|
|
242
|
-
const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
|
|
243
|
-
expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
|
|
244
|
-
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
|
245
|
-
expect(document.activeElement).toBe(items[0])
|
|
246
|
-
|
|
247
|
-
items[0].dispatchEvent(
|
|
248
|
-
new KeyboardEvent('keydown', {
|
|
249
|
-
key: 'End',
|
|
250
|
-
bubbles: true,
|
|
251
|
-
cancelable: true
|
|
252
|
-
})
|
|
253
|
-
)
|
|
254
|
-
expect(document.activeElement).toBe(items[items.length - 1])
|
|
255
|
-
items[items.length - 1]!.dispatchEvent(
|
|
256
|
-
new KeyboardEvent('keydown', {
|
|
257
|
-
key: 'ArrowDown',
|
|
258
|
-
bubbles: true,
|
|
259
|
-
cancelable: true
|
|
260
|
-
})
|
|
261
|
-
)
|
|
262
|
-
expect(document.activeElement).toBe(items[0])
|
|
263
|
-
|
|
264
|
-
items[0].dispatchEvent(
|
|
265
|
-
new KeyboardEvent('keydown', {
|
|
266
|
-
key: 'Escape',
|
|
267
|
-
bubbles: true,
|
|
268
|
-
cancelable: true
|
|
269
|
-
})
|
|
270
|
-
)
|
|
271
|
-
await settle()
|
|
272
|
-
expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
|
|
273
|
-
expect(document.activeElement).toBe(trigger)
|
|
274
|
-
})
|
|
275
|
-
})
|
|
@@ -1,121 +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(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)
|
|
17
|
-
cleanups.push(() => {
|
|
18
|
-
app.unmount()
|
|
19
|
-
host.remove()
|
|
20
|
-
})
|
|
21
|
-
return host
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
cleanups
|
|
26
|
-
.splice(0)
|
|
27
|
-
.reverse()
|
|
28
|
-
.forEach((cleanup) => cleanup())
|
|
29
|
-
document.body.innerHTML = ''
|
|
30
|
-
document.documentElement.style.overflow = ''
|
|
31
|
-
vi.restoreAllMocks()
|
|
32
|
-
})
|
|
33
|
-
|
|
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', {
|
|
38
|
-
configurable: true,
|
|
39
|
-
value: { writeText }
|
|
40
|
-
})
|
|
41
|
-
const host = mount(CodeBlock, {
|
|
42
|
-
code: 'const value = 1\n',
|
|
43
|
-
info: 'ts [demo.ts]',
|
|
44
|
-
highlightedHtml: '<pre class="tn-code-highlight"><code>ok</code></pre>'
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const buttons = host.querySelectorAll<HTMLButtonElement>('button')
|
|
48
|
-
buttons[0].click()
|
|
49
|
-
await nextTick()
|
|
50
|
-
expect(writeText).toHaveBeenCalledWith('const value = 1')
|
|
51
|
-
|
|
52
|
-
buttons[1].click()
|
|
53
|
-
await nextTick()
|
|
54
|
-
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
|
55
|
-
document.body
|
|
56
|
-
.querySelector<HTMLElement>('[role="dialog"]')!
|
|
57
|
-
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
58
|
-
await nextTick()
|
|
59
|
-
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('switches code-group tabs with click and arrow keys', async () => {
|
|
63
|
-
const host = mount(CodeGroup, {
|
|
64
|
-
items: [
|
|
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
|
-
})
|
|
76
|
-
|
|
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)
|
|
84
|
-
|
|
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
|
-
})
|
|
100
|
-
|
|
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)
|
|
108
|
-
|
|
109
|
-
document.dispatchEvent(
|
|
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,205 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import { createExcalidrawSession, persistedSceneSignature } from './editorSession'
|
|
4
|
-
|
|
5
|
-
const scene = (overrides: Record<string, unknown> = {}) =>
|
|
6
|
-
JSON.stringify({
|
|
7
|
-
type: 'excalidraw',
|
|
8
|
-
elements: [{ id: 'rect-1' }],
|
|
9
|
-
appState: { viewBackgroundColor: '#ffffff' },
|
|
10
|
-
files: {},
|
|
11
|
-
...overrides
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
/** 只改展示状态:appState 必须与基础场景合并,否则是「删掉了背景色」这种真变化 */
|
|
15
|
-
const viewChanged = (appState: Record<string, unknown>) =>
|
|
16
|
-
scene({ appState: { viewBackgroundColor: '#ffffff', ...appState } })
|
|
17
|
-
|
|
18
|
-
function setup(options: { save?: ReturnType<typeof vi.fn>; initial?: string } = {}) {
|
|
19
|
-
const save =
|
|
20
|
-
options.save ??
|
|
21
|
-
vi.fn(async ({ content }: { content: string }) => ({
|
|
22
|
-
ok: true as const,
|
|
23
|
-
revision: `rev-${content.length}-${Math.random().toString(16).slice(2, 6)}`
|
|
24
|
-
}))
|
|
25
|
-
const session = createExcalidrawSession({
|
|
26
|
-
initialContent: options.initial ?? scene(),
|
|
27
|
-
initialRevision: 'rev-0',
|
|
28
|
-
save: save as never,
|
|
29
|
-
debounceMs: 200,
|
|
30
|
-
maxWaitMs: 1000
|
|
31
|
-
})
|
|
32
|
-
return { session, save }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
beforeEach(() => {
|
|
36
|
-
vi.useFakeTimers()
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
vi.useRealTimers()
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
describe('画布编辑会话', () => {
|
|
44
|
-
it('展示状态变化(平移/缩放/选中)不触发写盘', async () => {
|
|
45
|
-
const { session, save } = setup()
|
|
46
|
-
session.update(viewChanged({ scrollX: 120, zoom: { value: 2 } }))
|
|
47
|
-
await vi.advanceTimersByTimeAsync(1500)
|
|
48
|
-
|
|
49
|
-
expect(save).not.toHaveBeenCalled()
|
|
50
|
-
expect(session.state.value).toBe('idle')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('内容变化防抖 200ms 后写入一次', async () => {
|
|
54
|
-
const { session, save } = setup()
|
|
55
|
-
session.update(scene({ elements: [{ id: 'rect-1' }, { id: 'rect-2' }] }))
|
|
56
|
-
expect(session.state.value).toBe('pending')
|
|
57
|
-
|
|
58
|
-
await vi.advanceTimersByTimeAsync(199)
|
|
59
|
-
expect(save).not.toHaveBeenCalled()
|
|
60
|
-
await vi.advanceTimersByTimeAsync(1)
|
|
61
|
-
|
|
62
|
-
expect(save).toHaveBeenCalledTimes(1)
|
|
63
|
-
expect(session.state.value).toBe('idle')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('连续变化时最大等待 1s 强制落盘,不会被防抖无限推迟', async () => {
|
|
67
|
-
const { session, save } = setup()
|
|
68
|
-
for (let index = 0; index < 12; index += 1) {
|
|
69
|
-
session.update(
|
|
70
|
-
scene({ elements: Array.from({ length: index + 2 }, (_, i) => ({ id: `r${i}` })) })
|
|
71
|
-
)
|
|
72
|
-
await vi.advanceTimersByTimeAsync(100)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
expect(save).toHaveBeenCalled()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('写入串行:进行中的写入不会被并发覆盖,且旧完成回调不清掉新状态', async () => {
|
|
79
|
-
const pending: Array<(value: { ok: true; revision: string }) => void> = []
|
|
80
|
-
const save = vi.fn(
|
|
81
|
-
() =>
|
|
82
|
-
new Promise<{ ok: true; revision: string }>((resolve) => {
|
|
83
|
-
pending.push(resolve)
|
|
84
|
-
})
|
|
85
|
-
)
|
|
86
|
-
const { session } = setup({ save: save as never })
|
|
87
|
-
|
|
88
|
-
session.update(scene({ elements: [{ id: 'first' }] }))
|
|
89
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
90
|
-
expect(save).toHaveBeenCalledTimes(1)
|
|
91
|
-
|
|
92
|
-
session.update(scene({ elements: [{ id: 'second' }] }))
|
|
93
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
94
|
-
expect(save).toHaveBeenCalledTimes(1) // 仍在写入中,不并发
|
|
95
|
-
|
|
96
|
-
pending[0]?.({ ok: true, revision: 'rev-1' })
|
|
97
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
98
|
-
expect(save).toHaveBeenCalledTimes(2) // 写完后补写最新内容
|
|
99
|
-
|
|
100
|
-
pending[1]?.({ ok: true, revision: 'rev-2' })
|
|
101
|
-
await vi.advanceTimersByTimeAsync(0)
|
|
102
|
-
expect(session.state.value).toBe('idle')
|
|
103
|
-
expect(session.revision.value).toBe('rev-2')
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('写入失败保留最新内容并进入 failed,重试成功后回到 idle', async () => {
|
|
107
|
-
const save = vi
|
|
108
|
-
.fn()
|
|
109
|
-
.mockResolvedValueOnce({ ok: false, code: 'error', message: '磁盘满' })
|
|
110
|
-
.mockResolvedValueOnce({ ok: true, revision: 'rev-ok' })
|
|
111
|
-
const { session } = setup({ save: save as never })
|
|
112
|
-
|
|
113
|
-
const latest = scene({ elements: [{ id: 'latest' }] })
|
|
114
|
-
session.update(latest)
|
|
115
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
116
|
-
|
|
117
|
-
expect(session.state.value).toBe('failed')
|
|
118
|
-
expect(session.lastError.value).toBe('磁盘满')
|
|
119
|
-
expect(session.currentContent()).toBe(latest) // 内容没丢
|
|
120
|
-
|
|
121
|
-
await session.retry()
|
|
122
|
-
expect(session.state.value).toBe('idle')
|
|
123
|
-
expect(session.revision.value).toBe('rev-ok')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('冲突单独标记,内容保留供用户选择', async () => {
|
|
127
|
-
const save = vi.fn(async () => ({
|
|
128
|
-
ok: false as const,
|
|
129
|
-
code: 'conflict' as const,
|
|
130
|
-
message: '已被外部修改'
|
|
131
|
-
}))
|
|
132
|
-
const { session } = setup({ save: save as never })
|
|
133
|
-
session.update(scene({ elements: [{ id: 'mine' }] }))
|
|
134
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
135
|
-
|
|
136
|
-
expect(session.state.value).toBe('conflict')
|
|
137
|
-
// 冲突时保留我的内容等待用户决定,不能被清掉
|
|
138
|
-
expect(session.hasPending()).toBe(true)
|
|
139
|
-
expect(session.currentContent()).toContain('mine')
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('flush 立即写盘(切换承载位置/关闭入口)', async () => {
|
|
143
|
-
const { session, save } = setup()
|
|
144
|
-
session.update(scene({ elements: [{ id: 'pending' }] }))
|
|
145
|
-
expect(save).not.toHaveBeenCalled()
|
|
146
|
-
|
|
147
|
-
await session.flush()
|
|
148
|
-
expect(save).toHaveBeenCalledTimes(1)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('dispose 之后不再写盘', async () => {
|
|
152
|
-
const { session, save } = setup()
|
|
153
|
-
session.update(scene({ elements: [{ id: 'x' }] }))
|
|
154
|
-
session.dispose()
|
|
155
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
156
|
-
expect(save).not.toHaveBeenCalled()
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('adopt 把首帧规范化结果当基线:不写盘,且之后相同内容不触发写入', async () => {
|
|
160
|
-
const { session, save } = setup()
|
|
161
|
-
// 模拟 Excalidraw 载入后补齐字段:元素与 appState 都被规范化
|
|
162
|
-
const normalized = scene({
|
|
163
|
-
elements: [{ id: 'rect-1', groupIds: [], frameId: null, roundness: null }],
|
|
164
|
-
appState: { viewBackgroundColor: '#ffffff', gridSize: 20, gridStep: 5 }
|
|
165
|
-
})
|
|
166
|
-
session.update(normalized)
|
|
167
|
-
expect(session.state.value).toBe('pending')
|
|
168
|
-
|
|
169
|
-
session.adopt(normalized, 'rev-normalized')
|
|
170
|
-
|
|
171
|
-
expect(session.state.value).toBe('idle')
|
|
172
|
-
expect(session.hasPending()).toBe(false)
|
|
173
|
-
expect(session.revision.value).toBe('rev-normalized')
|
|
174
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
175
|
-
expect(save).not.toHaveBeenCalled()
|
|
176
|
-
|
|
177
|
-
// 同一份规范化内容再来一次(编辑器抖动)也不写
|
|
178
|
-
session.update(normalized)
|
|
179
|
-
await vi.advanceTimersByTimeAsync(2000)
|
|
180
|
-
expect(save).not.toHaveBeenCalled()
|
|
181
|
-
|
|
182
|
-
// 真正的内容变化照常写盘
|
|
183
|
-
session.update(scene({ elements: [{ id: 'rect-1' }, { id: 'rect-2' }] }))
|
|
184
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
185
|
-
expect(save).toHaveBeenCalledTimes(1)
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('adopt 之后 revision 不变时,写入仍会用最新 revision', async () => {
|
|
189
|
-
const { session, save } = setup()
|
|
190
|
-
session.adopt(scene({ elements: [{ id: 'normalized' }] }))
|
|
191
|
-
expect(session.revision.value).toBe('rev-0')
|
|
192
|
-
session.update(scene({ elements: [{ id: 'drawn' }] }))
|
|
193
|
-
await vi.advanceTimersByTimeAsync(200)
|
|
194
|
-
expect(save).toHaveBeenCalledWith(expect.objectContaining({ expectedRevision: 'rev-0' }))
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('持久化指纹忽略展示状态但包含元素与内嵌文件', () => {
|
|
198
|
-
const a = persistedSceneSignature(scene())
|
|
199
|
-
expect(persistedSceneSignature(viewChanged({ scrollX: 10, zoom: { value: 1.5 } }))).toBe(a)
|
|
200
|
-
expect(persistedSceneSignature(scene({ elements: [{ id: 'changed' }] }))).not.toBe(a)
|
|
201
|
-
expect(
|
|
202
|
-
persistedSceneSignature(scene({ files: { f1: { id: 'f1', dataURL: 'data:image/png' } } }))
|
|
203
|
-
).not.toBe(a)
|
|
204
|
-
})
|
|
205
|
-
})
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
|
|
5
|
-
import { restoreCanvasKeyboardFocus, restoreCanvasKeyboardFocusWhenReady } from './focus'
|
|
6
|
-
|
|
7
|
-
function fixture(height: number): { host: HTMLDivElement; canvas: HTMLCanvasElement } {
|
|
8
|
-
const host = document.createElement('div')
|
|
9
|
-
const canvas = document.createElement('canvas')
|
|
10
|
-
canvas.className = 'excalidraw__canvas interactive'
|
|
11
|
-
canvas.getBoundingClientRect = () => ({ height, width: 400 }) as DOMRect
|
|
12
|
-
host.append(canvas)
|
|
13
|
-
document.body.append(host)
|
|
14
|
-
return { host, canvas }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
document.body.replaceChildren()
|
|
19
|
-
vi.restoreAllMocks()
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe('画布键盘焦点', () => {
|
|
23
|
-
it('补 tabindex 并把焦点交给交互画布', () => {
|
|
24
|
-
const { host, canvas } = fixture(600)
|
|
25
|
-
|
|
26
|
-
expect(restoreCanvasKeyboardFocus(host)).toBe(true)
|
|
27
|
-
expect(canvas.getAttribute('tabindex')).toBe('0')
|
|
28
|
-
expect(document.activeElement).toBe(canvas)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('没有交互画布时不抢焦点并返回 false', () => {
|
|
32
|
-
const host = document.createElement('div')
|
|
33
|
-
const button = document.createElement('button')
|
|
34
|
-
host.append(button)
|
|
35
|
-
document.body.append(host)
|
|
36
|
-
button.focus()
|
|
37
|
-
|
|
38
|
-
expect(restoreCanvasKeyboardFocus(host)).toBe(false)
|
|
39
|
-
// blur 掉当前焦点后没有可聚焦目标:焦点回到 body,不会落到别的控件上
|
|
40
|
-
expect(document.activeElement).toBe(document.body)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('画布还在 display:none 里(高度 0)时不聚焦,等布局就绪后再聚焦', () => {
|
|
44
|
-
const { host, canvas } = fixture(0)
|
|
45
|
-
const frames: Array<() => void> = []
|
|
46
|
-
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
47
|
-
frames.push(() => callback(0))
|
|
48
|
-
return frames.length
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
restoreCanvasKeyboardFocusWhenReady(host)
|
|
52
|
-
// 关键回归点:还没布局就 focus() 会静默失败、焦点留在 body,快捷键收不到
|
|
53
|
-
expect(document.activeElement).not.toBe(canvas)
|
|
54
|
-
expect(frames.length).toBeGreaterThan(0)
|
|
55
|
-
|
|
56
|
-
canvas.getBoundingClientRect = () => ({ height: 600, width: 400 }) as DOMRect
|
|
57
|
-
frames.forEach((run) => run())
|
|
58
|
-
expect(document.activeElement).toBe(canvas)
|
|
59
|
-
})
|
|
60
|
-
})
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
getExcalidrawAssetPath,
|
|
5
|
-
inlineExcalidrawFonts,
|
|
6
|
-
resolveFontUrl,
|
|
7
|
-
setExcalidrawAssetPath
|
|
8
|
-
} from './fonts'
|
|
9
|
-
|
|
10
|
-
const SVG = `<svg><style>@font-face{src:url(https://esm.sh/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Virgil/Virgil-Regular.woff2)}</style></svg>`
|
|
11
|
-
|
|
12
|
-
function fetchReturning(bytes: number[], ok = true) {
|
|
13
|
-
return vi.fn(async () => ({
|
|
14
|
-
ok,
|
|
15
|
-
status: ok ? 200 : 404,
|
|
16
|
-
arrayBuffer: async () => new Uint8Array(bytes).buffer
|
|
17
|
-
})) as unknown as typeof fetch
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('画布字体自包含', () => {
|
|
21
|
-
it('把字体基址换成配置的本地目录', () => {
|
|
22
|
-
expect(
|
|
23
|
-
resolveFontUrl(
|
|
24
|
-
'https://esm.sh/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Virgil/Virgil-Regular.woff2',
|
|
25
|
-
'/excalidraw/'
|
|
26
|
-
)
|
|
27
|
-
).toBe('/excalidraw/fonts/Virgil/Virgil-Regular.woff2')
|
|
28
|
-
expect(resolveFontUrl('/fonts/Virgil/x.woff2')).toBe('/fonts/Virgil/x.woff2')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('把 SVG 里的字体内联成 data URL,不再有外链', async () => {
|
|
32
|
-
const result = await inlineExcalidrawFonts(SVG, {
|
|
33
|
-
base: '/excalidraw/',
|
|
34
|
-
fetchImpl: fetchReturning([1, 2, 3, 4])
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
expect(result.inlined).toBe(1)
|
|
38
|
-
expect(result.remaining).toEqual([])
|
|
39
|
-
expect(result.svg).toContain('data:font/woff2;base64,')
|
|
40
|
-
expect(result.svg).not.toContain('esm.sh')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('取不到字体时如实报告 remaining,不静默失败', async () => {
|
|
44
|
-
const result = await inlineExcalidrawFonts(SVG, {
|
|
45
|
-
base: '/excalidraw/',
|
|
46
|
-
fetchImpl: fetchReturning([], false)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
expect(result.inlined).toBe(0)
|
|
50
|
-
expect(result.remaining).toEqual(['/excalidraw/fonts/Virgil/Virgil-Regular.woff2'])
|
|
51
|
-
expect(result.svg).toBe(SVG)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('没有字体引用时不做任何事', async () => {
|
|
55
|
-
const result = await inlineExcalidrawFonts('<svg></svg>', {
|
|
56
|
-
fetchImpl: fetchReturning([1])
|
|
57
|
-
})
|
|
58
|
-
expect(result.inlined).toBe(0)
|
|
59
|
-
expect(result.remaining).toEqual([])
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('资产基址可设置与读取(离线必需)', () => {
|
|
63
|
-
setExcalidrawAssetPath('/excalidraw/')
|
|
64
|
-
expect(getExcalidrawAssetPath()).toBe('/excalidraw/')
|
|
65
|
-
})
|
|
66
|
-
})
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import { createExcalidrawSvgRenderer } from './renderer'
|
|
4
|
-
import { SvgCache } from './scene'
|
|
5
|
-
|
|
6
|
-
const scene = (extra: Record<string, unknown> = {}) =>
|
|
7
|
-
JSON.stringify({
|
|
8
|
-
type: 'excalidraw',
|
|
9
|
-
elements: [{ id: 'rect-1' }],
|
|
10
|
-
appState: {},
|
|
11
|
-
files: {},
|
|
12
|
-
...extra
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
function setup(options: {
|
|
16
|
-
content: string
|
|
17
|
-
theme?: 'light' | 'dark'
|
|
18
|
-
render?: () => Promise<string>
|
|
19
|
-
}) {
|
|
20
|
-
const renderCalls: Array<{ dark: boolean }> = []
|
|
21
|
-
const cache = new SvgCache(4)
|
|
22
|
-
const renderer = createExcalidrawSvgRenderer({
|
|
23
|
-
getContent: () => options.content,
|
|
24
|
-
getTheme: () => options.theme ?? 'light',
|
|
25
|
-
cache,
|
|
26
|
-
loadExporter: async () => ({
|
|
27
|
-
renderExcalidrawSvg: async (_scene, exportOptions) => {
|
|
28
|
-
renderCalls.push(exportOptions)
|
|
29
|
-
if (options.render) return await options.render()
|
|
30
|
-
return `<svg data-dark="${exportOptions.dark}">ok</svg>`
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
return { renderer, renderCalls, cache }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
vi.restoreAllMocks()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
describe('只读画布渲染器', () => {
|
|
42
|
-
it('渲染成功的 SVG 变成 data URL,不触碰文件系统', async () => {
|
|
43
|
-
const { renderer, renderCalls } = setup({ content: scene() })
|
|
44
|
-
await renderer.render()
|
|
45
|
-
|
|
46
|
-
expect(renderer.state.value).toBe('ready')
|
|
47
|
-
expect(renderer.dataUrl.value).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
|
|
48
|
-
expect(decodeURIComponent(renderer.dataUrl.value)).toContain('<svg')
|
|
49
|
-
expect(renderCalls).toHaveLength(1)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('相同内容与主题命中缓存,不重复导出', async () => {
|
|
53
|
-
const cache = new SvgCache(4)
|
|
54
|
-
const calls: number[] = []
|
|
55
|
-
const make = () =>
|
|
56
|
-
createExcalidrawSvgRenderer({
|
|
57
|
-
getContent: () => scene(),
|
|
58
|
-
getTheme: () => 'light',
|
|
59
|
-
cache,
|
|
60
|
-
loadExporter: async () => ({
|
|
61
|
-
renderExcalidrawSvg: async () => {
|
|
62
|
-
calls.push(1)
|
|
63
|
-
return '<svg>once</svg>'
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
await make().render()
|
|
68
|
-
await make().render()
|
|
69
|
-
expect(calls).toHaveLength(1)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('深色主题单独缓存,导出选项跟随主题', async () => {
|
|
73
|
-
const { renderer, renderCalls, cache } = setup({ content: scene(), theme: 'dark' })
|
|
74
|
-
await renderer.render()
|
|
75
|
-
expect(renderCalls[0]).toEqual({ dark: true })
|
|
76
|
-
expect(cache.size).toBe(1)
|
|
77
|
-
expect(decodeURIComponent(renderer.dataUrl.value)).toContain('data-dark="true"')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('非法 JSON 与空场景进入错误态,且不调用导出器', async () => {
|
|
81
|
-
const broken = setup({ content: '{ nope' })
|
|
82
|
-
await broken.renderer.render()
|
|
83
|
-
expect(broken.renderer.state.value).toBe('error')
|
|
84
|
-
expect(broken.renderer.errorReason.value).toContain('合法 JSON')
|
|
85
|
-
expect(broken.renderCalls).toHaveLength(0)
|
|
86
|
-
|
|
87
|
-
const empty = setup({ content: JSON.stringify({ type: 'excalidraw', elements: [] }) })
|
|
88
|
-
await empty.renderer.render()
|
|
89
|
-
expect(empty.renderer.state.value).toBe('error')
|
|
90
|
-
expect(empty.renderer.errorReason.value).toBe('空画布')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('导出抛错时进入错误态而不是崩掉', async () => {
|
|
94
|
-
const { renderer } = setup({
|
|
95
|
-
content: scene(),
|
|
96
|
-
render: async () => {
|
|
97
|
-
throw new Error('导出失败')
|
|
98
|
-
}
|
|
99
|
-
})
|
|
100
|
-
await renderer.render()
|
|
101
|
-
expect(renderer.state.value).toBe('error')
|
|
102
|
-
expect(renderer.errorReason.value).toBe('导出失败')
|
|
103
|
-
expect(renderer.dataUrl.value).toBe('')
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('在途结果过期时被丢弃(内容已变化)', async () => {
|
|
107
|
-
let release: ((svg: string) => void) | undefined
|
|
108
|
-
const calls: string[] = []
|
|
109
|
-
let content = scene()
|
|
110
|
-
const renderer = createExcalidrawSvgRenderer({
|
|
111
|
-
getContent: () => content,
|
|
112
|
-
getTheme: () => 'light',
|
|
113
|
-
cache: new SvgCache(4),
|
|
114
|
-
loadExporter: async () => ({
|
|
115
|
-
renderExcalidrawSvg: async () => {
|
|
116
|
-
calls.push(content)
|
|
117
|
-
if (calls.length === 1) {
|
|
118
|
-
return await new Promise<string>((resolve) => {
|
|
119
|
-
release = resolve
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
return '<svg>new</svg>'
|
|
123
|
-
}
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
const first = renderer.render()
|
|
128
|
-
content = scene({ elements: [{ id: 'newer' }] })
|
|
129
|
-
const second = renderer.render()
|
|
130
|
-
// 等两次 render 都进入 exporter(动态 import 需要一个微任务)
|
|
131
|
-
for (let tick = 0; tick < 5 && !release; tick += 1) await Promise.resolve()
|
|
132
|
-
release?.('<svg>stale</svg>')
|
|
133
|
-
await Promise.all([first, second])
|
|
134
|
-
|
|
135
|
-
expect(decodeURIComponent(renderer.dataUrl.value)).toContain('new')
|
|
136
|
-
expect(decodeURIComponent(renderer.dataUrl.value)).not.toContain('stale')
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('释放后清空 data URL,之后再渲染不再产生输出', async () => {
|
|
140
|
-
const { renderer } = setup({ content: scene() })
|
|
141
|
-
await renderer.render()
|
|
142
|
-
renderer.dispose()
|
|
143
|
-
expect(renderer.dataUrl.value).toBe('')
|
|
144
|
-
await renderer.render()
|
|
145
|
-
expect(renderer.state.value).toBe('idle')
|
|
146
|
-
expect(renderer.dataUrl.value).toBe('')
|
|
147
|
-
})
|
|
148
|
-
})
|
|
@@ -1,86 +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('{w=400px align=center}')
|
|
55
|
-
expect(formatImageAttrs({ align: 'left' })).toBe('')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('rejects empty, zero, and over-wide values', () => {
|
|
59
|
-
expect(normalizeImageWidth('')).toBe('')
|
|
60
|
-
expect(normalizeImageWidth('0%')).toBe('')
|
|
61
|
-
expect(normalizeImageWidth('120%')).toBe('')
|
|
62
|
-
expect(normalizeImageWidth('auto')).toBe('')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('serializes caption as alt and omits natural width', () => {
|
|
66
|
-
expect(
|
|
67
|
-
serializeImageMarkdown({
|
|
68
|
-
alt: '知识库资源示意',
|
|
69
|
-
src: '../assets/pixel.svg',
|
|
70
|
-
width: '50%'
|
|
71
|
-
})
|
|
72
|
-
).toBe(' {w=50%}')
|
|
73
|
-
expect(serializeImageMarkdown({ alt: '', src: '../assets/pixel.svg' })).toBe(
|
|
74
|
-
''
|
|
75
|
-
)
|
|
76
|
-
expect(formatImageWidthAttr('100')).toBe('{w=100%}')
|
|
77
|
-
expect(
|
|
78
|
-
serializeImageMarkdown({
|
|
79
|
-
alt: '',
|
|
80
|
-
src: '../assets/shot.png',
|
|
81
|
-
width: '400px',
|
|
82
|
-
align: 'right'
|
|
83
|
-
})
|
|
84
|
-
).toBe(' {w=400px align=right}')
|
|
85
|
-
})
|
|
86
|
-
})
|
|
@@ -1,61 +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((tab) => tab.textContent)
|
|
35
|
-
expect(tabs).toEqual(['A', 'B', 'C'])
|
|
36
|
-
expect(host.querySelectorAll('.tn-tab-nav')).toHaveLength(3) // < / >
|
|
37
|
-
|
|
38
|
-
const slides = [...host.querySelectorAll('.swiper-slide')] as HTMLElement[]
|
|
39
|
-
expect(slides[0]!.classList.contains('is-active')).toBe(true)
|
|
40
|
-
expect(slides[0]!.hidden).toBe(false)
|
|
41
|
-
expect(slides[1]!.hidden).toBe(true)
|
|
42
|
-
expect(slides[2]!.hidden).toBe(true)
|
|
43
|
-
|
|
44
|
-
;(host.querySelectorAll('.tn-tab')[1] as HTMLButtonElement).click()
|
|
45
|
-
expect(slides[0]!.hidden).toBe(true)
|
|
46
|
-
expect(slides[1]!.hidden).toBe(false)
|
|
47
|
-
expect(slides[1]!.classList.contains('is-active')).toBe(true)
|
|
48
|
-
|
|
49
|
-
// Idempotent
|
|
50
|
-
expect(hydrateTnSwipers(root)).toBe(0)
|
|
51
|
-
root.remove()
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('omits the tab bar for a single slide', () => {
|
|
55
|
-
const root = swiperShell(['solo'])
|
|
56
|
-
hydrateTnSwipers(root)
|
|
57
|
-
expect(root.querySelector('.tn-swiper-tabs')).toBeNull()
|
|
58
|
-
expect(root.querySelector('.swiper-slide')!.classList.contains('is-active')).toBe(true)
|
|
59
|
-
root.remove()
|
|
60
|
-
})
|
|
61
|
-
})
|