md2ui 1.0.18 → 1.0.20

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 (80) hide show
  1. package/README.md +51 -58
  2. package/bin/build.js +95 -9
  3. package/bin/md2ui.js +102 -13
  4. package/package.json +24 -10
  5. package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
  6. package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
  7. package/public/docs/02-Markdown/346/270/262/346/237/223/00-/345/237/272/347/241/200/350/257/255/346/263/225.md +88 -0
  8. package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
  9. package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
  10. package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
  11. package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
  12. package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
  13. package/public/docs/02-Markdown/346/270/262/346/237/223/06-Mermaid/345/244/215/346/235/202/345/233/276/350/241/250/346/265/213/350/257/225.md +1376 -0
  14. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  15. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
  16. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
  17. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
  18. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
  19. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
  20. package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/05-/345/244/247/347/272/262/345/216/213/345/212/233/346/265/213/350/257/225.md +340 -0
  21. package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
  22. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
  23. package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
  24. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
  25. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
  26. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
  27. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  28. package/public/docs/07-/347/247/273/345/212/250/347/253/257/351/200/202/351/205/215/00-/345/223/215/345/272/224/345/274/217/345/270/203/345/261/200.md +37 -0
  29. package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
  30. package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
  31. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
  32. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
  33. package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
  34. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
  35. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
  36. package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
  37. package/src/App.vue +111 -12
  38. package/src/components/AppSidebar.vue +181 -21
  39. package/src/components/CodeBlockNodeView.vue +72 -0
  40. package/src/components/DocContent.vue +25 -14
  41. package/src/components/EditorContent.vue +257 -0
  42. package/src/components/EditorToolbar.vue +264 -0
  43. package/src/components/ImageZoom.vue +88 -5
  44. package/src/components/MathBlockNodeView.vue +160 -0
  45. package/src/components/MathInlineNodeView.vue +145 -0
  46. package/src/components/MermaidNodeView.vue +157 -0
  47. package/src/components/MobileSearch.vue +97 -0
  48. package/src/components/TableOfContents.vue +174 -32
  49. package/src/components/TopBar.vue +69 -4
  50. package/src/components/TreeNode.vue +232 -39
  51. package/src/components/WelcomePage.vue +2 -2
  52. package/src/composables/useDocHash.js +9 -1
  53. package/src/composables/useDocManager.js +452 -105
  54. package/src/composables/useDocTree.js +33 -2
  55. package/src/composables/useExportWord.js +73 -10
  56. package/src/composables/useFileWatcher.js +45 -0
  57. package/src/composables/useFrontmatter.js +2 -2
  58. package/src/composables/useMarkdown.js +450 -52
  59. package/src/composables/useMermaidCache.js +15 -0
  60. package/src/composables/useScroll.js +354 -27
  61. package/src/composables/useSearch.js +12 -11
  62. package/src/config.js +1 -4
  63. package/src/extensions/CodeBlockCustom.js +113 -0
  64. package/src/extensions/MathBlock.js +107 -0
  65. package/src/extensions/MathInline.js +100 -0
  66. package/src/extensions/MermaidBlock.js +73 -0
  67. package/src/extensions/TableControls.js +670 -0
  68. package/src/services/DocService.js +168 -0
  69. package/src/style.css +2416 -36
  70. package/src/utils/imageConverter.js +129 -0
  71. package/vite-plugin-doc-api.js +369 -0
  72. package/vite.config.js +7 -2
  73. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  74. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
  75. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
  76. package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
  77. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  78. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  79. package/src/api/docs.js +0 -106
  80. package/src/components/SearchPanel.vue +0 -90
