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.
- package/README.md +51 -58
- package/bin/build.js +95 -9
- package/bin/md2ui.js +102 -13
- package/package.json +24 -10
- package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
- package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
- 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
- package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
- 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
- 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
- package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/src/App.vue +111 -12
- package/src/components/AppSidebar.vue +181 -21
- package/src/components/CodeBlockNodeView.vue +72 -0
- package/src/components/DocContent.vue +25 -14
- package/src/components/EditorContent.vue +257 -0
- package/src/components/EditorToolbar.vue +264 -0
- package/src/components/ImageZoom.vue +88 -5
- package/src/components/MathBlockNodeView.vue +160 -0
- package/src/components/MathInlineNodeView.vue +145 -0
- package/src/components/MermaidNodeView.vue +157 -0
- package/src/components/MobileSearch.vue +97 -0
- package/src/components/TableOfContents.vue +174 -32
- package/src/components/TopBar.vue +69 -4
- package/src/components/TreeNode.vue +232 -39
- package/src/components/WelcomePage.vue +2 -2
- package/src/composables/useDocHash.js +9 -1
- package/src/composables/useDocManager.js +452 -105
- package/src/composables/useDocTree.js +33 -2
- package/src/composables/useExportWord.js +73 -10
- package/src/composables/useFileWatcher.js +45 -0
- package/src/composables/useFrontmatter.js +2 -2
- package/src/composables/useMarkdown.js +450 -52
- package/src/composables/useMermaidCache.js +15 -0
- package/src/composables/useScroll.js +354 -27
- package/src/composables/useSearch.js +12 -11
- package/src/config.js +1 -4
- package/src/extensions/CodeBlockCustom.js +113 -0
- package/src/extensions/MathBlock.js +107 -0
- package/src/extensions/MathInline.js +100 -0
- package/src/extensions/MermaidBlock.js +73 -0
- package/src/extensions/TableControls.js +670 -0
- package/src/services/DocService.js +168 -0
- package/src/style.css +2416 -36
- package/src/utils/imageConverter.js +129 -0
- package/vite-plugin-doc-api.js +369 -0
- package/vite.config.js +7 -2
- package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
- 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
- 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
- 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
- package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
- package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
- package/src/api/docs.js +0 -106
- 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
|
-
|
|
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;
|