Files
NotesAgentic/frontend/src/features/editor/inlineCodeInput.ts
T

100 lines
4.2 KiB
TypeScript

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)
},
}
},
}))