@tnotesjs/ui 0.1.4 → 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 +28 -13
- 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 +2 -2
- 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,66 @@
|
|
|
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
|
+
})
|
|
@@ -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,148 @@
|
|
|
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
|
+
})
|
|
@@ -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'
|
|
@@ -51,9 +51,7 @@ describe('image markdown helpers', () => {
|
|
|
51
51
|
rest: ''
|
|
52
52
|
})
|
|
53
53
|
expect(normalizeImageAlign('middle')).toBe('center')
|
|
54
|
-
expect(formatImageAttrs({ width: '400px', align: 'center' })).toBe(
|
|
55
|
-
'{w=400px align=center}'
|
|
56
|
-
)
|
|
54
|
+
expect(formatImageAttrs({ width: '400px', align: 'center' })).toBe('{w=400px align=center}')
|
|
57
55
|
expect(formatImageAttrs({ align: 'left' })).toBe('')
|
|
58
56
|
})
|
|
59
57
|
|
package/src/markdown/image.ts
CHANGED
|
@@ -76,13 +76,9 @@ export function normalizeImageWidth(raw: string): string {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Logical CSS px for a pasted bitmap, folding device pixel ratio (4K/retina). */
|
|
79
|
-
export function pasteDisplayWidthPx(
|
|
80
|
-
naturalWidth: number,
|
|
81
|
-
devicePixelRatio = 1
|
|
82
|
-
): string {
|
|
79
|
+
export function pasteDisplayWidthPx(naturalWidth: number, devicePixelRatio = 1): string {
|
|
83
80
|
if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return ''
|
|
84
|
-
const dpr =
|
|
85
|
-
Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1
|
|
81
|
+
const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1
|
|
86
82
|
return normalizeImageWidth(`${Math.round(naturalWidth / dpr)}px`)
|
|
87
83
|
}
|
|
88
84
|
|
|
@@ -90,10 +86,7 @@ export function formatImageWidthAttr(width: string): string {
|
|
|
90
86
|
return formatImageAttrs({ width, align: 'left' })
|
|
91
87
|
}
|
|
92
88
|
|
|
93
|
-
export function formatImageAttrs(attrs: {
|
|
94
|
-
width?: string
|
|
95
|
-
align?: ImageAlign | string
|
|
96
|
-
}): string {
|
|
89
|
+
export function formatImageAttrs(attrs: { width?: string; align?: ImageAlign | string }): string {
|
|
97
90
|
const width = normalizeImageWidth(attrs.width ?? '')
|
|
98
91
|
const align = normalizeImageAlign(attrs.align ?? '')
|
|
99
92
|
const parts: string[] = []
|
|
@@ -104,9 +97,7 @@ export function formatImageAttrs(attrs: {
|
|
|
104
97
|
|
|
105
98
|
export function serializeImageMarkdown(image: ImageMarkdown): string {
|
|
106
99
|
const alt = image.alt.replaceAll('[', '').replaceAll(']', '')
|
|
107
|
-
const title = image.title?.trim()
|
|
108
|
-
? ` "${image.title.replaceAll('"', '')}"`
|
|
109
|
-
: ''
|
|
100
|
+
const title = image.title?.trim() ? ` "${image.title.replaceAll('"', '')}"` : ''
|
|
110
101
|
const suffix = formatImageAttrs({
|
|
111
102
|
width: image.width ?? '',
|
|
112
103
|
align: image.align ?? 'left'
|
package/src/styles/code.css
CHANGED
|
@@ -105,13 +105,13 @@
|
|
|
105
105
|
color: var(--shiki-light);
|
|
106
106
|
background-color: transparent;
|
|
107
107
|
}
|
|
108
|
-
html.dark:not([data-theme=
|
|
109
|
-
html[data-theme=
|
|
108
|
+
html.dark:not([data-theme='light']) .tn-code-highlight,
|
|
109
|
+
html[data-theme='dark'] .tn-code-highlight {
|
|
110
110
|
color: var(--shiki-dark);
|
|
111
111
|
background-color: var(--tn-c-bg-soft);
|
|
112
112
|
}
|
|
113
|
-
html.dark:not([data-theme=
|
|
114
|
-
html[data-theme=
|
|
113
|
+
html.dark:not([data-theme='light']) .tn-code-highlight span:not(.line),
|
|
114
|
+
html[data-theme='dark'] .tn-code-highlight span:not(.line) {
|
|
115
115
|
color: var(--shiki-dark);
|
|
116
116
|
background-color: transparent;
|
|
117
117
|
}
|
|
@@ -151,7 +151,7 @@ html[data-theme="dark"] .tn-code-highlight span:not(.line) {
|
|
|
151
151
|
border-bottom: 2px solid transparent;
|
|
152
152
|
padding: 4px 10px;
|
|
153
153
|
}
|
|
154
|
-
.tn-code-group__tabs button[aria-selected=
|
|
154
|
+
.tn-code-group__tabs button[aria-selected='true'] {
|
|
155
155
|
color: var(--tn-c-brand);
|
|
156
156
|
border-bottom-color: var(--tn-c-brand);
|
|
157
157
|
}
|
package/src/styles/prose.css
CHANGED
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
:is(.tn-prose, .vp-doc) :where(hr) {
|
|
105
105
|
margin: 16px 0;
|
|
106
106
|
border: 0;
|
|
107
|
-
border-top: 1px solid var(--tn-c-
|
|
107
|
+
border-top: 1px solid var(--tn-c-divider);
|
|
108
108
|
}
|
|
109
109
|
:is(.tn-prose, .vp-doc) :where(img) {
|
|
110
110
|
max-width: 100%;
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
display: none;
|
|
199
199
|
}
|
|
200
200
|
:is(.tn-prose, .vp-doc) :where(details.tn-custom-block > summary::before) {
|
|
201
|
-
content:
|
|
201
|
+
content: '';
|
|
202
202
|
flex: none;
|
|
203
203
|
box-sizing: border-box;
|
|
204
204
|
width: 0.42em;
|