@tnotesjs/ui 0.1.5 → 0.2.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 +30 -97
- package/dist/code/highlight.js +6 -15
- package/dist/components/Footprints/parse.js +1 -3
- package/dist/components/Mindmap/markdown.d.ts +1 -7
- package/dist/components/Mindmap/markdown.js +1 -15
- package/package.json +24 -8
- package/src/browser/clipboard.ts +16 -19
- package/src/browser/theme.ts +10 -15
- package/src/code/highlight.test.ts +23 -6
- package/src/code/highlight.ts +89 -105
- package/src/components/Badge/Badge.vue +4 -4
- package/src/components/CodeBlock/CodeBlock.vue +43 -53
- package/src/components/CodeGroup/CodeGroup.vue +32 -35
- package/src/components/Footprints/Footprints.vue +2 -14
- package/src/components/Footprints/parse.ts +1 -3
- package/src/components/ImagePreview/ImagePreview.vue +101 -126
- package/src/components/Mermaid/Mermaid.vue +9 -12
- package/src/components/Mindmap/FocusBreadcrumbs.test.ts +40 -30
- package/src/components/Mindmap/FocusBreadcrumbs.vue +31 -28
- package/src/components/Mindmap/InlineRuns.ts +25 -16
- package/src/components/Mindmap/Mindmap.vue +147 -81
- package/src/components/Mindmap/MindmapOutlineNode.vue +4 -1
- package/src/components/Mindmap/editor/AppIcon.vue +32 -11
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +126 -49
- package/src/components/Mindmap/editor/LinkPopover.vue +101 -23
- package/src/components/Mindmap/editor/MarkdownView.vue +7 -4
- package/src/components/Mindmap/editor/MindmapView.vue +11 -3
- package/src/components/Mindmap/editor/OutlineView.vue +144 -58
- package/src/components/Mindmap/editor/RichInlineEditor.vue +93 -44
- package/src/components/Mindmap/editor/SelectionToolbar.vue +133 -41
- package/src/components/Mindmap/editor/canvasClipboard.ts +1 -5
- package/src/components/Mindmap/editor/imagePaste.ts +1 -1
- package/src/components/Mindmap/editor/mindmapClipboard.ts +17 -5
- package/src/components/Mindmap/editor/outlineDrag.ts +1 -1
- package/src/components/Mindmap/markdown.ts +10 -29
- package/src/components/WordList/RightClickMenu.vue +10 -35
- package/src/components/WordList/WordList.vue +19 -42
- package/src/components/sharedMedia.test.ts +94 -112
- package/src/entries/bilibili-video.ts +1 -1
- package/src/entries/excalidraw-editor.ts +20 -0
- package/src/entries/excalidraw-view.ts +15 -0
- package/src/entries/footprints.ts +1 -1
- package/src/entries/mermaid.ts +1 -1
- package/src/entries/mindmap.ts +1 -1
- package/src/entries/notes-table.ts +2 -2
- package/src/entries/word-list.ts +1 -1
- package/src/excalidraw/ExcalidrawSvg.vue +125 -0
- package/src/excalidraw/editorHost.ts +110 -0
- package/src/excalidraw/editorSession.test.ts +205 -0
- package/src/excalidraw/editorSession.ts +224 -0
- package/src/excalidraw/exporter.ts +50 -0
- package/src/excalidraw/focus.test.ts +60 -0
- package/src/excalidraw/focus.ts +33 -0
- package/src/excalidraw/fonts.test.ts +66 -0
- package/src/excalidraw/fonts.ts +92 -0
- package/src/excalidraw/renderer.test.ts +148 -0
- package/src/excalidraw/renderer.ts +105 -0
- package/src/excalidraw/scene.ts +93 -0
- package/src/index.ts +25 -33
- package/src/markdown/image.test.ts +1 -3
- package/src/markdown/image.ts +4 -13
- package/src/styles/code.css +5 -5
- package/src/styles/prose.css +1 -1
- package/src/styles/swiper.css +1 -1
- package/src/styles/tokens.css +5 -6
- package/src/swiper/hydrate.test.ts +37 -41
- package/src/swiper/hydrate.ts +91 -102
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excalidraw 编辑器宿主(Desk 专用)。
|
|
3
|
+
*
|
|
4
|
+
* 关键行为:
|
|
5
|
+
* - 稳定实例:切换卡片/全屏/标签页时只**移动承载 DOM 节点**,不重建 React 树,
|
|
6
|
+
* 否则场景与原生撤销历史都会丢(E0 已实证)
|
|
7
|
+
* - 承载位置交接后必须重建键盘焦点:E0 实测「blur 当前焦点 + 给交互画布加
|
|
8
|
+
* tabindex + canvas.focus()」是唯一有效路径;不重建则快捷键(撤销等)失效
|
|
9
|
+
* - 内容变化只在需要持久化时回调(宿主用 persistedSceneSignature 过滤)
|
|
10
|
+
*/
|
|
11
|
+
import { createElement } from 'react'
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
13
|
+
import { Excalidraw, serializeAsJSON } from '@excalidraw/excalidraw'
|
|
14
|
+
// 官方样式表必须由宿主 import 一次:缺它的容器高度会算成 2^24,画布不可见且点不到
|
|
15
|
+
import '@excalidraw/excalidraw/index.css'
|
|
16
|
+
|
|
17
|
+
import { setExcalidrawAssetPath } from './fonts'
|
|
18
|
+
import { restoreCanvasKeyboardFocusWhenReady } from './focus'
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
ExcalidrawImperativeAPI,
|
|
22
|
+
ExcalidrawInitialDataState
|
|
23
|
+
} from '@excalidraw/excalidraw/types'
|
|
24
|
+
|
|
25
|
+
export interface MountExcalidrawHostOptions {
|
|
26
|
+
host: HTMLElement
|
|
27
|
+
/** 初始磁盘内容(唯一真相源) */
|
|
28
|
+
content: string
|
|
29
|
+
theme: 'light' | 'dark'
|
|
30
|
+
/** 本地字体基址;离线必须设置 */
|
|
31
|
+
fontBase?: string
|
|
32
|
+
/**
|
|
33
|
+
* 场景序列化回调。
|
|
34
|
+
*
|
|
35
|
+
* 注意:Excalidraw 载入磁盘场景时会补齐自己的字段(groupIds/roundness/gridSize…),
|
|
36
|
+
* 挂载后的第一次回调就是这份「规范化」结果,**不是用户编辑**。调用方要把它当作
|
|
37
|
+
* 新的持久化基线(`session.adopt`),否则只读打开也会回写一份规范化副本。
|
|
38
|
+
*/
|
|
39
|
+
onChange: (content: string) => void
|
|
40
|
+
onReady?: (api: ExcalidrawImperativeAPI) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExcalidrawHostHandle {
|
|
44
|
+
/** 只移动承载节点,保持实例与撤销历史 */
|
|
45
|
+
transferTo(target: HTMLElement): void
|
|
46
|
+
/** 重建键盘焦点(交接后必须调用) */
|
|
47
|
+
focus(): void
|
|
48
|
+
/** 跟随宿主主题切换;不重建实例,只改 appState.theme */
|
|
49
|
+
setTheme(theme: 'light' | 'dark'): void
|
|
50
|
+
getApi(): ExcalidrawImperativeAPI | null
|
|
51
|
+
destroy(): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 磁盘 JSON 的形状由 kb 层保证;这里只做最必要的兜底。 */
|
|
55
|
+
function parseInitial(content: string): ExcalidrawInitialDataState {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(content) as ExcalidrawInitialDataState
|
|
58
|
+
return {
|
|
59
|
+
elements: Array.isArray(parsed.elements) ? parsed.elements : [],
|
|
60
|
+
appState: parsed.appState ?? {},
|
|
61
|
+
files: parsed.files ?? {}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
return { elements: [], appState: {}, files: {} }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { restoreCanvasKeyboardFocus, restoreCanvasKeyboardFocusWhenReady } from './focus'
|
|
69
|
+
|
|
70
|
+
export function mountExcalidrawHost(options: MountExcalidrawHostOptions): ExcalidrawHostHandle {
|
|
71
|
+
if (options.fontBase) setExcalidrawAssetPath(options.fontBase)
|
|
72
|
+
const initial = parseInitial(options.content)
|
|
73
|
+
let api: ExcalidrawImperativeAPI | null = null
|
|
74
|
+
const appState = {
|
|
75
|
+
...initial.appState,
|
|
76
|
+
theme: options.theme,
|
|
77
|
+
// 不做「整篇替换」的展示状态,交给 Excalidraw 自己维护
|
|
78
|
+
collaborators: new Map()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const root: Root = createRoot(options.host)
|
|
82
|
+
root.render(
|
|
83
|
+
createElement(Excalidraw, {
|
|
84
|
+
initialData: { elements: initial.elements, appState, files: initial.files },
|
|
85
|
+
theme: options.theme,
|
|
86
|
+
excalidrawAPI: (next: ExcalidrawImperativeAPI) => {
|
|
87
|
+
api = next
|
|
88
|
+
options.onReady?.(next)
|
|
89
|
+
},
|
|
90
|
+
onChange: (elements, nextAppState, files) => {
|
|
91
|
+
options.onChange(serializeAsJSON(elements, nextAppState, files, 'local'))
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
transferTo: (target: HTMLElement) => {
|
|
98
|
+
if (options.host.parentElement !== target) target.append(options.host)
|
|
99
|
+
// 换了承载容器:画布尺寸与命中区域都要重算,否则指针事件落在旧几何上
|
|
100
|
+
api?.refresh?.()
|
|
101
|
+
restoreCanvasKeyboardFocusWhenReady(options.host)
|
|
102
|
+
},
|
|
103
|
+
focus: () => restoreCanvasKeyboardFocusWhenReady(options.host),
|
|
104
|
+
setTheme: (theme: 'light' | 'dark') => {
|
|
105
|
+
api?.updateScene({ appState: { theme } })
|
|
106
|
+
},
|
|
107
|
+
getApi: () => api,
|
|
108
|
+
destroy: () => root.unmount()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画布编辑会话(宿主无关)。
|
|
3
|
+
*
|
|
4
|
+
* 计划 E3 的硬约束:
|
|
5
|
+
* - 内容变化自动写盘,无保存按钮;连续变化合并(200ms)但有最大等待上限(1s),
|
|
6
|
+
* 避免一直拖动手势导致永不落盘
|
|
7
|
+
* - 写入严格有序:同一文件的写请求串行;旧完成的回调不能清掉更新的状态
|
|
8
|
+
* - 没有手动保存流程,但必须有 pending/writing/failed/conflict 状态,
|
|
9
|
+
* 失败时保留最新内存场景,不能显示成"已持久化"
|
|
10
|
+
* - 只有真正需要持久化的内容变化才触发写入(平移/缩放/选中不算)
|
|
11
|
+
*/
|
|
12
|
+
import { ref, type Ref } from 'vue'
|
|
13
|
+
|
|
14
|
+
import { parseExcalidrawScene } from './scene'
|
|
15
|
+
|
|
16
|
+
export type ExcalidrawWriteState = 'idle' | 'pending' | 'writing' | 'failed' | 'conflict'
|
|
17
|
+
|
|
18
|
+
export interface ExcalidrawSaveInput {
|
|
19
|
+
content: string
|
|
20
|
+
expectedRevision: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ExcalidrawSaveResult =
|
|
24
|
+
{ ok: true; revision: string } | { ok: false; code: 'conflict' | 'error'; message: string }
|
|
25
|
+
|
|
26
|
+
export interface ExcalidrawSessionOptions {
|
|
27
|
+
initialContent: string
|
|
28
|
+
initialRevision: string
|
|
29
|
+
save: (input: ExcalidrawSaveInput) => Promise<ExcalidrawSaveResult>
|
|
30
|
+
debounceMs?: number
|
|
31
|
+
maxWaitMs?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ExcalidrawSession {
|
|
35
|
+
state: Ref<ExcalidrawWriteState>
|
|
36
|
+
lastError: Ref<string>
|
|
37
|
+
revision: Ref<string>
|
|
38
|
+
/** 编辑器内容变化回调(只在持久化内容真的变了时才调用) */
|
|
39
|
+
update(content: string): void
|
|
40
|
+
/** 立即写入:失去焦点、关闭入口、切换承载位置时调用 */
|
|
41
|
+
flush(): Promise<void>
|
|
42
|
+
/** 失败后重试(内容仍在内存里) */
|
|
43
|
+
retry(): Promise<void>
|
|
44
|
+
/**
|
|
45
|
+
* 把一段内容当作「已持久化」的基线:丢弃待写队列、更新 revision。
|
|
46
|
+
*
|
|
47
|
+
* 用途:编辑器首帧会把磁盘场景规范化(补齐 groupIds/roundness/gridSize 等字段),
|
|
48
|
+
* 那不是用户编辑,不能因此回写一份「规范化副本」;也可能用于放弃本地修改。
|
|
49
|
+
*/
|
|
50
|
+
adopt(content: string, revision?: string): void
|
|
51
|
+
currentContent(): string
|
|
52
|
+
pendingContent(): string | null
|
|
53
|
+
hasPending(): boolean
|
|
54
|
+
dispose(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 只有元素 / 内嵌文件 / 与文档语义有关的 appState 才算"需要持久化"。
|
|
59
|
+
* 平移、缩放、选中、当前工具、滚动位置都属于展示状态,改了不该写盘。
|
|
60
|
+
*/
|
|
61
|
+
const PERSISTED_APP_STATE_KEYS = [
|
|
62
|
+
'viewBackgroundColor',
|
|
63
|
+
'gridSize',
|
|
64
|
+
'currentItemFontFamily',
|
|
65
|
+
'currentItemStrokeColor',
|
|
66
|
+
'currentItemBackgroundColor',
|
|
67
|
+
'currentItemFillStyle',
|
|
68
|
+
'currentItemStrokeWidth',
|
|
69
|
+
'currentItemRoughness',
|
|
70
|
+
'currentItemOpacity',
|
|
71
|
+
'name'
|
|
72
|
+
] as const
|
|
73
|
+
|
|
74
|
+
/** 提取用于比较的持久化指纹;解析失败时退回整串内容(保证坏数据也会被写回报告)。 */
|
|
75
|
+
export function persistedSceneSignature(content: string): string {
|
|
76
|
+
const parsed = parseExcalidrawScene(content)
|
|
77
|
+
if (!parsed.ok) return content
|
|
78
|
+
const appState: Record<string, unknown> = {}
|
|
79
|
+
for (const key of PERSISTED_APP_STATE_KEYS) {
|
|
80
|
+
if (key in parsed.scene.appState) appState[key] = parsed.scene.appState[key]
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify({ elements: parsed.scene.elements, files: parsed.scene.files, appState })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createExcalidrawSession(options: ExcalidrawSessionOptions): ExcalidrawSession {
|
|
86
|
+
const debounceMs = options.debounceMs ?? 200
|
|
87
|
+
const maxWaitMs = options.maxWaitMs ?? 1000
|
|
88
|
+
const state = ref<ExcalidrawWriteState>('idle')
|
|
89
|
+
const lastError = ref('')
|
|
90
|
+
const revision = ref(options.initialRevision)
|
|
91
|
+
|
|
92
|
+
let savedSignature = persistedSceneSignature(options.initialContent)
|
|
93
|
+
let savedContent = options.initialContent
|
|
94
|
+
let queued: string | null = null
|
|
95
|
+
let writing = false
|
|
96
|
+
let writeAgain = false
|
|
97
|
+
let disposed = false
|
|
98
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
99
|
+
let maxTimer: ReturnType<typeof setTimeout> | null = null
|
|
100
|
+
|
|
101
|
+
function clearTimers(): void {
|
|
102
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
103
|
+
if (maxTimer) clearTimeout(maxTimer)
|
|
104
|
+
debounceTimer = null
|
|
105
|
+
maxTimer = null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function schedule(): void {
|
|
109
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
110
|
+
debounceTimer = setTimeout(() => {
|
|
111
|
+
debounceTimer = null
|
|
112
|
+
void flush()
|
|
113
|
+
}, debounceMs)
|
|
114
|
+
// 最大等待:连续编辑时至少每 maxWaitMs 落盘一次
|
|
115
|
+
if (!maxTimer) {
|
|
116
|
+
maxTimer = setTimeout(() => {
|
|
117
|
+
maxTimer = null
|
|
118
|
+
void flush()
|
|
119
|
+
}, maxWaitMs)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function update(content: string): void {
|
|
124
|
+
if (disposed) return
|
|
125
|
+
const signature = persistedSceneSignature(content)
|
|
126
|
+
if (signature === savedSignature && queued === null) return // 展示状态变化,不写盘
|
|
127
|
+
queued = content
|
|
128
|
+
if (state.value !== 'writing') state.value = 'pending'
|
|
129
|
+
schedule()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runWrite(): Promise<void> {
|
|
133
|
+
if (writing || disposed) {
|
|
134
|
+
if (writing) writeAgain = true
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const content = queued
|
|
138
|
+
if (content === null) return
|
|
139
|
+
if (persistedSceneSignature(content) === savedSignature) {
|
|
140
|
+
queued = null
|
|
141
|
+
state.value = 'idle'
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writing = true
|
|
146
|
+
state.value = 'writing'
|
|
147
|
+
const writingSignature = persistedSceneSignature(content)
|
|
148
|
+
try {
|
|
149
|
+
const result = await options.save({ content, expectedRevision: revision.value })
|
|
150
|
+
if (disposed) return
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
revision.value = result.revision
|
|
153
|
+
savedContent = content
|
|
154
|
+
savedSignature = writingSignature
|
|
155
|
+
lastError.value = ''
|
|
156
|
+
// 旧回调不能清掉更新的状态:只有当前队列就是刚写成功的内容才算干净
|
|
157
|
+
if (queued !== null && persistedSceneSignature(queued) === writingSignature) {
|
|
158
|
+
queued = null
|
|
159
|
+
}
|
|
160
|
+
state.value = queued === null ? 'idle' : 'pending'
|
|
161
|
+
} else if (result.code === 'conflict') {
|
|
162
|
+
state.value = 'conflict'
|
|
163
|
+
lastError.value = result.message
|
|
164
|
+
} else {
|
|
165
|
+
state.value = 'failed'
|
|
166
|
+
lastError.value = result.message
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (!disposed) {
|
|
170
|
+
state.value = 'failed'
|
|
171
|
+
lastError.value = error instanceof Error ? error.message : String(error)
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
writing = false
|
|
175
|
+
if (writeAgain) {
|
|
176
|
+
writeAgain = false
|
|
177
|
+
void runWrite()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function flush(): Promise<void> {
|
|
183
|
+
if (disposed) return
|
|
184
|
+
clearTimers()
|
|
185
|
+
await runWrite()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function retry(): Promise<void> {
|
|
189
|
+
if (disposed) return
|
|
190
|
+
if (state.value === 'failed' && queued === null) queued = savedContent
|
|
191
|
+
await flush()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function adopt(content: string, nextRevision?: string): void {
|
|
195
|
+
if (disposed) return
|
|
196
|
+
clearTimers()
|
|
197
|
+
queued = null
|
|
198
|
+
savedContent = content
|
|
199
|
+
savedSignature = persistedSceneSignature(content)
|
|
200
|
+
if (nextRevision !== undefined) revision.value = nextRevision
|
|
201
|
+
lastError.value = ''
|
|
202
|
+
state.value = 'idle'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function dispose(): void {
|
|
206
|
+
disposed = true
|
|
207
|
+
clearTimers()
|
|
208
|
+
queued = null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
state,
|
|
213
|
+
lastError,
|
|
214
|
+
revision,
|
|
215
|
+
update,
|
|
216
|
+
flush,
|
|
217
|
+
retry,
|
|
218
|
+
adopt,
|
|
219
|
+
currentContent: () => queued ?? savedContent,
|
|
220
|
+
pendingContent: () => queued,
|
|
221
|
+
hasPending: () => queued !== null,
|
|
222
|
+
dispose
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 懒加载的导出适配:**唯一**静态引用 @excalidraw/excalidraw 的地方。
|
|
3
|
+
* 宿主(Desk 卡片 / SSG 客户端)通过动态 import 使用它,避免把编辑器打进首屏。
|
|
4
|
+
*/
|
|
5
|
+
import { exportToSvg } from '@excalidraw/excalidraw'
|
|
6
|
+
|
|
7
|
+
import { getExcalidrawAssetPath, inlineExcalidrawFonts } from './fonts'
|
|
8
|
+
|
|
9
|
+
import type { ExcalidrawScene } from './scene'
|
|
10
|
+
|
|
11
|
+
export interface ExcalidrawExportOptions {
|
|
12
|
+
/** 深色主题导出;只影响显示,不修改磁盘 appState */
|
|
13
|
+
dark: boolean
|
|
14
|
+
/** 导出背景;文档自身语义优先,默认沿用场景里的 viewBackgroundColor */
|
|
15
|
+
background?: boolean
|
|
16
|
+
/** 本地字体基址;缺省用 window.EXCALIDRAW_ASSET_PATH */
|
|
17
|
+
fontBase?: string
|
|
18
|
+
/** 便于测试注入 */
|
|
19
|
+
fetchImpl?: typeof fetch
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成自包含的 SVG 字符串。实测官方导出会内嵌 @font-face 与内联图片 data URL,
|
|
24
|
+
* 因此可以安全地作为 <img src="data:image/svg+xml…"> 渲染:没有脚本执行、
|
|
25
|
+
* 没有外部网络请求。
|
|
26
|
+
*/
|
|
27
|
+
export async function renderExcalidrawSvg(
|
|
28
|
+
scene: ExcalidrawScene,
|
|
29
|
+
options: ExcalidrawExportOptions
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const svg = await exportToSvg({
|
|
32
|
+
elements: scene.elements as never,
|
|
33
|
+
appState: {
|
|
34
|
+
...(scene.appState as Record<string, unknown>),
|
|
35
|
+
exportWithDarkMode: options.dark,
|
|
36
|
+
...(options.background === undefined ? {} : { exportBackground: options.background })
|
|
37
|
+
},
|
|
38
|
+
files: (scene.files ?? {}) as never
|
|
39
|
+
})
|
|
40
|
+
// 只读路径必须自包含:把 CDN/相对字体换成 data URL,离线与 <img> 渲染才不掉字体
|
|
41
|
+
const inlined = await inlineExcalidrawFonts(svg.outerHTML, {
|
|
42
|
+
base: options.fontBase ?? getExcalidrawAssetPath(),
|
|
43
|
+
fetchImpl: options.fetchImpl
|
|
44
|
+
})
|
|
45
|
+
if (inlined.remaining.length > 0) {
|
|
46
|
+
// 不能静默:宿主据此提示「字体未内联,离线可能掉字体」
|
|
47
|
+
console.warn('[tnotes] excalidraw fonts not inlined', inlined.remaining.slice(0, 3))
|
|
48
|
+
}
|
|
49
|
+
return inlined.svg
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画布键盘焦点。
|
|
3
|
+
*
|
|
4
|
+
* 背景(E0/E3 实测):Excalidraw 把键盘快捷键挂在容器上,宿主节点被搬动
|
|
5
|
+
* (卡片 → 全屏 → 标签页)之后焦点会留在触发交接的控件上,快捷键不再送达编辑器;
|
|
6
|
+
* 指针路径正常。恢复方式只有一条:blur 当前焦点 → 给交互画布补 tabindex → focus。
|
|
7
|
+
*
|
|
8
|
+
* E4 补充:标签页/分栏切回来时承载容器可能还在 `display:none` 里(组件 watcher
|
|
9
|
+
* 触发的 DOM 更新还没落地),此时 `focus()` 静默失败、焦点留在 body。
|
|
10
|
+
* 因此用 `restoreCanvasKeyboardFocusWhenReady` 等到画布真的有高度再聚焦。
|
|
11
|
+
*/
|
|
12
|
+
export function restoreCanvasKeyboardFocus(host: HTMLElement): boolean {
|
|
13
|
+
if (typeof document === 'undefined') return false
|
|
14
|
+
const active = document.activeElement
|
|
15
|
+
if (active instanceof HTMLElement) active.blur()
|
|
16
|
+
const canvas = host.querySelector<HTMLElement>('.excalidraw__canvas.interactive')
|
|
17
|
+
if (!canvas) return false
|
|
18
|
+
if (!canvas.hasAttribute('tabindex')) canvas.setAttribute('tabindex', '0')
|
|
19
|
+
canvas.focus()
|
|
20
|
+
return document.activeElement === canvas
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 等画布真的有布局(高度 > 0)再聚焦;最多重试 30 帧。 */
|
|
24
|
+
export function restoreCanvasKeyboardFocusWhenReady(host: HTMLElement, attempt = 0): void {
|
|
25
|
+
if (typeof document === 'undefined') return
|
|
26
|
+
const canvas = host.querySelector<HTMLElement>('.excalidraw__canvas.interactive')
|
|
27
|
+
const laidOut = canvas ? canvas.getBoundingClientRect().height > 0 : false
|
|
28
|
+
if (laidOut || typeof requestAnimationFrame !== 'function' || attempt >= 30) {
|
|
29
|
+
restoreCanvasKeyboardFocus(host)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
requestAnimationFrame(() => restoreCanvasKeyboardFocusWhenReady(host, attempt + 1))
|
|
33
|
+
}
|