Keys, input rules, paste and files
The things a user sets off without pressing a button — typing a key, typing a pattern, pasting, dropping a file.
keys — claiming a single key
interface KeyBinding {
readonly key: string // the KeyboardEvent.key value
readonly shift?: boolean // default false — only when Shift is not held
run(context: CommandContext, event: KeyboardEvent): KeyRunResult
}
type KeyRunResult = false | 'handled' | 'changed'For keys whose meaning depends on where you are, like Tab. Wings are asked in registration order and the first to consume it takes it — the key's own behaviour and core's default handling do not happen.
// table — Tab is "next cell". At the end of the table it is handed back to core
keys: [
{
key: 'Tab',
run: (context) => {
const position = tableAt(context.cell)
return position && moveToCell(context.root, position, true) ? 'handled' : false
},
},
{
key: 'Tab',
shift: true,
run: (context) => {
const position = tableAt(context.cell)
return position && moveToCell(context.root, position, false) ? 'handled' : false
},
},
],// list — Tab indents. The document changes, so 'changed'
keys: [
{ key: 'Tab', run: (context) => (indentListItems(context, false) ? 'changed' : false) },
{ key: 'Tab', shift: true, run: (context) => (indentListItems(context, true) ? 'changed' : false) },
],Why there are three return values
true is not accepted: it cannot distinguish "changed" from "merely consumed".
| Value | Meaning | What core does |
|---|---|---|
false | not handled | passes to the next wing, then to core's default |
'handled' | the key was consumed but the document is unchanged (caret moves) | nothing — no undo point is created |
'changed' | the document changed | normalisation · undo snapshot · onChange |
Using 'changed' where 'handled' belongs grows the undo history every time the caret moves. The fold box's Enter gets this exactly right — 'changed' if it opened the box or created a paragraph, 'handled' if it only moved the caret.
Worth knowing
Modifier combinations (Ctrl/Cmd/Alt) never arrive here. That is the shortcut layer, which has not been designed yet.
Omitting
shiftmatches only when Shift is not held.Keys pressed during IME composition never enter the pipeline. The commit
Enterand the candidate-navigation arrows belong to the IME; a wing neither receives nor blocks them.Key order is ① double Shift ② floating boxes ③ undo ④ a mark's
escapeKeys⑤ wingkeys⑥ core'sTabfallback.A
Tabnobody claims prevents focus movement and inserts a tab character.Hand
Enterback to core at the start of a block. Core does not split there, it pushes — an empty paragraph appears above and the block goes down intact. Splitting would move a heading's text into a<p>and leave an empty<h1>.ts// code — hand it back at the start. Empty blocks are the exception if (caretAtBlockStart(context.root, block) && !isEmptyBlock(block)) return falseIn an empty block the caret is always at the start, so handing it back makes
Enterstack paragraphs above forever.
inputRules — transforms triggered by typing alone
interface InputRule {
readonly trigger: 'space' | 'enter'
readonly pattern: RegExp
isAvailable?(context: CommandContext): boolean
run(context: CommandContext, match: RegExpMatchArray): boolean
}Markdown-like transforms where typing alone changes the block. ---+Enter is a divider, #+space a heading, - a bullet list, 1. a numbered list, [ ] /[x] a checklist.
// divider — Enter on a line containing only ---
inputRules: [
{ trigger: 'enter', pattern: /^-{3,}$/, run: (context) => insertEmptyBlock(context, 'hr') },
],// heading — the count has to match exactly (## does not trigger the h1 rule)
inputRules: [
{
trigger: 'space',
pattern: new RegExp(`^#{${level}}$`),
isAvailable: isPlainBlock,
run: (context) => setBlockType(context, tag),
},
],Core does four things.
- At the
triggermoment it matches the block's plain text before the caret againstpattern. - On a match it blocks the key's own behaviour, deletes the matched characters and calls
run. - It remembers the state just before the transform.
- If undo cancels the transform, that rule is not applied again in that block — otherwise it could never be undone.
Positional conditions belong in isAvailable
Rejecting inside run happens after the characters are already gone, so the space disappears. isAvailable is asked before the deletion, so returning false there lets the key behave normally — a # typed inside a list simply stays as text.
Do not put the
gflag onpattern.isPlainBlockasks "is the caret in an outermost block (paragraph or heading)". Rules that swap the block (setBlockType) use it as their condition, because running inside a list would unwrap the whole list.If
runreturnsfalse, core puts the deleted characters back.To touch the block further after the transform you must re-measure the caret. The checklist does that.
tsrun: (context, match) => { if (!rule.run(context, match)) return false // re-read after the mutation — run just created the list, context.caret is stale const item = caretBlock(context.root, context.registry)?.editable if (item?.tagName === 'LI') { item.setAttribute(TASK_CHECKED_ATTR, String(match[1]?.toLowerCase() === 'x')) } return true },
pasteText — one pasted plain-text token
pasteText?(text: string, context: CommandContext): HTMLElement | nullAsked in registration order when the clipboard holds a single string with no whitespace. The first wing to return an element takes it; null means no interest.
// pasting a YouTube URL turns it into a video
pasteText(text, context) {
const id = youtubeVideoId(text)
return id ? createEmbed(context.document, id) : null
},// pasting an image URL turns it into an image
pasteText(text, context) {
const source = looksLikeImageUrl(text) ? safeUrl(text) : null
return source ? createImage(context.document, source) : null
},- Build the element with
context.document. - When judging by URL, accept only what is certain. A false negative leaves plain text; a false positive embeds something broken in the document.
- Never test a hostname with string containment.
youtube.com.evil.testgets through. Parse withnew URL()and comparehostname.
pasteHtml — a whole HTML fragment
pasteHtml?(fragment: DocumentFragment, context: CommandContext): HTMLElement[] | nullOn the text/html path, asked before the core filter, in registration order. The first wing to return blocks takes it and the rest are not asked.
Why before the filter — the filter decides, element by element, whether to keep or unwrap. It cannot rearrange children. Markup like GitHub's, where a <table> holds a line-number column beside the code column, has already merged the line numbers into the body text by the time the filter is done, and it cannot be undone. It has to be asked while the original shape is still there.
pasteHtml(fragment, context) {
const tables = [...fragment.querySelectorAll('table')]
if (tables.length === 0) return null
// pick out the code tables. If even one is not, this fragment is not ours
const found = tables.map((table) => ({ table, lines: codeLinesFromTable(table) }))
if (found.some((entry) => !entry.lines)) return null
// if text remains after removing the tables, something other than code is mixed in
const rest = fragment.cloneNode(true) as DocumentFragment
for (const table of rest.querySelectorAll('table')) table.remove()
if ((rest.textContent ?? '').trim() !== '') return null
return found.map(({ table, lines }) => {
const pre = context.document.createElement('pre')
for (const [index, line] of lines.entries()) {
if (index > 0) pre.append(context.document.createElement('br'))
if (line !== '') pre.append(context.document.createTextNode(line))
}
return pre
})
},- The fragment belongs to an inert document — no scripts, no resource loads. Treat it as read-only and build the blocks you return with
context.document. - The blocks you return are not trusted either; core runs them through the filter once.
nullmeans "not interested" (on to the next wing);[]means "taken, nothing to keep" — an empty array does not move on.- Throwing skips only that wing; the paste continues.
- A false positive is worse than a miss. The code wing takes the fragment only when all of it is a code table — swallowing a single table caught between paragraphs would break ordinary table pasting.
What wings do not claim, core handles
Blocks owned by a wing (headings, code, lists, tables, embeds) are inserted whole; only paragraphs are unwrapped into text and joined. Copying carries the block context too — the browser default only carries the inline fragment when the selection sits inside one block, so selecting all of a heading's text would lose "this was a heading" from the clipboard.
handleFiles — taking incoming files
handleFiles?(files: readonly File[], context: CommandContext, at: FileDrop): boolean
interface FileDrop {
readonly source: 'paste' | 'drop' | 'pick'
readonly range: Range | null // the caret computed at the drop point; null otherwise
}Called from both paste and drag-and-drop. Wings are asked in registration order and the first true takes it.
const handleFiles = (files: readonly File[], context: CommandContext): boolean => {
if (!editor || editor.locked) return false
const accepted = accept(files, config, report)
// even if validation rejected everything, we took it — files must not paste as text
if (accepted.length === 0) return true
void runner.run(accepted, context)
return true
}| Path | What core does |
|---|---|
| paste | asks only when text/html produced nothing — files are the last resort |
| drag & drop | shows the drop affordance, moves the caret to the drop point, then asks |
| file dialog | the wing opens it in activate and joins the same hook |
Files come last so that a cut-and-paste never turns into an upload.
- Core does no cleanup even when you take it. Uploads are asynchronous, so core cannot know when the document changed. Call
context.commit()when it does. rangeis handed over after core has already moved the caret there.context.blockis usually enough.- Core blocks the default drag behaviour even with no wing to take the files. Without that the browser does not treat the editable area as a drop target, the drop lands on the document, and the page navigates to the dropped file — the whole draft disappears. "Nobody will take this" must mean "nothing happens", not "whatever the browser feels like".
Lock for anything long-running
const lock = editor.lock({ timeout: 60000 })
try {
/* … upload … */
lock.heartbeat() // still alive — the deadline restarts
} finally {
lock.release()
}A lock is separate from readOnly. Several can overlap and editing returns only when the last one is released. The deadline is the safety net — without a heartbeat() core releases it itself. destroy() releases them all.
Temporary markup planted while locked follows four rules — prefixed tag, no text nodes, no commit(), and removal before the lock is released.
prepareComposition — preparing the caret slot before IME
prepareComposition?(context: CommandContext): booleanThe DOM cannot be touched during Korean or Japanese composition — touching it cuts the IME off and garbles the characters. So a wing that needs a slot for the caret creates it just before composition starts.
Slot preparation is a registered pipeline.
paragraph beside an object block → pending mark (zero-width space) → the wing's prepareComposition- Most wings do not need this. No bundled wing uses the hook today — only features where "the first composed character must land somewhere special" do.
- Return
trueif you touched the document; core then runs normalisation once. - Throwing does not stop the composition.
What a wing owes composition
- Do not attach your own
compositionstart/compositionendlisteners. The moment your verdict disagrees with core's, that is the bug. Readeditor.composing. - Defer work with
editor.afterComposition(fn)(it runs immediately when not composing). Code highlighting is the precedent. commit()already goes through the same deferral inside — no need to wrap it. Arriving mid-composition, normalisation, snapshotting andonChangeare all postponed until after.- Never remember a text node's identity while editing. Composition commits, normalisation and range extraction replace or split text nodes without warning. If you must remember a position, use coordinates that survive a split — block plus character offset. Typing in English appends within one node, so this becomes a bug only when typing Korean.