NABI NOTE
Docs

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

ts
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.

ts
// 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
    },
  },
],
ts
// 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".

ValueMeaningWhat core does
falsenot handledpasses 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 changednormalisation · 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 shift matches only when Shift is not held.

  • Keys pressed during IME composition never enter the pipeline. The commit Enter and 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 ⑤ wing keys ⑥ core's Tab fallback.

  • A Tab nobody claims prevents focus movement and inserts a tab character.

  • Hand Enter back 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 false

    In an empty block the caret is always at the start, so handing it back makes Enter stack paragraphs above forever.


inputRules — transforms triggered by typing alone

ts
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.

ts
// divider — Enter on a line containing only ---
inputRules: [
  { trigger: 'enter', pattern: /^-{3,}$/, run: (context) => insertEmptyBlock(context, 'hr') },
],
ts
// 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.

  1. At the trigger moment it matches the block's plain text before the caret against pattern.
  2. On a match it blocks the key's own behaviour, deletes the matched characters and calls run.
  3. It remembers the state just before the transform.
  4. 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 g flag on pattern.

  • isPlainBlock asks "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 run returns false, core puts the deleted characters back.

  • To touch the block further after the transform you must re-measure the caret. The checklist does that.

    ts
    run: (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

ts
pasteText?(text: string, context: CommandContext): HTMLElement | null

Asked 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.

ts
// pasting a YouTube URL turns it into a video
pasteText(text, context) {
  const id = youtubeVideoId(text)
  return id ? createEmbed(context.document, id) : null
},
ts
// 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.test gets through. Parse with new URL() and compare hostname.

pasteHtml — a whole HTML fragment

ts
pasteHtml?(fragment: DocumentFragment, context: CommandContext): HTMLElement[] | null

On 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.

ts
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.
  • null means "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

ts
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.

ts
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
}
PathWhat core does
pasteasks only when text/html produced nothing — files are the last resort
drag & dropshows the drop affordance, moves the caret to the drop point, then asks
file dialogthe 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.
  • range is handed over after core has already moved the caret there. context.block is 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

ts
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

ts
prepareComposition?(context: CommandContext): boolean

The 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 true if 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/compositionend listeners. The moment your verdict disagrees with core's, that is the bug. Read editor.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 and onChange are 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.