@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.
Files changed (36) hide show
  1. package/README.md +81 -0
  2. package/dist/code/highlight.d.ts +39 -1
  3. package/dist/code/highlight.js +54 -1
  4. package/package.json +21 -7
  5. package/src/code/collapse.ts +79 -0
  6. package/src/code/highlight.ts +12 -0
  7. package/src/components/CodeBlock/CodeBlock.vue +39 -5
  8. package/src/components/CodeGroup/CodeGroup.vue +70 -7
  9. package/src/components/Mindmap/Mindmap.vue +7 -6
  10. package/src/components/Mindmap/editor/CanvasContextMenu.vue +77 -68
  11. package/src/components/Mindmap/editor/CollapseMenu.vue +113 -0
  12. package/src/components/Mindmap/editor/IconButton.vue +63 -0
  13. package/src/components/Mindmap/editor/LinkPopover.vue +50 -46
  14. package/src/components/Mindmap/editor/MarkdownView.vue +5 -3
  15. package/src/components/Mindmap/editor/MindmapView.vue +11 -1
  16. package/src/components/Mindmap/editor/SearchBar.vue +672 -0
  17. package/src/components/Mindmap/editor/SelectionToolbar.vue +1 -1
  18. package/src/entries/excalidraw-view.ts +5 -0
  19. package/src/entries/mindmap-editor.ts +37 -0
  20. package/src/excalidraw/exporter.ts +1 -0
  21. package/src/excalidraw/fonts.ts +21 -1
  22. package/src/styles/code.css +74 -2
  23. package/src/styles/prose.css +45 -0
  24. package/src/styles/tokens.css +6 -0
  25. package/src/code/highlight.test.ts +0 -72
  26. package/src/components/Mermaid/Mermaid.test.ts +0 -54
  27. package/src/components/Mermaid/adoptLiveSvg.test.ts +0 -16
  28. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +0 -275
  29. package/src/components/Mindmap/editor/canvasClipboard.ts +0 -2
  30. package/src/components/sharedMedia.test.ts +0 -121
  31. package/src/excalidraw/editorSession.test.ts +0 -205
  32. package/src/excalidraw/focus.test.ts +0 -60
  33. package/src/excalidraw/fonts.test.ts +0 -66
  34. package/src/excalidraw/renderer.test.ts +0 -148
  35. package/src/markdown/image.test.ts +0 -86
  36. package/src/swiper/hydrate.test.ts +0 -61