@@ -0,0 +1,257 @@
1
+ <template>
2
+ <main class="content editor-content" @scroll="$emit('scroll', $event)" @click="$emit('content-click', $event)">
3
+ <WelcomePage v-if="showWelcome" @start="$emit('start')" />
4
+ <template v-else>
5
+ <EditorToolbar v-if="editor" :editor="editor" />
6
+ <div v-if="hasFrontmatter" class="markdown-content">
7
+ <div class="code-block-wrapper frontmatter-block frontmatter-editor-block">
8
+ <div class="code-block-header">
9
+ <span class="code-lang-label">FRONTMATTER</span>
10
+ <div class="code-block-actions">
11
+ <button class="code-action-btn" :data-tooltip="frontmatterCollapsed ? '展开' : '折叠'" @click="frontmatterCollapsed = !frontmatterCollapsed">
12
+ <svg v-if="frontmatterCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
13
+ <svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
14
+ </button>
15
+ </div>
16
+ </div>
17
+ <div v-show="!frontmatterCollapsed" class="code-block-body frontmatter-edit-body">
18
+ <div class="fm-edit-layout">
19
+ <div class="fm-line-gutter" aria-hidden="true">
20
+ <span v-for="n in fmLineCount" :key="n" class="code-ln-num">{{ n }}</span>
21
+ </div>
22
+ <textarea ref="fmTextarea" class="frontmatter-textarea" :value="frontmatterYaml" @input="onFrontmatterInput" spellcheck="false" rows="1" />
23
+ </div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ <article class="markdown-content editor-area">
28
+ <editor-content :editor="editor" />
29
+ </article>
30
+ <nav v-if="prevDoc || nextDoc" class="doc-nav">
31
+ <a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
32
+ <ChevronLeft :size="16" />
33
+ <div class="doc-nav-text">
34
+ <span class="doc-nav-label">上一篇</span>
35
+ <span class="doc-nav-title">{{ prevDoc.label }}</span>
36
+ </div>
37
+ </a>
38
+ <div v-else></div>
39
+ <a v-if="nextDoc" class="doc-nav-link next" @click.prevent="$emit('load-doc', nextDoc.key)">
40
+ <div class="doc-nav-text">
41
+ <span class="doc-nav-label">下一篇</span>
42
+ <span class="doc-nav-title">{{ nextDoc.label }}</span>
43
+ </div>
44
+ <ChevronRight :size="16" />
45
+ </a>
46
+ </nav>
47
+ </template>
48
+ </main>
49
+ </template>
50
+
51
+ <script setup>
52
+ import { watch, onBeforeUnmount, onMounted, ref, onUnmounted, computed, nextTick } from 'vue'
53
+ import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
54
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
55
+ import StarterKit from '@tiptap/starter-kit'
56
+ import { Table } from '@tiptap/extension-table'
57
+ import { TableRow } from '@tiptap/extension-table-row'
58
+ import { TableCell } from '@tiptap/extension-table-cell'
59
+ import { TableHeader } from '@tiptap/extension-table-header'
60
+ import { TaskList } from '@tiptap/extension-task-list'
61
+ import { TaskItem } from '@tiptap/extension-task-item'
62
+ import { Image } from '@tiptap/extension-image'
63
+ import { Placeholder } from '@tiptap/extension-placeholder'
64
+ import { Underline } from '@tiptap/extension-underline'
65
+ import { Markdown } from 'tiptap-markdown'
66
+ import { MermaidBlock } from '../extensions/MermaidBlock.js'
67
+ import { CodeBlockCustom } from '../extensions/CodeBlockCustom.js'
68
+ import { TableControls } from '../extensions/TableControls.js'
69
+ import { MathBlock } from '../extensions/MathBlock.js'
70
+ import { MathInline } from '../extensions/MathInline.js'
71
+ import { uploadImage } from '../services/DocService.js'
72
+ import { parseFrontmatter } from '../composables/useFrontmatter.js'
73
+ import WelcomePage from './WelcomePage.vue'
74
+ import EditorToolbar from './EditorToolbar.vue'
75
+
76
+ const props = defineProps({
77
+ showWelcome: { type: Boolean, default: true },
78
+ markdownContent: { type: String, default: '' },
79
+ prevDoc: { type: Object, default: null },
80
+ nextDoc: { type: Object, default: null },
81
+ docTitle: { type: String, default: '' },
82
+ currentDocPath: { type: String, default: '' }
83
+ })
84
+
85
+ const emit = defineEmits(['scroll', 'content-click', 'start', 'load-doc', 'save', 'update:markdownContent'])
86
+
87
+ const frontmatterYaml = ref('')
88
+ const hasFrontmatter = computed(() => frontmatterYaml.value.length > 0)
89
+ const frontmatterCollapsed = ref(false)
90
+ const fmTextarea = ref(null)
91
+
92
+ const fmLineCount = computed(() => {
93
+ const text = frontmatterYaml.value
94
+ if (!text) return 1
95
+ return text.split('\n').length
96
+ })
97
+
98
+ function splitFrontmatter(markdown) {
99
+ const { content, rawYaml } = parseFrontmatter(markdown)
100
+ return { yaml: rawYaml, body: content }
101
+ }
102
+
103
+ function joinFrontmatter(yaml, body) {
104
+ if (!yaml.trim()) return body
105
+ return '---\n' + yaml + '\n---\n' + body
106
+ }
107
+
108
+ function getFullMarkdown() {
109
+ if (!editor.value) return ''
110
+ const body = editor.value.storage.markdown.getMarkdown()
111
+ return joinFrontmatter(frontmatterYaml.value, body)
112
+ }
113
+
114
+ function onFrontmatterInput(e) {
115
+ frontmatterYaml.value = e.target.value
116
+ autoResizeTextarea(e.target)
117
+ emit('update:markdownContent', getFullMarkdown())
118
+ scheduleAutoSave()
119
+ }
120
+
121
+ function autoResizeTextarea(el) {
122
+ if (!el) return
123
+ el.style.height = 'auto'
124
+ el.style.height = el.scrollHeight + 'px'
125
+ }
126
+
127
+ function handleKeydown(e) {
128
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
129
+ e.preventDefault()
130
+ if (!editor.value || !props.currentDocPath) return
131
+ if (autoSaveTimer) clearTimeout(autoSaveTimer)
132
+ const md = getFullMarkdown()
133
+ emit('save', { path: props.currentDocPath, content: md })
134
+ }
135
+ }
136
+
137
+ onMounted(() => { window.addEventListener('keydown', handleKeydown) })
138
+ onUnmounted(() => { window.removeEventListener('keydown', handleKeydown) })
139
+
140
+ let autoSaveTimer = null
141
+ function scheduleAutoSave() {
142
+ if (autoSaveTimer) clearTimeout(autoSaveTimer)
143
+ autoSaveTimer = setTimeout(() => {
144
+ if (!editor.value || !props.currentDocPath) return
145
+ const md = getFullMarkdown()
146
+ emit('save', { path: props.currentDocPath, content: md })
147
+ }, 1000)
148
+ }
149
+
150
+ onUnmounted(() => {
151
+ if (autoSaveTimer) {
152
+ clearTimeout(autoSaveTimer)
153
+ if (editor.value && props.currentDocPath) {
154
+ const md = getFullMarkdown()
155
+ emit('save', { path: props.currentDocPath, content: md })
156
+ }
157
+ }
158
+ })
159
+
160
+ const isExternalUpdate = ref(false)
161
+
162
+ function preloadImage(src) {
163
+ return new Promise((resolve) => {
164
+ const img = document.createElement('img')
165
+ img.onload = () => resolve(true)
166
+ img.onerror = () => resolve(false)
167
+ img.src = src
168
+ })
169
+ }
170
+
171
+ async function handleImageUpload(file, editorInstance) {
172
+ if (!file || !file.type.startsWith('image/')) return
173
+ if (!props.currentDocPath) return
174
+ try {
175
+ const url = await uploadImage(file, props.currentDocPath)
176
+ await preloadImage(url)
177
+ editorInstance.chain().focus().setImage({ src: url }).run()
178
+ } catch (e) {
179
+ console.error('upload failed:', e)
180
+ }
181
+ }
182
+
183
+ function createImagePasteProps(getEditor) {
184
+ return {
185
+ handlePaste(view, event) {
186
+ const items = event.clipboardData?.items
187
+ if (!items) return false
188
+ for (const item of items) {
189
+ if (item.type.startsWith('image/')) {
190
+ event.preventDefault()
191
+ const file = item.getAsFile()
192
+ if (file) handleImageUpload(file, getEditor())
193
+ return true
194
+ }
195
+ }
196
+ return false
197
+ },
198
+ handleDrop(view, event) {
199
+ const files = event.dataTransfer?.files
200
+ if (!files || files.length === 0) return false
201
+ const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'))
202
+ if (imageFiles.length === 0) return false
203
+ event.preventDefault()
204
+ const editorInstance = getEditor()
205
+ const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
206
+ if (pos) {
207
+ editorInstance.commands.focus()
208
+ editorInstance.commands.setTextSelection(pos.pos)
209
+ }
210
+ imageFiles.forEach(file => handleImageUpload(file, editorInstance))
211
+ return true
212
+ },
213
+ }
214
+ }
215
+
216
+ const { yaml: initYaml, body: initBody } = splitFrontmatter(props.markdownContent)
217
+ frontmatterYaml.value = initYaml
218
+
219
+ const editor = useEditor({
220
+ extensions: [
221
+ StarterKit.configure({ codeBlock: false }),
222
+ Table.configure({ resizable: true }),
223
+ TableRow, TableCell, TableHeader,
224
+ TaskList, TaskItem.configure({ nested: true }),
225
+ Image, Underline,
226
+ MermaidBlock, CodeBlockCustom, TableControls,
227
+ MathBlock, MathInline,
228
+ Placeholder.configure({ placeholder: '开始编写文档...' }),
229
+ Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
230
+ ],
231
+ content: initBody,
232
+ editorProps: createImagePasteProps(() => editor.value),
233
+ onUpdate: ({ editor }) => {
234
+ if (isExternalUpdate.value) return
235
+ emit('update:markdownContent', getFullMarkdown())
236
+ scheduleAutoSave()
237
+ },
238
+ })
239
+
240
+ onMounted(() => {
241
+ nextTick(() => { if (fmTextarea.value) autoResizeTextarea(fmTextarea.value) })
242
+ })
243
+
244
+ watch(() => props.markdownContent, (newContent) => {
245
+ if (!editor.value) return
246
+ const { yaml, body } = splitFrontmatter(newContent)
247
+ frontmatterYaml.value = yaml
248
+ nextTick(() => { if (fmTextarea.value) autoResizeTextarea(fmTextarea.value) })
249
+ const currentMd = editor.value.storage.markdown.getMarkdown()
250
+ if (currentMd === body) return
251
+ isExternalUpdate.value = true
252
+ editor.value.commands.setContent(body)
253
+ isExternalUpdate.value = false
254
+ })
255
+
256
+ onBeforeUnmount(() => { editor.value?.destroy() })
257
+ </script>
@@ -0,0 +1,264 @@
1
+ <template>
2
+ <div class="editor-toolbar">
3
+ <div class="editor-toolbar-group">
4
+ <button
5
+ class="editor-toolbar-btn"
6
+ :class="{ active: editor.isActive('bold') }"
7
+ @click="editor.chain().focus().toggleBold().run()"
8
+ title="加粗 (Ctrl+B)"
9
+ >
10
+ <Bold :size="15" />
11
+ </button>
12
+ <button
13
+ class="editor-toolbar-btn"
14
+ :class="{ active: editor.isActive('italic') }"
15
+ @click="editor.chain().focus().toggleItalic().run()"
16
+ title="斜体 (Ctrl+I)"
17
+ >
18
+ <Italic :size="15" />
19
+ </button>
20
+ <button
21
+ class="editor-toolbar-btn"
22
+ :class="{ active: editor.isActive('underline') }"
23
+ @click="editor.chain().focus().toggleUnderline().run()"
24
+ title="下划线 (Ctrl+U)"
25
+ >
26
+ <UnderlineIcon :size="15" />
27
+ </button>
28
+ <button
29
+ class="editor-toolbar-btn"
30
+ :class="{ active: editor.isActive('strike') }"
31
+ @click="editor.chain().focus().toggleStrike().run()"
32
+ title="删除线"
33
+ >
34
+ <Strikethrough :size="15" />
35
+ </button>
36
+ <button
37
+ class="editor-toolbar-btn"
38
+ :class="{ active: editor.isActive('code') }"
39
+ @click="editor.chain().focus().toggleCode().run()"
40
+ title="行内代码"
41
+ >
42
+ <Code :size="15" />
43
+ </button>
44
+ </div>
45
+
46
+ <div class="editor-toolbar-divider"></div>
47
+
48
+ <div class="editor-toolbar-group">
49
+ <button
50
+ v-for="level in [1, 2, 3, 4]"
51
+ :key="level"
52
+ class="editor-toolbar-btn heading-btn"
53
+ :class="{ active: editor.isActive('heading', { level }) }"
54
+ @click="editor.chain().focus().toggleHeading({ level }).run()"
55
+ :title="`标题 ${level}`"
56
+ >
57
+ H{{ level }}
58
+ </button>
59
+ </div>
60
+
61
+ <div class="editor-toolbar-divider"></div>
62
+
63
+ <div class="editor-toolbar-group">
64
+ <button
65
+ class="editor-toolbar-btn"
66
+ :class="{ active: editor.isActive('bulletList') }"
67
+ @click="editor.chain().focus().toggleBulletList().run()"
68
+ title="无序列表"
69
+ >
70
+ <List :size="15" />
71
+ </button>
72
+ <button
73
+ class="editor-toolbar-btn"
74
+ :class="{ active: editor.isActive('orderedList') }"
75
+ @click="editor.chain().focus().toggleOrderedList().run()"
76
+ title="有序列表"
77
+ >
78
+ <ListOrdered :size="15" />
79
+ </button>
80
+ <button
81
+ class="editor-toolbar-btn"
82
+ :class="{ active: editor.isActive('taskList') }"
83
+ @click="editor.chain().focus().toggleTaskList().run()"
84
+ title="任务列表"
85
+ >
86
+ <ListChecks :size="15" />
87
+ </button>
88
+ </div>
89
+
90
+ <div class="editor-toolbar-divider"></div>
91
+
92
+ <div class="editor-toolbar-group">
93
+ <button
94
+ class="editor-toolbar-btn"
95
+ :class="{ active: editor.isActive('blockquote') }"
96
+ @click="editor.chain().focus().toggleBlockquote().run()"
97
+ title="引用"
98
+ >
99
+ <Quote :size="15" />
100
+ </button>
101
+ <button
102
+ class="editor-toolbar-btn"
103
+ @click="editor.chain().focus().setHorizontalRule().run()"
104
+ title="分割线"
105
+ >
106
+ <Minus :size="15" />
107
+ </button>
108
+ <button
109
+ class="editor-toolbar-btn"
110
+ @click="editor.chain().focus().toggleCodeBlock().run()"
111
+ :class="{ active: editor.isActive('codeBlock') }"
112
+ title="代码块"
113
+ >
114
+ <FileCode :size="15" />
115
+ </button>
116
+ <div class="table-picker-wrapper" ref="tablePickerRef">
117
+ <button
118
+ class="editor-toolbar-btn"
119
+ @click="toggleTablePicker"
120
+ title="插入表格"
121
+ >
122
+ <TableIcon :size="15" />
123
+ </button>
124
+ <div v-if="showTablePicker" class="table-picker-dropdown">
125
+ <div class="table-picker-label">{{ pickerRows }} x {{ pickerCols }}</div>
126
+ <div class="table-picker-grid"
127
+ @mouseleave="pickerRows = 1; pickerCols = 1"
128
+ >
129
+ <div
130
+ v-for="r in 8" :key="r"
131
+ class="table-picker-row"
132
+ >
133
+ <div
134
+ v-for="c in 8" :key="c"
135
+ class="table-picker-cell"
136
+ :class="{ active: r <= pickerRows && c <= pickerCols }"
137
+ @mouseenter="pickerRows = r; pickerCols = c"
138
+ @click="confirmInsertTable(r, c)"
139
+ ></div>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ <button
145
+ class="editor-toolbar-btn"
146
+ @click="insertImage"
147
+ title="插入图片"
148
+ >
149
+ <ImageIcon :size="15" />
150
+ </button>
151
+ <button
152
+ class="editor-toolbar-btn math-toolbar-btn"
153
+ @click="insertMathInline"
154
+ title="行内公式"
155
+ >
156
+ <span class="math-toolbar-icon">$</span>
157
+ </button>
158
+ <button
159
+ class="editor-toolbar-btn math-toolbar-btn"
160
+ @click="insertMathBlock"
161
+ title="块级公式"
162
+ >
163
+ <span class="math-toolbar-icon">$$</span>
164
+ </button>
165
+ </div>
166
+
167
+ <div class="editor-toolbar-divider"></div>
168
+
169
+ <div class="editor-toolbar-group">
170
+ <button
171
+ class="editor-toolbar-btn"
172
+ @click="editor.chain().focus().undo().run()"
173
+ :disabled="!editor.can().undo()"
174
+ title="撤销 (Ctrl+Z)"
175
+ >
176
+ <Undo :size="15" />
177
+ </button>
178
+ <button
179
+ class="editor-toolbar-btn"
180
+ @click="editor.chain().focus().redo().run()"
181
+ :disabled="!editor.can().redo()"
182
+ title="重做 (Ctrl+Shift+Z)"
183
+ >
184
+ <Redo :size="15" />
185
+ </button>
186
+ </div>
187
+
188
+ <div class="editor-toolbar-spacer"></div>
189
+ </div>
190
+ </template>
191
+
192
+ <script setup>
193
+ import { ref, onMounted, onUnmounted } from 'vue'
194
+ import {
195
+ Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
196
+ List, ListOrdered, ListChecks, Quote, Minus, FileCode,
197
+ Table as TableIcon, Image as ImageIcon,
198
+ Undo, Redo
199
+ } from 'lucide-vue-next'
200
+
201
+ const props = defineProps({
202
+ editor: { type: Object, required: true }
203
+ })
204
+
205
+ defineEmits([])
206
+
207
+ // 表格行列选择器
208
+ const showTablePicker = ref(false)
209
+ const pickerRows = ref(1)
210
+ const pickerCols = ref(1)
211
+ const tablePickerRef = ref(null)
212
+
213
+ function toggleTablePicker() {
214
+ showTablePicker.value = !showTablePicker.value
215
+ pickerRows.value = 1
216
+ pickerCols.value = 1
217
+ }
218
+
219
+ function confirmInsertTable(rows, cols) {
220
+ props.editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
221
+ showTablePicker.value = false
222
+ }
223
+
224
+ // 点击外部关闭选择器
225
+ function handleClickOutside(e) {
226
+ if (tablePickerRef.value && !tablePickerRef.value.contains(e.target)) {
227
+ showTablePicker.value = false
228
+ }
229
+ }
230
+
231
+ onMounted(() => {
232
+ document.addEventListener('mousedown', handleClickOutside)
233
+ })
234
+
235
+ onUnmounted(() => {
236
+ document.removeEventListener('mousedown', handleClickOutside)
237
+ })
238
+
239
+ function insertImage() {
240
+ const url = window.prompt('输入图片 URL:')
241
+ if (url) {
242
+ props.editor.chain().focus().setImage({ src: url }).run()
243
+ }
244
+ }
245
+
246
+ // 插入行内公式
247
+ function insertMathInline() {
248
+ const latex = window.prompt('输入行内 LaTeX 公式:', 'E=mc^2')
249
+ if (latex !== null && latex.trim()) {
250
+ props.editor.chain().focus().insertContent({
251
+ type: 'mathInline',
252
+ attrs: { latex: latex.trim() },
253
+ }).run()
254
+ }
255
+ }
256
+
257
+ // 插入块级公式
258
+ function insertMathBlock() {
259
+ props.editor.chain().focus().insertContent({
260
+ type: 'mathBlock',
261
+ content: [{ type: 'text', text: '\\int_0^\\infty e^{-x} dx' }],
262
+ }).run()
263
+ }
264
+ </script>
@@ -22,6 +22,14 @@
22
22
  <button class="zoom-btn" @click="resetZoom" title="重置">
