@tnotesjs/ui 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +26 -9
- package/src/browser/clipboard.ts +16 -19
- package/src/browser/theme.ts +10 -15
- 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.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/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.ts +224 -0
- package/src/excalidraw/exporter.ts +50 -0
- package/src/excalidraw/focus.ts +33 -0
- package/src/excalidraw/fonts.ts +92 -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.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.ts +91 -102
- package/src/code/highlight.test.ts +0 -55
- 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 -265
- package/src/components/sharedMedia.test.ts +0 -139
- package/src/markdown/image.test.ts +0 -88
- package/src/swiper/hydrate.test.ts +0 -65
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 字体自包含。
|
|
3
|
+
*
|
|
4
|
+
* 背景:`@excalidraw/excalidraw` 官方 npm 包把资源基址硬编码为
|
|
5
|
+
* `https://esm.sh/@excalidraw/excalidraw@<version>/dist/prod/`(源码里的
|
|
6
|
+
* `ASSETS_FALLBACK_URL`),导出的 SVG 会引用 CDN 字体:
|
|
7
|
+
* - 离线 Desk / 离线站点会掉字体;
|
|
8
|
+
* - 用 `<img>` 渲染 SVG 时浏览器禁止加载外部资源,字体一定不生效。
|
|
9
|
+
*
|
|
10
|
+
* 因此只读路径必须把 SVG 实际引用到的字体内联成 data URL;同时用
|
|
11
|
+
* `EXCALIDRAW_ASSET_PATH` 让编辑器与导出时的文本度量走本地字体目录。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface InlineFontsOptions {
|
|
15
|
+
/** 本地资源基址(与 window.EXCALIDRAW_ASSET_PATH 同一口径),如 '/excalidraw/' */
|
|
16
|
+
base?: string
|
|
17
|
+
/** 便于测试注入 */
|
|
18
|
+
fetchImpl?: typeof fetch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InlineFontsResult {
|
|
22
|
+
svg: string
|
|
23
|
+
/** 成功内联的字体文件数 */
|
|
24
|
+
inlined: number
|
|
25
|
+
/** 内联失败仍是外链的 URL(调用方应记录,不能静默) */
|
|
26
|
+
remaining: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FONT_URL_PATTERN = /url\((?:'|")?([^'")]+\.woff2?)(?:'|")?\)/g
|
|
30
|
+
|
|
31
|
+
/** 把 CDN / 相对基址统一成本地基址下的字体路径。 */
|
|
32
|
+
export function resolveFontUrl(rawUrl: string, base?: string): string {
|
|
33
|
+
const fileName = rawUrl.split('/fonts/').pop() ?? rawUrl.split('/').pop() ?? rawUrl
|
|
34
|
+
if (!base) return rawUrl
|
|
35
|
+
const prefix = base.endsWith('/') ? base : `${base}/`
|
|
36
|
+
return `${prefix}fonts/${fileName}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toBase64(bytes: Uint8Array): string {
|
|
40
|
+
// 纯浏览器实现:这个包不依赖 node 类型(Buffer 在 SSG/Desk 浏览器侧不存在)
|
|
41
|
+
let binary = ''
|
|
42
|
+
const chunk = 0x8000
|
|
43
|
+
for (let offset = 0; offset < bytes.length; offset += chunk) {
|
|
44
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk))
|
|
45
|
+
}
|
|
46
|
+
return btoa(binary)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mimeFor(url: string): string {
|
|
50
|
+
return url.endsWith('.woff') ? 'font/woff' : 'font/woff2'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function inlineExcalidrawFonts(
|
|
54
|
+
svg: string,
|
|
55
|
+
options: InlineFontsOptions = {}
|
|
56
|
+
): Promise<InlineFontsResult> {
|
|
57
|
+
const fetchImpl = options.fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined)
|
|
58
|
+
const urls = [...new Set([...svg.matchAll(FONT_URL_PATTERN)].map((match) => match[1]))]
|
|
59
|
+
if (urls.length === 0) return { svg, inlined: 0, remaining: [] }
|
|
60
|
+
if (!fetchImpl) return { svg, inlined: 0, remaining: urls }
|
|
61
|
+
|
|
62
|
+
const replacements = new Map<string, string>()
|
|
63
|
+
const remaining: string[] = []
|
|
64
|
+
for (const original of urls) {
|
|
65
|
+
const target = resolveFontUrl(original, options.base)
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetchImpl(target)
|
|
68
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
69
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
70
|
+
replacements.set(original, `data:${mimeFor(target)};base64,${toBase64(bytes)}`)
|
|
71
|
+
} catch {
|
|
72
|
+
remaining.push(target)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let next = svg
|
|
77
|
+
for (const [from, to] of replacements) {
|
|
78
|
+
next = next.split(from).join(to)
|
|
79
|
+
}
|
|
80
|
+
return { svg: next, inlined: replacements.size, remaining }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 告知 Excalidraw 从本地目录取字体(离线必需)。 */
|
|
84
|
+
export function setExcalidrawAssetPath(base: string): void {
|
|
85
|
+
if (typeof window === 'undefined') return
|
|
86
|
+
;(window as { EXCALIDRAW_ASSET_PATH?: string }).EXCALIDRAW_ASSET_PATH = base
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getExcalidrawAssetPath(): string | undefined {
|
|
90
|
+
if (typeof window === 'undefined') return undefined
|
|
91
|
+
return (window as { EXCALIDRAW_ASSET_PATH?: string }).EXCALIDRAW_ASSET_PATH
|
|
92
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读画布渲染器的纯逻辑:与 Vue 组件解耦,便于单测。
|
|
3
|
+
*
|
|
4
|
+
* 职责:解析场景 → 命中缓存或懒加载导出 → 产出可直接给 <img> 的 data URL。
|
|
5
|
+
* 不做文件写入;过期结果一律丢弃;释放时清空 data URL。
|
|
6
|
+
*/
|
|
7
|
+
import { ref, type Ref } from 'vue'
|
|
8
|
+
|
|
9
|
+
import { parseExcalidrawScene, sceneCacheKey, svgCache, type SvgCache } from './scene'
|
|
10
|
+
|
|
11
|
+
export type ExcalidrawViewState = 'idle' | 'loading' | 'ready' | 'error'
|
|
12
|
+
|
|
13
|
+
export interface ExcalidrawSvgRenderer {
|
|
14
|
+
state: Ref<ExcalidrawViewState>
|
|
15
|
+
errorReason: Ref<string>
|
|
16
|
+
dataUrl: Ref<string>
|
|
17
|
+
render(): Promise<void>
|
|
18
|
+
dispose(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ExcalidrawSvgRendererOptions {
|
|
22
|
+
getContent: () => string
|
|
23
|
+
/** undefined 表示跟随宿主(由组件解析 .dark 后回传) */
|
|
24
|
+
getTheme: () => 'light' | 'dark'
|
|
25
|
+
/** 引擎版本进缓存键:升级 Excalidraw 后旧缓存自动失效 */
|
|
26
|
+
engine?: string
|
|
27
|
+
cache?: SvgCache
|
|
28
|
+
/** 便于测试注入;默认动态 import 真实导出器 */
|
|
29
|
+
loadExporter?: () => Promise<{
|
|
30
|
+
renderExcalidrawSvg: (scene: unknown, options: { dark: boolean }) => Promise<string>
|
|
31
|
+
}>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toSvgDataUrl(svg: string): string {
|
|
35
|
+
// encodeURIComponent:中文文本与特殊字符在 data URL 里都安全
|
|
36
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createExcalidrawSvgRenderer(
|
|
40
|
+
options: ExcalidrawSvgRendererOptions
|
|
41
|
+
): ExcalidrawSvgRenderer {
|
|
42
|
+
const state = ref<ExcalidrawViewState>('idle')
|
|
43
|
+
const errorReason = ref('')
|
|
44
|
+
const dataUrl = ref('')
|
|
45
|
+
const cache = options.cache ?? svgCache
|
|
46
|
+
const engine = options.engine ?? 'excalidraw-0.18.1'
|
|
47
|
+
const loadExporter = options.loadExporter ?? (() => import('./exporter') as Promise<never>)
|
|
48
|
+
let generation = 0
|
|
49
|
+
let disposed = false
|
|
50
|
+
|
|
51
|
+
function fail(reason: string): void {
|
|
52
|
+
state.value = 'error'
|
|
53
|
+
errorReason.value = reason
|
|
54
|
+
dataUrl.value = ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function render(): Promise<void> {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
const current = (generation += 1)
|
|
60
|
+
const content = options.getContent()
|
|
61
|
+
const dark = options.getTheme() === 'dark'
|
|
62
|
+
|
|
63
|
+
const parsed = parseExcalidrawScene(content)
|
|
64
|
+
if (!parsed.ok) {
|
|
65
|
+
fail(parsed.reason)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (parsed.scene.elements.length === 0) {
|
|
69
|
+
fail('空画布')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const key = sceneCacheKey(content, dark, engine)
|
|
74
|
+
const cached = cache.get(key)
|
|
75
|
+
if (cached !== undefined) {
|
|
76
|
+
dataUrl.value = toSvgDataUrl(cached)
|
|
77
|
+
errorReason.value = ''
|
|
78
|
+
state.value = 'ready'
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
state.value = 'loading'
|
|
83
|
+
try {
|
|
84
|
+
const exporter = await loadExporter()
|
|
85
|
+
const svg = await exporter.renderExcalidrawSvg(parsed.scene, { dark })
|
|
86
|
+
if (disposed || current !== generation) return // 过期结果:内容或主题已更新
|
|
87
|
+
cache.set(key, svg)
|
|
88
|
+
dataUrl.value = toSvgDataUrl(svg)
|
|
89
|
+
errorReason.value = ''
|
|
90
|
+
state.value = 'ready'
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (disposed || current !== generation) return
|
|
93
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dispose(): void {
|
|
98
|
+
disposed = true
|
|
99
|
+
generation += 1
|
|
100
|
+
dataUrl.value = ''
|
|
101
|
+
state.value = 'idle'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { state, errorReason, dataUrl, render, dispose }
|
|
105
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读画布场景的解析与缓存键。
|
|
3
|
+
*
|
|
4
|
+
* 不写任何文件:只读视图只把 JSON 解析成场景,导出结果只进内存缓存。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ExcalidrawScene {
|
|
8
|
+
elements: unknown[]
|
|
9
|
+
appState: Record<string, unknown>
|
|
10
|
+
files: Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SceneParseResult = { ok: true; scene: ExcalidrawScene } | { ok: false; reason: string }
|
|
14
|
+
|
|
15
|
+
export function parseExcalidrawScene(content: string): SceneParseResult {
|
|
16
|
+
let parsed: unknown
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(content)
|
|
19
|
+
} catch {
|
|
20
|
+
return { ok: false, reason: '画布内容不是合法 JSON' }
|
|
21
|
+
}
|
|
22
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
23
|
+
return { ok: false, reason: '画布内容不是场景对象' }
|
|
24
|
+
}
|
|
25
|
+
const candidate = parsed as Partial<ExcalidrawScene> & { type?: unknown }
|
|
26
|
+
if (candidate.type !== 'excalidraw' || !Array.isArray(candidate.elements)) {
|
|
27
|
+
return { ok: false, reason: '不是 Excalidraw 场景' }
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
scene: {
|
|
32
|
+
elements: candidate.elements,
|
|
33
|
+
appState:
|
|
34
|
+
candidate.appState && typeof candidate.appState === 'object'
|
|
35
|
+
? (candidate.appState as Record<string, unknown>)
|
|
36
|
+
: {},
|
|
37
|
+
files:
|
|
38
|
+
candidate.files && typeof candidate.files === 'object'
|
|
39
|
+
? (candidate.files as Record<string, unknown>)
|
|
40
|
+
: {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** FNV-1a:浏览器里不需要 node:crypto,仅用于内存缓存键。 */
|
|
46
|
+
export function sceneCacheKey(content: string, dark: boolean, engine: string): string {
|
|
47
|
+
let hash = 0x811c9dc5
|
|
48
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
49
|
+
hash ^= content.charCodeAt(index)
|
|
50
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
51
|
+
}
|
|
52
|
+
return `${engine}:${dark ? 'dark' : 'light'}:${hash.toString(16)}:${content.length}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 有界内存缓存:画布越大缓存越值钱,但不能无上限。
|
|
57
|
+
* 只存字符串,卸载后由组件负责释放 Blob/data URL。
|
|
58
|
+
*/
|
|
59
|
+
export class SvgCache {
|
|
60
|
+
private readonly entries = new Map<string, string>()
|
|
61
|
+
|
|
62
|
+
constructor(private readonly limit = 24) {}
|
|
63
|
+
|
|
64
|
+
get(key: string): string | undefined {
|
|
65
|
+
const value = this.entries.get(key)
|
|
66
|
+
if (value === undefined) return undefined
|
|
67
|
+
// 命中后移到末尾,实现 LRU 淘汰
|
|
68
|
+
this.entries.delete(key)
|
|
69
|
+
this.entries.set(key, value)
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
set(key: string, value: string): void {
|
|
74
|
+
this.entries.delete(key)
|
|
75
|
+
this.entries.set(key, value)
|
|
76
|
+
while (this.entries.size > this.limit) {
|
|
77
|
+
const oldest = this.entries.keys().next().value
|
|
78
|
+
if (oldest === undefined) break
|
|
79
|
+
this.entries.delete(oldest)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clear(): void {
|
|
84
|
+
this.entries.clear()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get size(): number {
|
|
88
|
+
return this.entries.size
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 模块级共享缓存:同一篇笔记的多个入口(卡片/全屏)复用同一份导出结果。 */
|
|
93
|
+
export const svgCache = new SvgCache()
|
package/src/index.ts
CHANGED
|
@@ -1,43 +1,35 @@
|
|
|
1
|
-
export { default as BilibiliVideo } from
|
|
2
|
-
export { default as WordList } from
|
|
3
|
-
export { default as Mermaid } from
|
|
4
|
-
export { default as Mindmap } from
|
|
5
|
-
export { default as FocusBreadcrumbs } from
|
|
6
|
-
export { default as NotesTable } from
|
|
7
|
-
export { default as Footprints } from
|
|
1
|
+
export { default as BilibiliVideo } from './components/BilibiliVideo/BilibiliVideo.vue'
|
|
2
|
+
export { default as WordList } from './components/WordList/WordList.vue'
|
|
3
|
+
export { default as Mermaid } from './components/Mermaid/Mermaid.vue'
|
|
4
|
+
export { default as Mindmap } from './components/Mindmap/Mindmap.vue'
|
|
5
|
+
export { default as FocusBreadcrumbs } from './components/Mindmap/FocusBreadcrumbs.vue'
|
|
6
|
+
export { default as NotesTable } from './components/NotesTable/NotesTable.vue'
|
|
7
|
+
export { default as Footprints } from './components/Footprints/Footprints.vue'
|
|
8
8
|
export {
|
|
9
9
|
WORD_LIST_FEATURES_FULL,
|
|
10
10
|
WORD_LIST_FEATURES_STATIC,
|
|
11
|
-
resolveWordListFeatures
|
|
12
|
-
} from
|
|
13
|
-
export type { WordListFeatures } from
|
|
14
|
-
export {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
parseMindmapReference,
|
|
18
|
-
} from "./components/Mindmap/markdown";
|
|
19
|
-
export type {
|
|
20
|
-
MindmapFenceOptions,
|
|
21
|
-
MindmapReference,
|
|
22
|
-
NormalizeMindmapOptions,
|
|
23
|
-
} from "./components/Mindmap/markdown";
|
|
24
|
-
export type { NotesTableRow } from "./components/NotesTable/types";
|
|
11
|
+
resolveWordListFeatures
|
|
12
|
+
} from './components/WordList/wordListFeatures'
|
|
13
|
+
export type { WordListFeatures } from './components/WordList/wordListFeatures'
|
|
14
|
+
export { normalizeMindmapMarkdown, parseMindmapFence } from './components/Mindmap/markdown'
|
|
15
|
+
export type { MindmapFenceOptions, NormalizeMindmapOptions } from './components/Mindmap/markdown'
|
|
16
|
+
export type { NotesTableRow } from './components/NotesTable/types'
|
|
25
17
|
export {
|
|
26
18
|
parseFootprintsSource,
|
|
27
19
|
rebuildFootprintsSource,
|
|
28
|
-
parseFootprintsDatetime
|
|
29
|
-
} from
|
|
30
|
-
export type { FootprintsPayload } from
|
|
31
|
-
export { default as Badge } from
|
|
32
|
-
export { default as CodeBlock } from
|
|
33
|
-
export { default as CodeGroup } from
|
|
34
|
-
export { default as ImagePreview } from
|
|
35
|
-
export * from
|
|
36
|
-
export * from
|
|
37
|
-
export * from
|
|
20
|
+
parseFootprintsDatetime
|
|
21
|
+
} from './components/Footprints/parse'
|
|
22
|
+
export type { FootprintsPayload } from './components/Footprints/parse'
|
|
23
|
+
export { default as Badge } from './components/Badge/Badge.vue'
|
|
24
|
+
export { default as CodeBlock } from './components/CodeBlock/CodeBlock.vue'
|
|
25
|
+
export { default as CodeGroup } from './components/CodeGroup/CodeGroup.vue'
|
|
26
|
+
export { default as ImagePreview } from './components/ImagePreview/ImagePreview.vue'
|
|
27
|
+
export * from './browser/clipboard'
|
|
28
|
+
export * from './browser/theme'
|
|
29
|
+
export * from './code/highlight'
|
|
38
30
|
export {
|
|
39
31
|
hydrateTnSwipers,
|
|
40
32
|
applySwiperTabsPadding,
|
|
41
33
|
createSwiperTabNav,
|
|
42
|
-
wrapSlideIndex
|
|
43
|
-
} from
|
|
34
|
+
wrapSlideIndex
|
|
35
|
+
} from './swiper/hydrate'
|