md2ui 1.0.18 → 1.0.19

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 (74) hide show
  1. package/README.md +3 -55
  2. package/bin/build.js +82 -7
  3. package/bin/md2ui.js +80 -4
  4. package/package.json +23 -9
  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 +86 -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/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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. package/src/App.vue +130 -6
  35. package/src/components/AppSidebar.vue +181 -21
  36. package/src/components/CodeBlockNodeView.vue +72 -0
  37. package/src/components/DocContent.vue +25 -14
  38. package/src/components/EditorContent.vue +257 -0
  39. package/src/components/EditorToolbar.vue +264 -0
  40. package/src/components/ImageZoom.vue +199 -2
  41. package/src/components/MathBlockNodeView.vue +160 -0
  42. package/src/components/MathInlineNodeView.vue +145 -0
  43. package/src/components/MermaidNodeView.vue +149 -0
  44. package/src/components/TableBubbleMenu.vue +177 -0
  45. package/src/components/TableOfContents.vue +138 -32
  46. package/src/components/TopBar.vue +69 -4
  47. package/src/components/TreeNode.vue +232 -39
  48. package/src/components/WelcomePage.vue +2 -2
  49. package/src/composables/useDocHash.js +9 -1
  50. package/src/composables/useDocManager.js +325 -68
  51. package/src/composables/useDocTree.js +56 -1
  52. package/src/composables/useExportPdf.js +102 -0
  53. package/src/composables/useExportWord.js +73 -10
  54. package/src/composables/useFileWatcher.js +45 -0
  55. package/src/composables/useFrontmatter.js +2 -2
  56. package/src/composables/useMarkdown.js +529 -42
  57. package/src/composables/useScroll.js +47 -5
  58. package/src/config.js +1 -1
  59. package/src/extensions/CodeBlockCustom.js +113 -0
  60. package/src/extensions/MathBlock.js +107 -0
  61. package/src/extensions/MathInline.js +100 -0
  62. package/src/extensions/MermaidBlock.js +73 -0
  63. package/src/extensions/TableControls.js +670 -0
  64. package/src/services/DocService.js +184 -0
  65. package/src/style.css +2194 -39
  66. package/vite-plugin-doc-api.js +368 -0
  67. package/vite.config.js +2 -1
  68. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  69. 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
  70. 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
  71. 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
  72. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  73. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  74. package/src/api/docs.js +0 -106
@@ -2,13 +2,12 @@
2
2
  <main class="content" @scroll="$emit('scroll', $event)" @click="$emit('content-click', $event)">
3
3
  <WelcomePage v-if="showWelcome" @start="$emit('start')" />
4
4
  <template v-else>
5
- <div class="doc-toolbar">
6
- <button class="export-word-btn" :disabled="exporting" @click="handleExport" title="导出为 Word 文档">
7
- <FileDown :size="15" />
8
- <span>{{ exporting ? '导出中...' : '导出 Word' }}</span>
9
- </button>
10
- </div>
11
5
  <article class="markdown-content" v-html="htmlContent"></article>
6
+ <!-- 最后更新时间 -->
7
+ <div v-if="lastModifiedText" class="doc-last-modified">
8
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
9
+ <span>最后更新于 {{ lastModifiedText }}</span>
10
+ </div>
12
11
  <nav v-if="prevDoc || nextDoc" class="doc-nav">
13
12
  <a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
14
13
  <ChevronLeft :size="16" />
@@ -31,23 +30,35 @@
31
30
  </template>
32
31
 
33
32
  <script setup>
34
- import { ChevronLeft, ChevronRight, FileDown } from 'lucide-vue-next'
33
+ import { computed } from 'vue'
34
+ import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
35
35
  import WelcomePage from './WelcomePage.vue'
36
- import { useExportWord } from '../composables/useExportWord.js'
37
36
 
38
37
  const props = defineProps({
39
38
  showWelcome: { type: Boolean, default: true },
40
39
  htmlContent: { type: String, default: '' },
41
40
  prevDoc: { type: Object, default: null },
42
41
  nextDoc: { type: Object, default: null },
43
- docTitle: { type: String, default: '文档' }
42
+ docTitle: { type: String, default: '文档' },
43
+ lastModified: { type: String, default: '' },
44
44
  })
45
45
 
46
46
  defineEmits(['scroll', 'content-click', 'start', 'load-doc'])
47
47
 
48
- const { exporting, exportToWord } = useExportWord()
49
-
50
- function handleExport() {
51
- exportToWord(props.docTitle)
52
- }
48
+ // 格式化最后修改时间
49
+ const lastModifiedText = computed(() => {
50
+ if (!props.lastModified) return ''
51
+ try {
52
+ const date = new Date(props.lastModified)
53
+ if (isNaN(date.getTime())) return ''
54
+ const y = date.getFullYear()
55
+ const m = String(date.getMonth() + 1).padStart(2, '0')
56
+ const d = String(date.getDate()).padStart(2, '0')
57
+ const h = String(date.getHours()).padStart(2, '0')
58
+ const min = String(date.getMinutes()).padStart(2, '0')
59
+ return `${y}-${m}-${d} ${h}:${min}`
60
+ } catch {
61
+ return ''
62
+ }
63
+ })
53
64
  </script>
@@ -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>