23
23
  <RotateCcw :size="16" />
24
24
  </button>
25
+ <button class="zoom-btn" :class="{ 'copy-success': copySuccess, 'copy-fail': copyFail }" @click="handleCopyImage" :title="copySuccess ? '已复制' : '复制图片'">
26
+ <Check v-if="copySuccess" :size="16" />
27
+ <Copy v-else :size="16" />
28
+ </button>
29
+ <transition name="tip-fade">
30
+ <span v-if="copySuccess" class="copy-tip copy-tip-ok">已复制</span>
31
+ <span v-else-if="copyFail" class="copy-tip copy-tip-fail">复制失败</span>
32
+ </transition>
25
33
  <span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
26
34
  <!-- 图片计数 -->
27
35
  <span v-if="images.length > 1" class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</span>
@@ -69,8 +77,9 @@
69
77
  </template>
70
78
 
71
79
  <script setup>
72
- import { ref, computed } from 'vue'
73
- import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
80
+ import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
81
+ import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight, Copy, Check } from 'lucide-vue-next'
82
+ import { imgToPngBlob, svgToPngBlob } from '../utils/imageConverter.js'
74
83
 
75
84
  // Props
76
85
  const props = defineProps({
@@ -113,6 +122,8 @@ const translateY = ref(0)
113
122
  const isDragging = ref(false)
114
123
  const lastMouseX = ref(0)
115
124
  const lastMouseY = ref(0)
125
+ const copySuccess = ref(false)
126
+ const copyFail = ref(false)
116
127
 
117
128
  // 缩放控制
118
129
  const minScale = 0.1
@@ -161,6 +172,34 @@ function close() {
161
172
  emit('close')
162
173
  }
163
174
 
175
+ // 复制图片到剪贴板
176
+ async function handleCopyImage() {
177
+ try {
178
+ const wrapper = document.querySelector('.image-wrapper')
179
+ if (!wrapper) return
180
+ const img = wrapper.querySelector('img')
181
+ const svg = wrapper.querySelector('svg')
182
+
183
+ let blobPromise
184
+ if (img) {
185
+ blobPromise = imgToPngBlob(img.src)
186
+ } else if (svg) {
187
+ blobPromise = svgToPngBlob(svg, 2)
188
+ } else {
189
+ return
190
+ }
191
+
192
+ const item = new ClipboardItem({ 'image/png': blobPromise })
193
+ await navigator.clipboard.write([item])
194
+ copySuccess.value = true
195
+ setTimeout(() => { copySuccess.value = false }, 1500)
196
+ } catch (e) {
197
+ console.warn('复制图片失败:', e)
198
+ copyFail.value = true
199
+ setTimeout(() => { copyFail.value = false }, 1500)
200
+ }
201
+ }
202
+
164
203
  // 处理遮罩层点击
165
204
  function handleOverlayClick(event) {
166
205
  if (event.target.classList.contains('image-zoom-overlay')) {
@@ -204,7 +243,7 @@ function handleMouseUp() {
204
243
  isDragging.value = false
205
244
  }
206
245
 
207
- // 监听键盘事件
246
+ // 监听键盘事件(使用生命周期钩子管理,避免泄漏)
208
247
  function handleKeyDown(event) {
209
248
  if (!props.visible) return
210
249
  switch (event.key) {
@@ -230,9 +269,13 @@ function handleKeyDown(event) {
230
269
  }
231
270
  }
232
271
 
233
- if (typeof window !== 'undefined') {
272
+ onMounted(() => {
234
273
  window.addEventListener('keydown', handleKeyDown)
235
- }
274
+ })
275
+
276
+ onBeforeUnmount(() => {
277
+ window.removeEventListener('keydown', handleKeyDown)
278
+ })
236
279
  </script>
237
280
 
238
281
  <style scoped>
@@ -309,6 +352,38 @@ if (typeof window !== 'undefined') {
309
352
  color: #c53030;
310
353
  }
311
354
 
355
+ .copy-success {
356
+ color: #38a169 !important;
357
+ }
358
+
359
+ .copy-fail {
360
+ color: #e53e3e !important;
361
+ }
362
+
363
+ .copy-tip {
364
+ font-size: 12px;
365
+ font-weight: 500;
366
+ white-space: nowrap;
367
+ }
368
+
369
+ .copy-tip-ok {
370
+ color: #38a169;
371
+ }
372
+
373
+ .copy-tip-fail {
374
+ color: #e53e3e;
375
+ }
376
+
377
+ .tip-fade-enter-active,
378
+ .tip-fade-leave-active {
379
+ transition: opacity 0.2s;
380
+ }
381
+
382
+ .tip-fade-enter-from,
383
+ .tip-fade-leave-to {
384
+ opacity: 0;
385
+ }
386
+
312
387
  .zoom-level {
313
388
  font-size: 12px;
314
389
  font-weight: 600;
@@ -379,6 +454,14 @@ if (typeof window !== 'undefined') {
379
454
  cursor: grabbing;
380
455
  }
381
456
 
457
+ /* 确保图片在放大浮层中有合理的最小尺寸,避免复制按钮遮挡 */
458
+ .image-wrapper :deep(img) {
459
+ display: block;
460
+ min-width: 200px;
461
+ min-height: 200px;
462
+ object-fit: contain;
463
+ }
464
+
382
465
  /* 确保SVG在放大时保持清晰 */
383
466
  .image-wrapper :deep(svg) {
384
467
  display: block !important;