@@ -1,66 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- import {
4
- getExcalidrawAssetPath,
5
- inlineExcalidrawFonts,
6
- resolveFontUrl,
7
- setExcalidrawAssetPath
8
- } from './fonts'
9
-
10
- const SVG = `<svg><style>@font-face{src:url(https://esm.sh/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Virgil/Virgil-Regular.woff2)}</style></svg>`
11
-
12
- function fetchReturning(bytes: number[], ok = true) {
13
- return vi.fn(async () => ({
14
- ok,
15
- status: ok ? 200 : 404,
16
- arrayBuffer: async () => new Uint8Array(bytes).buffer
17
- })) as unknown as typeof fetch
18
- }
19
-
20
- describe('画布字体自包含', () => {
21
- it('把字体基址换成配置的本地目录', () => {
22
- expect(
23
- resolveFontUrl(
24
- 'https://esm.sh/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Virgil/Virgil-Regular.woff2',
25
- '/excalidraw/'
26
- )
27
- ).toBe('/excalidraw/fonts/Virgil/Virgil-Regular.woff2')
28
- expect(resolveFontUrl('/fonts/Virgil/x.woff2')).toBe('/fonts/Virgil/x.woff2')
29
- })
30
-
31
- it('把 SVG 里的字体内联成 data URL,不再有外链', async () => {
32
- const result = await inlineExcalidrawFonts(SVG, {
33
- base: '/excalidraw/',
34
- fetchImpl: fetchReturning([1, 2, 3, 4])
35
- })
36
-
37
- expect(result.inlined).toBe(1)
38
- expect(result.remaining).toEqual([])
39
- expect(result.svg).toContain('data:font/woff2;base64,')
40
- expect(result.svg).not.toContain('esm.sh')
41
- })
42
-
43
- it('取不到字体时如实报告 remaining,不静默失败', async () => {
44
- const result = await inlineExcalidrawFonts(SVG, {
45
- base: '/excalidraw/',
46
- fetchImpl: fetchReturning([], false)
47
- })
48
-
49
- expect(result.inlined).toBe(0)
50
- expect(result.remaining).toEqual(['/excalidraw/fonts/Virgil/Virgil-Regular.woff2'])
51
- expect(result.svg).toBe(SVG)
52
- })
53
-
54
- it('没有字体引用时不做任何事', async () => {
55
- const result = await inlineExcalidrawFonts('<svg></svg>', {
56
- fetchImpl: fetchReturning([1])
57
- })
58
- expect(result.inlined).toBe(0)
59
- expect(result.remaining).toEqual([])
60
- })
61
-
62
- it('资产基址可设置与读取(离线必需)', () => {
63
- setExcalidrawAssetPath('/excalidraw/')
64
- expect(getExcalidrawAssetPath()).toBe('/excalidraw/')
65
- })
66
- })
@@ -1,148 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- import { createExcalidrawSvgRenderer } from './renderer'
4
- import { SvgCache } from './scene'
5
-
6
- const scene = (extra: Record<string, unknown> = {}) =>
7
- JSON.stringify({
8
- type: 'excalidraw',
9
- elements: [{ id: 'rect-1' }],
10
- appState: {},
11
- files: {},
12
- ...extra
13
- })
14
-
15
- function setup(options: {
16
- content: string
17
- theme?: 'light' | 'dark'
18
- render?: () => Promise<string>
19
- }) {
20
- const renderCalls: Array<{ dark: boolean }> = []
21
- const cache = new SvgCache(4)
22
- const renderer = createExcalidrawSvgRenderer({
23
- getContent: () => options.content,
24
- getTheme: () => options.theme ?? 'light',
25
- cache,
26
- loadExporter: async () => ({
27
- renderExcalidrawSvg: async (_scene, exportOptions) => {
28
- renderCalls.push(exportOptions)
29
- if (options.render) return await options.render()
30
- return `<svg data-dark="${exportOptions.dark}">ok</svg>`
31
- }
32
- })
33
- })
34
- return { renderer, renderCalls, cache }
35
- }
36
-
37
- beforeEach(() => {
38
- vi.restoreAllMocks()
39
- })
40
-
41
- describe('只读画布渲染器', () => {
42
- it('渲染成功的 SVG 变成 data URL,不触碰文件系统', async () => {
43
- const { renderer, renderCalls } = setup({ content: scene() })
44
- await renderer.render()
45
-
46
- expect(renderer.state.value).toBe('ready')
47
- expect(renderer.dataUrl.value).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
48
- expect(decodeURIComponent(renderer.dataUrl.value)).toContain('<svg')
49
- expect(renderCalls).toHaveLength(1)
50
- })
51
-
52
- it('相同内容与主题命中缓存,不重复导出', async () => {
53
- const cache = new SvgCache(4)
54
- const calls: number[] = []
55
- const make = () =>
56
- createExcalidrawSvgRenderer({
57
- getContent: () => scene(),
58
- getTheme: () => 'light',
59
- cache,
60
- loadExporter: async () => ({
61
- renderExcalidrawSvg: async () => {
62
- calls.push(1)
63
- return '<svg>once</svg>'
64
- }
65
- })
66
- })
67
- await make().render()
68
- await make().render()
69
- expect(calls).toHaveLength(1)
70
- })
71
-
72
- it('深色主题单独缓存,导出选项跟随主题', async () => {
73
- const { renderer, renderCalls, cache } = setup({ content: scene(), theme: 'dark' })
74
- await renderer.render()
75
- expect(renderCalls[0]).toEqual({ dark: true })
76
- expect(cache.size).toBe(1)
77
- expect(decodeURIComponent(renderer.dataUrl.value)).toContain('data-dark="true"')
78
- })
79
-
80
- it('非法 JSON 与空场景进入错误态,且不调用导出器', async () => {
81
- const broken = setup({ content: '{ nope' })
82
- await broken.renderer.render()
83
- expect(broken.renderer.state.value).toBe('error')
84
- expect(broken.renderer.errorReason.value).toContain('合法 JSON')
85
- expect(broken.renderCalls).toHaveLength(0)
86
-
87
- const empty = setup({ content: JSON.stringify({ type: 'excalidraw', elements: [] }) })
88
- await empty.renderer.render()
89
- expect(empty.renderer.state.value).toBe('error')
90
- expect(empty.renderer.errorReason.value).toBe('空画布')
91
- })
92
-
93
- it('导出抛错时进入错误态而不是崩掉', async () => {
94
- const { renderer } = setup({
95
- content: scene(),
96
- render: async () => {
97
- throw new Error('导出失败')
98
- }
99
- })
100
- await renderer.render()
101
- expect(renderer.state.value).toBe('error')
102
- expect(renderer.errorReason.value).toBe('导出失败')
103
- expect(renderer.dataUrl.value).toBe('')
104
- })
105
-
106
- it('在途结果过期时被丢弃(内容已变化)', async () => {
107
- let release: ((svg: string) => void) | undefined
108
- const calls: string[] = []
109
- let content = scene()
110
- const renderer = createExcalidrawSvgRenderer({
111
- getContent: () => content,
112
- getTheme: () => 'light',
113
- cache: new SvgCache(4),
114
- loadExporter: async () => ({
115
- renderExcalidrawSvg: async () => {
116
- calls.push(content)
117
- if (calls.length === 1) {
118
- return await new Promise<string>((resolve) => {
119
- release = resolve
120
- })
121
- }
122
- return '<svg>new</svg>'
123
- }
124
- })
125
- })
126
-
127
- const first = renderer.render()
128
- content = scene({ elements: [{ id: 'newer' }] })
129
- const second = renderer.render()
130
- // 等两次 render 都进入 exporter(动态 import 需要一个微任务)
131
- for (let tick = 0; tick < 5 && !release; tick += 1) await Promise.resolve()
132
- release?.('<svg>stale</svg>')
133
- await Promise.all([first, second])
134
-
135
- expect(decodeURIComponent(renderer.dataUrl.value)).toContain('new')
136
- expect(decodeURIComponent(renderer.dataUrl.value)).not.toContain('stale')
137
- })
138
-
139
- it('释放后清空 data URL,之后再渲染不再产生输出', async () => {
140
- const { renderer } = setup({ content: scene() })
141
- await renderer.render()
142
- renderer.dispose()
143
- expect(renderer.dataUrl.value).toBe('')
144
- await renderer.render()
145
- expect(renderer.state.value).toBe('idle')
146
- expect(renderer.dataUrl.value).toBe('')
147
- })
148
- })
@@ -1,86 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import {
4
- formatImageAttrs,
5
- formatImageWidthAttr,
6
- normalizeImageAlign,
7
- normalizeImageWidth,
8
- parseImageAttrs,
9
- parseImageWidthAttr,
10
- pasteDisplayWidthPx,
11
- serializeImageMarkdown
12
- } from './image'
13
-
14
- describe('image markdown helpers', () => {
15
- it('accepts w and width, with or without a percent sign', () => {
16
- expect(parseImageWidthAttr(' {w=50%} ')).toEqual({ width: '50%', rest: '' })
17
- expect(parseImageWidthAttr('{width=33}')).toEqual({ width: '33%', rest: '' })
18
- expect(parseImageWidthAttr('not-an-attr')).toEqual({
19
- width: '',
20
- rest: 'not-an-attr'
21
- })
22
- })
23
-
24
- it('folds pasted bitmap width by device pixel ratio', () => {
25
- expect(pasteDisplayWidthPx(800, 2)).toBe('400px')
26
- expect(pasteDisplayWidthPx(400, 1)).toBe('400px')
27
- expect(pasteDisplayWidthPx(20, 2)).toBe('')
28
- expect(pasteDisplayWidthPx(0, 2)).toBe('')
29
- })
30
-
31
- it('accepts pixel widths from drag-resize', () => {
32
- expect(parseImageWidthAttr('{w=400px}')).toEqual({ width: '400px', rest: '' })
33
- expect(normalizeImageWidth('400px')).toBe('400px')
34
- expect(normalizeImageWidth('8px')).toBe('')
35
- })
36
-
37
- it('parses alignment beside width in one brace group', () => {
38
- expect(parseImageAttrs('{w=50% align=center}')).toEqual({
39
- width: '50%',
40
- align: 'center',
41
- rest: ''
42
- })
43
- expect(parseImageAttrs('{align=right}')).toEqual({
44
- width: '',
45
- align: 'right',
46
- rest: ''
47
- })
48
- expect(parseImageAttrs('{w=400px,align=center}')).toEqual({
49
- width: '400px',
50
- align: 'center',
51
- rest: ''
52
- })
53
- expect(normalizeImageAlign('middle')).toBe('center')
54
- expect(formatImageAttrs({ width: '400px', align: 'center' })).toBe('{w=400px align=center}')
55
- expect(formatImageAttrs({ align: 'left' })).toBe('')
56
- })
57
-
58
- it('rejects empty, zero, and over-wide values', () => {
59
- expect(normalizeImageWidth('')).toBe('')
60
- expect(normalizeImageWidth('0%')).toBe('')
61
- expect(normalizeImageWidth('120%')).toBe('')
62
- expect(normalizeImageWidth('auto')).toBe('')
63
- })
64
-
65
- it('serializes caption as alt and omits natural width', () => {
66
- expect(
67
- serializeImageMarkdown({
68
- alt: '知识库资源示意',
69
- src: '../assets/pixel.svg',
70
- width: '50%'
71
- })
72
- ).toBe('![知识库资源示意](../assets/pixel.svg) {w=50%}')
73
- expect(serializeImageMarkdown({ alt: '', src: '../assets/pixel.svg' })).toBe(
74
- '![](../assets/pixel.svg)'
75
- )
76
- expect(formatImageWidthAttr('100')).toBe('{w=100%}')
77
- expect(
78
- serializeImageMarkdown({
79
- alt: '',
80
- src: '../assets/shot.png',
81
- width: '400px',
82
- align: 'right'
83
- })
84
- ).toBe('![](../assets/shot.png) {w=400px align=right}')
85
- })
86
- })
@@ -1,61 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import { describe, expect, it } from 'vitest'
4
-
5
- import { hydrateTnSwipers } from './hydrate'
6
-
7
- function swiperShell(titles: string[]): HTMLElement {
8
- const root = document.createElement('div')
9
- root.innerHTML = `
10
- <div class="tn-swiper">
11
- <div class="tn-swiper-tabs"></div>
12
- <div class="swiper-container">
13
- <div class="swiper-wrapper">
14
- ${titles
15
- .map(
16
- (title) =>
17
- `<div class="swiper-slide" data-title="${title}"><img alt="${title}" src="/${title}.svg"></div>`
18
- )
19
- .join('')}
20
- </div>
21
- </div>
22
- </div>
23
- `
24
- document.body.append(root)
25
- return root
26
- }
27
-
28
- describe('hydrateTnSwipers', () => {
29
- it('builds tabs and shows only the active slide', () => {
30
- const root = swiperShell(['A', 'B', 'C'])
31
- expect(hydrateTnSwipers(root)).toBe(1)
32
-
33
- const host = root.querySelector('.tn-swiper')!
34
- const tabs = [...host.querySelectorAll('.tn-tab')].map((tab) => tab.textContent)
35
- expect(tabs).toEqual(['A', 'B', 'C'])
36
- expect(host.querySelectorAll('.tn-tab-nav')).toHaveLength(3) // < / >
37
-
38
- const slides = [...host.querySelectorAll('.swiper-slide')] as HTMLElement[]
39
- expect(slides[0]!.classList.contains('is-active')).toBe(true)
40
- expect(slides[0]!.hidden).toBe(false)
41
- expect(slides[1]!.hidden).toBe(true)
42
- expect(slides[2]!.hidden).toBe(true)
43
-
44
- ;(host.querySelectorAll('.tn-tab')[1] as HTMLButtonElement).click()
45
- expect(slides[0]!.hidden).toBe(true)
46
- expect(slides[1]!.hidden).toBe(false)
47
- expect(slides[1]!.classList.contains('is-active')).toBe(true)
48
-
49
- // Idempotent
50
- expect(hydrateTnSwipers(root)).toBe(0)
51
- root.remove()
52
- })
53
-
54
- it('omits the tab bar for a single slide', () => {
55
- const root = swiperShell(['solo'])
56
- hydrateTnSwipers(root)
57
- expect(root.querySelector('.tn-swiper-tabs')).toBeNull()
58
- expect(root.querySelector('.swiper-slide')!.classList.contains('is-active')).toBe(true)
59
- root.remove()
60
- })
61
- })