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
+369 -180
View File
@@ -1,14 +1,14 @@
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
import { $prose, $ctx, $markSchema } from '@milkdown/kit/utils'
import { parserCtx } from '@milkdown/kit/core'
import { Node as ProseNode, Fragment, Slice } from '@milkdown/prose/model'
import { Node as ProseNode, Fragment } from '@milkdown/prose/model'
import type { Ctx } from '@milkdown/kit/core'
import type { EditorView } from '@milkdown/prose/view'
import { getOcrCache, checkSizeLimit as checkOcrSizeLimit, OCR_SIZE_LIMIT } from '../utils/ocrCache'
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 500
let enabled = true
const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT
interface CopilotState {
from: number
@@ -21,12 +21,21 @@ interface CopilotConfig {
debounceMs?: number
}
interface CopilotRuntime {
enabled: boolean
debounceTimer: ReturnType<typeof setTimeout> | null
abortController: AbortController | null
ctx: Ctx
}
const initialState: CopilotState = {
from: 0,
to: 0,
suggestion: ''
}
const runtimeByView = new WeakMap<EditorView, CopilotRuntime>()
export const copilotConfigCtx = $ctx<CopilotConfig, 'copilotConfig'>({
fetchSuggestion: async () => '',
debounceMs: DEBOUNCE_MS
@@ -36,21 +45,68 @@ export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
excludes: '_',
inclusive: true,
parseDOM: [{ tag: 'span[data-copilot-ghost]' }],
toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0]
toDOM: () => ['span', { 'data-copilot-ghost': '', class: 'copilot-ghost-text' }, 0],
parseMarkdown: {
match: () => false,
runner: () => {}
},
toMarkdown: {
match: (mark) => mark.type.name === 'copilot_ghost',
runner: () => {}
}
}))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let abortController: AbortController | null = null
let currentCtx: Ctx | null = null
function clearRuntimeRequests(runtime: CopilotRuntime) {
if (runtime.debounceTimer) {
clearTimeout(runtime.debounceTimer)
runtime.debounceTimer = null
}
if (runtime.abortController) {
runtime.abortController.abort()
runtime.abortController = null
}
}
function findGhostRangeByMarks(view: EditorView): { from: number; to: number } | null {
const markType = view.state.schema.marks.copilot_ghost
if (!markType) return null
let from = Number.POSITIVE_INFINITY
let to = -1
view.state.doc.descendants((node, pos) => {
if (node.isText && node.marks.some((m: any) => m.type === markType)) {
from = Math.min(from, pos)
to = Math.max(to, pos + node.nodeSize)
}
return true
})
if (!Number.isFinite(from) || to <= from) return null
return { from, to }
}
function getGhostRange(view: EditorView): { from: number; to: number } | null {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state && state.from < state.to) {
return { from: state.from, to: state.to }
}
return findGhostRangeByMarks(view)
}
function hasGhostText(view: EditorView): boolean {
return getGhostRange(view) !== null
}
function clearGhostText(view: EditorView) {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state && state.suggestion && state.from < state.to) {
const tr = view.state.tr
.delete(state.from, state.to)
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
view.dispatch(tr)
}
const range = getGhostRange(view)
if (!range) return
const tr = view.state.tr
.delete(range.from, range.to)
.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
view.dispatch(tr)
}
function isBlockNode(node: ProseNode): boolean {
@@ -67,39 +123,24 @@ function hasBlockNodes(doc: ProseNode): boolean {
return hasBlock
}
function addGhostMarkToNode(node: ProseNode, ghostMarkType: any): ProseNode {
if (node.isText) {
return node.mark(node.marks.concat(ghostMarkType.create()))
}
if (node.isLeaf) {
return node
}
const newContent: ProseNode[] = []
node.forEach((child) => {
newContent.push(addGhostMarkToNode(child, ghostMarkType))
})
return node.copy(Fragment.from(newContent))
}
function extractInlineContent(doc: ProseNode, ghostMarkType: any, schema: any): Fragment {
function extractInlineContent(doc: ProseNode, schema: any): Fragment {
const nodes: ProseNode[] = []
let isFirstBlock = true
doc.forEach((blockNode) => {
if (!isFirstBlock) {
const hardBreak = schema.nodes.hard_break?.create()
if (hardBreak) {
nodes.push(hardBreak)
} else {
nodes.push(schema.text('\n', [ghostMarkType.create()]))
nodes.push(schema.text('\n'))
}
}
isFirstBlock = false
blockNode.forEach((inlineNode) => {
if (inlineNode.isText) {
const combinedMarks = inlineNode.marks.concat(ghostMarkType.create())
nodes.push(inlineNode.mark(combinedMarks))
nodes.push(inlineNode)
} else if (inlineNode.type.name === 'hard_break') {
nodes.push(inlineNode)
} else if (inlineNode.isLeaf) {
@@ -107,8 +148,7 @@ function extractInlineContent(doc: ProseNode, ghostMarkType: any, schema: any):
} else if (inlineNode.content.size > 0) {
inlineNode.forEach((nestedNode) => {
if (nestedNode.isText) {
const combinedMarks = nestedNode.marks.concat(ghostMarkType.create())
nodes.push(nestedNode.mark(combinedMarks))
nodes.push(nestedNode)
} else if (nestedNode.isLeaf) {
nodes.push(nestedNode)
}
@@ -116,52 +156,83 @@ function extractInlineContent(doc: ProseNode, ghostMarkType: any, schema: any):
}
})
})
return Fragment.from(nodes)
}
async function insertGhostText(view: EditorView, suggestion: string, from: number) {
if (!currentCtx || !suggestion) return
function normalizeSuggestionText(raw: string): string {
if (!raw) return raw
let text = raw.replace(/\r\n?/g, '\n')
const trimmed = text.trim()
// Some models may return a JSON-encoded string literal, decode it if so.
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed === 'string') {
text = parsed.replace(/\r\n?/g, '\n')
}
} catch {
// Keep original text when not valid JSON.
}
}
// If newlines are escaped literally, convert them back.
if (!text.includes('\n') && text.includes('\\n')) {
text = text.replace(/\\n/g, '\n')
}
if (text.includes('\\t')) {
text = text.replace(/\\t/g, '\t')
}
return text
}
async function insertGhostText(view: EditorView, suggestion: string, from: number, ctx: Ctx) {
if (!suggestion) return
const schema = view.state.schema
const markType = schema.marks.copilot_ghost
if (!markType) {
console.error('[Copilot] copilot_ghost mark not found in schema')
return
}
try {
const parser = currentCtx.get(parserCtx)
const parser = ctx.get(parserCtx)
const parsedDoc = await parser(suggestion)
if (!parsedDoc) {
insertPlainText(view, suggestion, from, markType)
return
}
const containsBlocks = hasBlockNodes(parsedDoc)
if (containsBlocks) {
const $from = view.state.doc.resolve(from)
const insertPos = $from.after($from.depth)
const blockNodes: ProseNode[] = []
parsedDoc.forEach((node) => {
blockNodes.push(addGhostMarkToNode(node, markType))
blockNodes.push(node)
})
const fragment = Fragment.from(blockNodes)
const tr = view.state.tr
tr.insert(insertPos, fragment)
const endPos = insertPos + fragment.size
tr.addMark(insertPos, endPos, markType.create())
tr.setMeta(COPILOT_PLUGIN_KEY, { from: insertPos, to: endPos, suggestion })
view.dispatch(tr)
} else {
const inlineFragment = extractInlineContent(parsedDoc, markType, schema)
const inlineFragment = extractInlineContent(parsedDoc, schema)
const tr = view.state.tr
tr.insert(from, inlineFragment)
const endPos = from + inlineFragment.size
tr.addMark(from, endPos, markType.create())
tr.setMeta(COPILOT_PLUGIN_KEY, { from, to: endPos, suggestion })
view.dispatch(tr)
}
@@ -180,66 +251,112 @@ function insertPlainText(view: EditorView, suggestion: string, from: number, mar
view.dispatch(tr)
}
function doFetchSuggestion(view: EditorView, pos: number, prefix: string, suffix: string) {
if (!currentCtx) return
const config = currentCtx.get(copilotConfigCtx.key)
if (abortController) {
abortController.abort()
abortController = null
function extractImageFilenames(doc: ProseNode): string[] {
const filenames: string[] = []
doc.descendants((node: ProseNode) => {
if (node.type.name === 'image' && node.attrs.src) {
filenames.push(node.attrs.src)
}
})
return filenames
}
function buildPrefixWithOCR(prefix: string, doc: ProseNode, cursorPos: number): string {
const ocrEntries: string[] = []
doc.descendants((node: ProseNode, pos) => {
if (pos >= cursorPos) return false
if (node.type.name !== 'image' || !node.attrs.src) return true
const ocrText = getOcrCache(node.attrs.src)
if (!ocrText) return true
const altText = typeof node.attrs.alt === 'string' ? node.attrs.alt : ''
ocrEntries.push(`image(${altText || 'untitled'}): ${ocrText}`)
return true
})
if (!ocrEntries.length) return prefix
return `${prefix}\n\n[OCR Context]\n${ocrEntries.join('\n')}`
}
function doFetchSuggestion(view: EditorView, runtime: CopilotRuntime, pos: number, prefix: string, suffix: string) {
const config = runtime.ctx.get(copilotConfigCtx.key)
if (runtime.abortController) {
runtime.abortController.abort()
runtime.abortController = null
}
abortController = new AbortController()
config.fetchSuggestion(prefix, suffix, abortController.signal)
.then(suggestion => {
if (view.state.selection.from !== pos) return
if (suggestion) {
insertGhostText(view, suggestion, pos)
const controller = new AbortController()
runtime.abortController = controller
config.fetchSuggestion(prefix, suffix, controller.signal)
.then((suggestion) => {
if (!runtime.enabled) return
if (view.state.selection.from !== pos || view.state.selection.to !== pos) return
const normalizedSuggestion = normalizeSuggestionText(suggestion)
if (normalizedSuggestion) {
insertGhostText(view, normalizedSuggestion, pos, runtime.ctx)
}
})
.catch(e => {
if (e.name !== 'AbortError') {
.catch((e: any) => {
if (e?.name !== 'AbortError') {
console.error('[Copilot] Error:', e)
}
})
.finally(() => {
abortController = null
if (runtime.abortController === controller) {
runtime.abortController = null
}
})
}
function scheduleFetch(view: EditorView, pos: number, prefix: string, suffix: string) {
if (!enabled) return
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number, prefix: string, suffix: string) {
if (!runtime.enabled) return
const doc = view.state.doc
const imageFilenames = extractImageFilenames(doc)
const { overLimit } = checkOcrSizeLimit(doc.content.size, imageFilenames)
if (overLimit) {
setCopilotEnabled(view, false)
return
}
debounceTimer = setTimeout(() => {
debounceTimer = null
doFetchSuggestion(view, pos, prefix, suffix)
}, DEBOUNCE_MS)
const prefixWithOCR = buildPrefixWithOCR(prefix, doc, pos)
if (runtime.debounceTimer) {
clearTimeout(runtime.debounceTimer)
runtime.debounceTimer = null
}
const debounceMs = runtime.ctx.get(copilotConfigCtx.key).debounceMs ?? DEBOUNCE_MS
runtime.debounceTimer = setTimeout(() => {
runtime.debounceTimer = null
doFetchSuggestion(view, runtime, pos, prefixWithOCR, suffix)
}, debounceMs)
}
function acceptSuggestion(view: EditorView) {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (!state?.suggestion || state.from >= state.to) return false
const range = getGhostRange(view)
if (!range) return false
const tr = view.state.tr
const doc = tr.doc
const from = state.from
const to = state.to
const from = range.from
const to = range.to
const markType = view.state.schema.marks.copilot_ghost
if (!markType) return false
doc.nodesBetween(from, to, (node, pos) => {
if (node.marks.some((m: any) => m.type.name === 'copilot_ghost')) {
tr.removeMark(pos, pos + node.nodeSize, view.state.schema.marks.copilot_ghost)
if (node.marks.some((m: any) => m.type === markType)) {
tr.removeMark(pos, pos + node.nodeSize, markType)
}
})
const endPos = Math.min(state.to, tr.doc.content.size)
const endPos = Math.min(to, tr.doc.content.size)
tr.setSelection(Selection.near(tr.doc.resolve(endPos)))
tr.setMeta(COPILOT_PLUGIN_KEY, { ...initialState })
view.dispatch(tr)
@@ -247,108 +364,180 @@ function acceptSuggestion(view: EditorView) {
}
function rejectSuggestion(view: EditorView) {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (!state?.suggestion) return false
if (!hasGhostText(view)) return false
clearGhostText(view)
return true
}
export const copilotPlugin = $prose((ctx) => {
currentCtx = ctx
return new Plugin<CopilotState>({
key: COPILOT_PLUGIN_KEY,
state: {
init: () => ({ ...initialState }),
apply: (tr, value) => {
const meta = tr.getMeta(COPILOT_PLUGIN_KEY)
if (meta !== undefined) {
return meta
}
if (tr.docChanged && value.suggestion) {
return { ...initialState }
}
return value
export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
key: COPILOT_PLUGIN_KEY,
state: {
init: () => ({ ...initialState }),
apply: (tr, value) => {
const meta = tr.getMeta(COPILOT_PLUGIN_KEY)
if (meta !== undefined) {
return meta
}
},
props: {
handleKeyDown: (view, event) => {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (event.key === 'Tab' && state?.suggestion) {
event.preventDefault()
return acceptSuggestion(view)
}
if (event.key === 'Escape' && state?.suggestion) {
event.preventDefault()
return rejectSuggestion(view)
}
if (state?.suggestion && event.key !== 'Shift' && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta') {
clearGhostText(view)
}
return false
},
handleClick: (view, pos) => {
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (!state?.suggestion) return false
if (pos >= state.from && pos < state.to) {
return acceptSuggestion(view)
}
if (tr.docChanged && value.suggestion) {
return { ...initialState }
}
return value
}
},
props: {
handleKeyDown: (view, event) => {
const hasGhost = hasGhostText(view)
if (event.key === 'Tab' && hasGhost) {
event.preventDefault()
return acceptSuggestion(view)
}
if (event.key === 'Escape' && hasGhost) {
event.preventDefault()
return rejectSuggestion(view)
}
if (hasGhost && event.key !== 'Shift' && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Meta') {
clearGhostText(view)
return false
}
return false
},
view: () => ({
update: (view, prevState) => {
if (view.state.doc.eq(prevState.doc) && view.state.selection.eq(prevState.selection)) {
return
}
const state = COPILOT_PLUGIN_KEY.getState(view.state)
if (state?.suggestion) {
return
}
if (!view.state.doc.eq(prevState.doc)) {
const { from, to } = view.state.selection
if (from !== to) return
const doc = view.state.doc
const prefix = doc.textBetween(0, from)
const suffix = doc.textBetween(to, doc.content.size)
scheduleFetch(view, from, prefix, suffix)
}
handleClick: (view, pos) => {
const range = getGhostRange(view)
if (!range) return false
if (pos >= range.from && pos <= range.to) {
return acceptSuggestion(view)
}
})
})
})
clearGhostText(view)
return false
}
},
view: (view) => {
let activeView = view
let activeDom = view.dom
const runtime: CopilotRuntime = {
enabled: true,
debounceTimer: null,
abortController: null,
ctx
}
runtimeByView.set(view, runtime)
const onKeydownCapture = (event: KeyboardEvent) => {
if (!hasGhostText(activeView)) return
if (event.key === 'Tab') {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
acceptSuggestion(activeView)
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
rejectSuggestion(activeView)
}
}
const onPointerDownCapture = (event: MouseEvent) => {
if (!hasGhostText(activeView)) return
const targetNode = event.target instanceof Node ? event.target : null
const target = targetNode instanceof Element ? targetNode : targetNode?.parentElement
if (!target) return
// Accept suggestion when user clicks any rendered ghost-text fragment.
if (target.closest('[data-copilot-ghost]')) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
acceptSuggestion(activeView)
}
}
const bindDomListeners = (dom: HTMLElement) => {
dom.addEventListener('keydown', onKeydownCapture, true)
dom.addEventListener('mousedown', onPointerDownCapture, true)
}
const unbindDomListeners = (dom: HTMLElement) => {
dom.removeEventListener('keydown', onKeydownCapture, true)
dom.removeEventListener('mousedown', onPointerDownCapture, true)
}
bindDomListeners(activeDom)
return {
update: (nextView, prevState) => {
if (nextView.dom !== activeDom) {
unbindDomListeners(activeDom)
activeDom = nextView.dom
bindDomListeners(activeDom)
}
activeView = nextView
const docChanged = !nextView.state.doc.eq(prevState.doc)
const selectionChanged = !nextView.state.selection.eq(prevState.selection)
if (!docChanged && !selectionChanged) {
return
}
if (hasGhostText(nextView)) {
return
}
const { from, to } = nextView.state.selection
if (from !== to) {
clearRuntimeRequests(runtime)
return
}
const doc = nextView.state.doc
const prefix = doc.textBetween(0, from)
const suffix = doc.textBetween(to, doc.content.size)
scheduleFetch(nextView, runtime, from, prefix, suffix)
},
destroy: () => {
unbindDomListeners(activeDom)
clearRuntimeRequests(runtime)
runtimeByView.delete(view)
}
}
}
}))
export { COPILOT_PLUGIN_KEY }
export function isCopilotEnabled(): boolean {
return enabled
export function isCopilotEnabled(view: EditorView): boolean {
return runtimeByView.get(view)?.enabled ?? true
}
export function setCopilotEnabled(value: boolean): void {
enabled = value
export function setCopilotEnabled(view: EditorView, value: boolean): void {
const runtime = runtimeByView.get(view)
if (!runtime) return
runtime.enabled = value
if (!value) {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (abortController) {
abortController.abort()
abortController = null
}
clearRuntimeRequests(runtime)
}
}
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
const doc = view.state.doc
const imageFilenames = extractImageFilenames(doc)
const result = checkOcrSizeLimit(doc.content.size, imageFilenames)
return { size: result.size, overLimit: result.overLimit }
}
export { SIZE_LIMIT }