feat(editor): add image insertion with OCR support and size limit handling

Add image button with dropdown menu for uploading local images or inserting from URL.
Integrate VLM-based OCR to extract text context from images and include in AI suggestions.
Implement document size limits to disable AI when exceeding threshold.
Refactor copilot plugin with per-view runtime state and OCR context injection.
Add OCR cache utility for managing image metadata.
Add code splitting configuration for optimized bundle size.
This commit is contained in:
“ydy0615”
2026-02-14 18:28:37 +08:00
parent c64ff7be45
commit 64cfa58376
16 changed files with 1593 additions and 458 deletions

View File

@@ -1,9 +1,15 @@
<template>
<div class="editor-container" ref="containerRef">
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
<div class="action-buttons">
<button class="action-btn" @click="triggerUpload">
<button
type="button"
class="action-btn"
aria-label="导入 Markdown 文件"
title="导入 Markdown"
@click="triggerUpload"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
@@ -13,7 +19,13 @@
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
<button class="action-btn" @click="exportMarkdown">
<button
type="button"
class="action-btn"
aria-label="导出 Markdown 文件"
title="导出 Markdown"
@click="exportMarkdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
@@ -22,34 +34,217 @@
<span class="btn-tooltip">导出 Markdown</span>
</button>
<button
class="action-btn ai-toggle"
:class="{ 'ai-disabled': !aiEnabled }"
<div class="image-btn-wrapper">
<button
type="button"
class="action-btn"
aria-label="Insert Image"
title="Insert Image"
@click="toggleImageDropdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span class="btn-tooltip">Insert Image</span>
</button>
<div v-if="showImageDropdown" class="image-dropdown">
<button type="button" @click="triggerImageUpload">Upload Local Image</button>
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">Insert from URL</button>
</div>
</div>
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
<button
type="button"
class="action-btn ai-toggle"
:class="{ 'ai-disabled': !aiEnabled, 'force-disabled': isOverLimit }"
@click="toggleAI"
:disabled="isOverLimit"
:aria-label="aiButtonLabel"
:title="aiButtonLabel"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/>
<path d="M12 6v6l4 2"/>
</svg>
<span class="btn-tooltip">{{ aiEnabled ? '禁用 AI' : '启用 AI' }}</span>
<span class="btn-tooltip">{{ aiButtonLabel }}</span>
</button>
<div class="size-indicator" :class="{ 'over-limit': isOverLimit }" aria-live="polite">
{{ sizeInKB }} KB
</div>
</div>
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
<div class="url-dialog">
<h3>Insert Image from URL</h3>
<input
v-model="imageUrl"
type="url"
placeholder="Enter image URL"
@keyup.enter="insertImageFromUrl"
/>
<div class="url-dialog-buttons">
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">Insert</button>
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, isCopilotEnabled, setCopilotEnabled, COPILOT_PLUGIN_KEY } from '../plugins/copilotPlugin'
import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { DEBUG } from '../utils/config.js'
import { DEBUG, API_URL } from '../utils/config.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache } from '../utils/ocrCache.js'
const emit = defineEmits(['update:markdown'])
const root = ref(null)
const containerRef = ref(null)
const fileInputRef = ref(null)
const imageInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showImageDropdown = ref(false)
const showUrlDialog = ref(false)
const imageUrl = ref('')
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const aiButtonLabel = computed(() => {
if (isOverLimit.value) return '文档过大AI已禁用'
return aiEnabled.value ? '禁用 AI' : '启用 AI'
})
let crepe = null
let markdownSyncTimer = null
const objectUrls = new Set()
const revokeObjectUrl = (url) => {
if (!objectUrls.has(url)) return
URL.revokeObjectURL(url)
objectUrls.delete(url)
clearOcrCache(url)
}
const collectImageObjectUrls = (doc) => {
const activeUrls = new Set()
doc.descendants((node) => {
if (
node.type?.name === 'image' &&
typeof node.attrs?.src === 'string' &&
node.attrs.src.startsWith('blob:')
) {
activeUrls.add(node.attrs.src)
}
})
return activeUrls
}
const syncObjectUrls = (doc) => {
const activeUrls = collectImageObjectUrls(doc)
for (const url of Array.from(objectUrls)) {
if (!activeUrls.has(url)) {
revokeObjectUrl(url)
}
}
}
const refreshSizeAndLimit = (ctx) => {
const view = ctx.get(editorViewCtx)
const { size, overLimit } = checkSizeLimit(view)
contentSize.value = size
if (overLimit && aiEnabled.value) {
aiEnabled.value = false
setCopilotEnabled(view, false)
}
}
const scheduleMarkdownSync = () => {
if (!crepe) return
if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer)
markdownSyncTimer = null
}
markdownSyncTimer = setTimeout(async () => {
markdownSyncTimer = null
if (!crepe) return
try {
let hasGhostSuggestion = false
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const state = COPILOT_PLUGIN_KEY.getState(view.state)
hasGhostSuggestion = Boolean(state?.suggestion && state.from < state.to)
})
// Ghost text is transient UI state and should not leak to emitted markdown.
if (hasGhostSuggestion) return
const markdown = await crepe.getMarkdown()
emit('update:markdown', markdown)
} catch (e) {
if (DEBUG) console.error('[Markdown] Sync failed:', e)
}
}, 120)
}
const clearCurrentSuggestion = (view) => {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state?.suggestion && state.from < state.to) {
const tr = view.state.tr
.delete(state.from, state.to)
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
view.dispatch(tr)
}
}
const performOCR = async (file, cacheKey) => {
if (!aiEnabled.value) return
const reader = new FileReader()
reader.onload = async () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
const splitIndex = dataUrl.indexOf(',')
if (splitIndex === -1) return
const base64 = dataUrl.slice(splitIndex + 1)
try {
const ocrUrl = API_URL.replace('/v1/completions', '/v1/ocr')
const res = await fetch(ocrUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: base64,
filename: file.name,
language: 'auto'
})
})
const data = await res.json()
if (data.text) {
setOcrCache(cacheKey, data.text)
setOcrCache(file.name, data.text)
if (crepe?.editor) {
crepe.editor.action((ctx) => {
refreshSizeAndLimit(ctx)
})
}
}
} catch (e) {
if (DEBUG) console.error('[OCR] Error:', e)
}
}
reader.readAsDataURL(file)
}
onMounted(async () => {
if (DEBUG) console.log('[Debug] onMounted called')
@@ -61,11 +256,20 @@ onMounted(async () => {
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
},
featureConfigs: {
[Crepe.Feature.Latex]: {
katexOptions: {},
inlineEditConfirm: 'Escape'
},
[Crepe.Feature.ImageBlock]: {
onUpload: (file) => {
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
performOCR(file, objectUrl)
return objectUrl
}
}
},
config: {
@@ -76,7 +280,7 @@ onMounted(async () => {
crepe.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 500
debounceMs: 1000
})
})
@@ -86,24 +290,30 @@ onMounted(async () => {
await crepe.create()
crepe.on((listener) => {
listener.updated((ctx, doc) => {
syncObjectUrls(doc)
refreshSizeAndLimit(ctx)
scheduleMarkdownSync()
})
})
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value)
refreshSizeAndLimit(ctx)
})
scheduleMarkdownSync()
if (DEBUG) console.log('[Debug] Crepe editor created with copilot plugin')
})
const exportMarkdown = async () => {
if (!crepe) return
const { editorViewCtx } = await import('@milkdown/kit/core')
const { COPILOT_PLUGIN_KEY } = await import('../plugins/copilotPlugin')
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state?.suggestion && state.from < state.to) {
const tr = view.state.tr
.delete(state.from, state.to)
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
view.dispatch(tr)
}
clearCurrentSuggestion(view)
})
const markdown = await crepe.getMarkdown()
@@ -112,7 +322,9 @@ const exportMarkdown = async () => {
const a = document.createElement('a')
a.href = url
a.download = `document-${Date.now()}.md`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
@@ -137,28 +349,80 @@ const handleFileUpload = async (event) => {
}
const toggleAI = async () => {
aiEnabled.value = !aiEnabled.value
setCopilotEnabled(aiEnabled.value)
if (isOverLimit.value || !crepe) return
if (!aiEnabled.value && crepe) {
const { editorViewCtx } = await import('@milkdown/kit/core')
aiEnabled.value = !aiEnabled.value
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, aiEnabled.value)
if (!aiEnabled.value) {
clearCurrentSuggestion(view)
}
})
}
const toggleImageDropdown = () => {
showImageDropdown.value = !showImageDropdown.value
}
const triggerImageUpload = () => {
showImageDropdown.value = false
imageInputRef.value?.click()
}
const insertImageAtCursor = (src) => {
if (!crepe || !src) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { state } = view
const { schema } = state
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state?.suggestion && state.from < state.to) {
const tr = view.state.tr
.delete(state.from, state.to)
.setMeta(COPILOT_PLUGIN_KEY, { from: 0, to: 0, suggestion: '' })
view.dispatch(tr)
}
})
}
const imageType = schema.nodes.image
if (!imageType) return
const imageNode = imageType.create({ src })
const tr = state.tr.replaceSelectionWith(imageNode)
view.dispatch(tr)
})
}
const handleImageUpload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
performOCR(file, objectUrl)
insertImageAtCursor(objectUrl)
event.target.value = ''
}
const insertImageFromUrl = () => {
const url = imageUrl.value.trim()
if (!url) return
insertImageAtCursor(url)
imageUrl.value = ''
showUrlDialog.value = false
}
onUnmounted(() => {
if (markdownSyncTimer) {
clearTimeout(markdownSyncTimer)
markdownSyncTimer = null
}
for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url)
}
clearAllOcrCache()
if (crepe) {
crepe.destroy()
crepe = null
}
})
</script>
@@ -176,6 +440,7 @@ onUnmounted(() => {
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
}
@@ -193,12 +458,14 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
opacity: 0.5;
}
.action-btn:hover {
background-color: #4a90d9;
color: white;
border-color: #4a90d9;
opacity: 1;
}
.action-btn.ai-disabled {
@@ -213,15 +480,42 @@ onUnmounted(() => {
border-color: #4a90d9;
}
.action-btn.force-disabled {
background-color: #ccc;
color: #999;
border-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.action-btn.force-disabled:hover {
background-color: #ccc;
color: #999;
border-color: #ccc;
opacity: 0.6;
}
.size-indicator {
font-size: 10px;
color: #999;
text-align: center;
margin-top: 4px;
}
.size-indicator.over-limit {
color: #e74c3c;
}
.action-btn {
position: relative;
}
.btn-tooltip {
position: absolute;
top: -32px;
left: 50%;
transform: translateX(-50%);
top: 50%;
right: 100%;
transform: translateY(-50%);
margin-right: 8px;
background: #333;
color: #fff;
font-size: 12px;
@@ -237,6 +531,116 @@ onUnmounted(() => {
opacity: 1;
}
.action-btn:focus-visible .btn-tooltip {
opacity: 1;
}
.image-btn-wrapper {
position: relative;
}
.image-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
overflow: hidden;
z-index: 10000;
min-width: 160px;
}
.image-dropdown button {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
color: #333;
}
.image-dropdown button:hover {
background: #f5f5f5;
}
.url-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
}
.url-dialog {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
min-width: 320px;
}
.url-dialog h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.url-dialog input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
}
.url-dialog input:focus {
outline: none;
border-color: #4a90d9;
}
.url-dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
background: #fff;
color: #333;
}
.dialog-btn:hover {
background: #f5f5f5;
}
.dialog-btn.primary {
background: #4a90d9;
color: #fff;
border-color: #4a90d9;
}
.dialog-btn.primary:hover {
background: #3a80c9;
}
.milkdown-editor {
width: 100%;
height: 100%;
@@ -247,7 +651,7 @@ onUnmounted(() => {
.milkdown-editor :deep(.milkdown) {
max-width: none;
margin: 0 !important;
padding: 20px 40px !important;
padding: 0 40px !important;
min-height: 100%;
}
@@ -262,6 +666,25 @@ onUnmounted(() => {
padding: 0 !important;
}
.milkdown-editor :deep(.milkdown > *:first-child) {
margin-top: 0 !important;
padding-top: 0 !important;
}
.milkdown-editor :deep(.ProseMirror) {
margin: 0 !important;
padding: 0 !important;
}
.milkdown-editor :deep(.ProseMirror img) {
max-width: 60%;
height: auto;
}
.milkdown-editor :deep(.ProseMirror > *:first-child) {
margin-top: 0 !important;
}
.milkdown-editor :deep(.milkdown__aside),
.milkdown-editor :deep(.milkdown__aside-wrapper),
.milkdown-editor :deep([class*="aside"]),
@@ -314,7 +737,7 @@ onUnmounted(() => {
.copilot-ghost-text {
color: #999;
opacity: 0.6;
pointer-events: none;
pointer-events: auto;
}
.copilot-ghost-text.copilot-loading {