@tnotesjs/ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/code/highlight.d.ts +39 -1
- package/dist/code/highlight.js +54 -1
- package/package.json +21 -7
- package/src/code/collapse.ts +79 -0
- package/src/code/highlight.ts +12 -0
- package/src/components/CodeBlock/CodeBlock.vue +39 -5
- package/src/components/CodeGroup/CodeGroup.vue +70 -7
- package/src/components/Mindmap/Mindmap.vue +7 -6
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +77 -68
- package/src/components/Mindmap/editor/CollapseMenu.vue +113 -0
- package/src/components/Mindmap/editor/IconButton.vue +63 -0
- package/src/components/Mindmap/editor/LinkPopover.vue +50 -46
- package/src/components/Mindmap/editor/MarkdownView.vue +5 -3
- package/src/components/Mindmap/editor/MindmapView.vue +11 -1
- package/src/components/Mindmap/editor/SearchBar.vue +672 -0
- package/src/components/Mindmap/editor/SelectionToolbar.vue +1 -1
- package/src/entries/excalidraw-view.ts +5 -0
- package/src/entries/mindmap-editor.ts +37 -0
- package/src/excalidraw/exporter.ts +1 -0
- package/src/excalidraw/fonts.ts +21 -1
- package/src/styles/code.css +74 -2
- package/src/styles/prose.css +45 -0
- package/src/styles/tokens.css +6 -0
- 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/Mindmap/editor/canvasClipboard.ts +0 -2
- 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
|
@@ -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
|
-
})
|