fix(editor): handle paired inline code input and complete markdown rendering
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin } from '@milkdown/kit/prose/state'
|
||||
import type { EditorView } from '@milkdown/kit/prose/view'
|
||||
|
||||
function reconcile(view: EditorView) {
|
||||
if (view.isDestroyed || view.composing || !view.state.selection.empty) return
|
||||
const { $from } = view.state.selection
|
||||
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
|
||||
// Also inspect the closing delimiter AFTER the caret: users commonly type
|
||||
// a pair of backticks first, move left, and then fill in the code.
|
||||
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
|
||||
let candidate: { start: number; end: number } | undefined
|
||||
for (const match of text.matchAll(spans)) {
|
||||
const start = match.index! + match[1]!.length
|
||||
const end = match.index! + match[0].length
|
||||
if (match[2]!.trim() && $from.parentOffset > start && $from.parentOffset <= end) {
|
||||
candidate = { start: $from.start() + start, end: $from.start() + end }
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!candidate) return
|
||||
const mark = view.state.schema.marks.inlineCode
|
||||
if (!mark) return
|
||||
const { start, end } = candidate
|
||||
if (view.state.doc.rangeHasMark(start, end, mark)) return
|
||||
const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1)
|
||||
tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create())
|
||||
// Filling an existing pair must keep subsequent letters inside code. Typing
|
||||
// the closing delimiter explicitly should instead leave code as usual.
|
||||
if ($from.pos < end) tr.setStoredMarks([mark.create()])
|
||||
else tr.removeStoredMark(mark)
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
// DOM input can bypass handleTextInput (IME, replacement text, missing event.data).
|
||||
// Observe it in capture phase, then wait for ProseMirror's DOM observer and its
|
||||
// composition cleanup before inspecting the document. Never reconcile on load,
|
||||
// paste, undo, or a selection change alone.
|
||||
export const inlineCodeInputPlugin = $prose(() => new Plugin({
|
||||
view(view) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let pending = false
|
||||
let composing = false
|
||||
let attempts = 0
|
||||
const cancel = () => { clearTimeout(timer); pending = false }
|
||||
const schedule = () => {
|
||||
clearTimeout(timer)
|
||||
if (!pending || composing) return
|
||||
timer = setTimeout(() => {
|
||||
if (view.isDestroyed) return cancel()
|
||||
if (view.composing) {
|
||||
if (++attempts < 10) schedule()
|
||||
else cancel()
|
||||
return
|
||||
}
|
||||
pending = false
|
||||
reconcile(view)
|
||||
}, 30)
|
||||
}
|
||||
const input = (event: Event) => {
|
||||
const type = (event as InputEvent).inputType
|
||||
if (type && (!type.startsWith('insert') || /paste|drop/i.test(type))) return cancel()
|
||||
pending = true
|
||||
attempts = 0
|
||||
schedule()
|
||||
}
|
||||
const start = () => { composing = true; cancel() }
|
||||
const end = () => { composing = false; pending = true; attempts = 0; schedule() }
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
// ProseMirror handles undo/paste itself, so those actions need not emit input.
|
||||
if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel()
|
||||
else if (pending && !composing && !view.composing && event.key === 'Enter') {
|
||||
cancel()
|
||||
reconcile(view)
|
||||
}
|
||||
}
|
||||
view.dom.addEventListener('input', input, true)
|
||||
view.dom.addEventListener('keydown', keydown, true)
|
||||
view.dom.addEventListener('paste', cancel, true)
|
||||
view.dom.addEventListener('drop', cancel, true)
|
||||
view.dom.addEventListener('compositionstart', start, true)
|
||||
view.dom.addEventListener('compositionend', end, true)
|
||||
return {
|
||||
update(current, previous) {
|
||||
if (pending && !current.state.doc.eq(previous.doc)) schedule()
|
||||
},
|
||||
destroy() {
|
||||
cancel()
|
||||
view.dom.removeEventListener('input', input, true)
|
||||
view.dom.removeEventListener('keydown', keydown, true)
|
||||
view.dom.removeEventListener('paste', cancel, true)
|
||||
view.dom.removeEventListener('drop', cancel, true)
|
||||
view.dom.removeEventListener('compositionstart', start, true)
|
||||
view.dom.removeEventListener('compositionend', end, true)
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user