NABI NOTE
Docs

The setup hook and the flower

Whatever the declarative fields cannot express, you build in setup. And the core behaviour you reach for there comes through a single door — the flower.

What the name means — the flower is what the body grew for its wings. It is not an external library but a surface core chose to offer. A wing takes nectar only from the flower.


setup

ts
setup?(editor: EditorHandle): (() => void) | void

Called once, right after mount. The function you return is called on destroy()whatever you made, undo it there.

ts
setup(editor) {
  const off = editor.on('change', sync)
  return () => off()
}

The upload wing holds and releases its instance state here.

ts
setup(handle) {
  editor = handle
  controller = new AbortController()

  return () => {
    // tell in-flight uploads to stop. Locks are collected by core on destroy
    controller.abort()
    // revoke the preview URLs of any placeholder not yet removed
    for (const record of active) releasePreview(record)
    active.clear()
    editor = null
  }
},

Code highlighting has the same shape — it listens to change, paints once per frame, and its teardown collects both the subscription and any scheduled frame.

ts
function startHighlighting(editor: EditorHandle, options: CodeOptions): () => void {
  let frame = 0

  const paintAll = () => {
    frame = 0
    // do not rewrite the DOM during composition — trust core's single verdict
    if (editor.composing) return
    /* … paint each code block … */
  }

  const schedule = () => {
    if (editor.composing || frame !== 0) return
    frame = editor.document.defaultView?.requestAnimationFrame(paintAll) ?? 0
    if (frame === 0) paintAll()
  }

  const off = editor.on('change', schedule)
  paintAll()                        // once at the start

  return () => {
    off()
    if (frame !== 0) editor.document.defaultView?.cancelAnimationFrame?.(frame)
  }
}

If setup throws, the editor keeps running. Scanning, keys, clicks, pasteText and pasteHtml swallow exceptions the same way — one wing does not take the editor down.


EditorHandle — what setup receives

Not the whole Nabi instance, but only the surface a wing needs. Handing over the whole instance would let wings lean on internals, and core could never be changed again.

FieldWhat it is
elementthe editable area (contenteditable root)
documentits document. Create new elements here
localethe display language
readOnlywhether the editor is read-only
lockedwhether editing is locked (a state separate from readOnly)
composingwhether an IME composition is in progress
afterComposition(run)defers until composition ends, or runs now if there is none
uithe floating UI service (popup · grid · prompt · notice)
lock({ timeout })locks editing → { heartbeat, release, active }
context()the current caret situation. Recomputed on every call
toggle(wingId)toggles one wing
runCommand(commandId)runs one command
labelOf(wingId)that wing's display name in the current locale
commit()call after changing things directly — invariants → snapshot → onChange
on(type, listener)subscribes; the returned function unsubscribes

The events you can listen to are change, selectionchange, markschange, menuchange, historychange, lockchange, fullscreenchange, focus, blur and destroy.

An edit without commit() never happened

Every path that changes the document goes through one edit gate, where invariant repair, the undo snapshot, the state refresh and onChange all happen together. An edit that skips it cannot be undone and never reaches the host.


What the flower provides

These are the exports of nabi-note you reach for while building a wing. Bundled wings and third-party wings use the same door — there is no shortcut open only on the inside.

ts
import {
  insertBlockAt,
  findAdjacentBlock,
  deleteBlockCommand,
  caretToStart,
  clearFormatAt,
  clearMarksInRange,
  isPlainBlock,
  safeUrl,
  markIcon,
  blockIcon,
  svgIcon,
  TRASH_ICON,
} from 'nabi-note'

Inserting, finding and deleting blocks

What it does
insertBlockAt(context, element)puts it at the caret block. It takes the slot of an empty paragraph, otherwise it follows. It guarantees a paragraph to keep writing in after the block and moves the caret there
findAdjacentBlock(context, matches)finds a matching block that is either the caret block itself or its immediately preceding sibling — the way to reach an object block the caret cannot enter
deleteBlockCommand({ id, names, find })stamps out a "delete this block" command, including swapping in an empty paragraph and placing the caret inside it
caretToStart(root, element)puts the caret at the start of that element

The point of insertBlockAt taking context.block rather than the caret is that it must still insert at the right place after focus has moved into an input box.

Marks

What it does
clearMarksInRange(context)strips every inline mark in the selection. It asks the schema which tags are marks, so newly registered marks are cleared automatically
clearFormatAt(context)strips every wing feature covering the caret — marks plus block attributes (alignment, drop cap), block type (heading → paragraph), and lists. Object blocks (table, image, code) are left alone: structure is not formatting. This is what the clearFormat() wing uses

Positional predicates

What it does
isPlainBlock(context)whether the caret is in an outermost block (paragraph, heading) rather than inside a list item or table cell. The positional condition for block-swapping input rules

Validation, icons, hashing

What it does
safeUrl(raw, { allowLocal })checks and tidies a URL for storage. Absolute http(s) and same-site relative paths only; allowLocal also accepts blob: and data:image/…
svgIcon(body, strokeWidth?) · markIcon(body) · blockIcon(body)freeze an SVG fragment into an icon URL
isIconUrl(icon) · paintIcon(doc, icon, label)tell whether an icon is a URL, and build the element that draws it
TRASH_ICONthe bin icon every block-delete command shares
sha1Hex(bytes)SHA-1, used by upload-like wings to fingerprint a file
EPHEMERAL_PREFIX · isEphemeralTag(tag)the tag prefix for editor-only temporary markup

Turn on safeUrl's allowLocal only for upload previews and demos. blob: and data: URLs mean nothing outside that page. Only images pass for data:data:text/html carries a document, not a picture.

What else comes with it

ts
import type {
  NabiWing, InlineMarkSpec, BlockSpec, ContentSpec, BlockAttributeSpec,
  CommandContext, CaretBlock, WingCommand, WingNames, WingGroup,
  KeyBinding, KeyRunResult, InputRule, GridPickerSpec, PromptSpec,
  EditorHandle, EditorLock, FileDrop, UIService, UIPopupHandle, NoticeTone, Locale,
} from 'nabi-note'

WingRegistry and NABI_CSS/NABI_STYLE_ID are exported too, for handling the sheet yourself or extracting it at build time.


What the inside wings use beyond that

The bundled wings use a wider surface of the flower — block conversion (setBlockType), list toggling and indenting (toggleContainer, indentListItems), the full set of table commands, caret reads (caretBlock, caretAtBlockStart), leaving a block (leaveBlock), applying a mark with a value (applyMarkWithValue, markAtCaret) and locale interpolation (t, resolveName).

Some of these are not yet exposed on the package entry point. The next section is what to do then.


When the contract falls short — where to widen

Sooner or later you hit "only core can do this". Do not quietly work around it. Forcing something the contract does not cover breaks silently on the next version.

Where to widen depends on what is missing.

What is missingWhere to widen
"I want to call this core function" (block conversion, table ops, caret reads)the flower — add the re-export in src/flower.ts and expose it on the entry point
"There should be a hook for this" (a new category like clicks, files, paste)the contract — add a field to NabiWing
"This spec is one field short" (something like appliesTo)the contract — widen that spec interface
"I have to edit a core file"that itself is the signal that the contract is short

Many of today's hooks came about exactly that way.

  • No way to click a checkbox → onClick was created.
  • No way to receive a file drop → handleFiles was created.
  • The YouTube URL box was hand-drawn → prompt.validate and preview were created.
  • Inferring the caret entry point from content/empty diverged → caretEntry was created.
  • Alignment buttons were "visible but did nothing" → appliesTo also drives visibility now.

None of these changed core; every one widened the contract. Which is why third-party wings gained the same ability on the same day.


A checklist for a new wing

  1. Is it a factory function? Does state live in the closure?
  2. Is the id lowercase-and-hyphens only? Does names.en start with a capital?
  3. Does scanning claim the right tags? Is the output the canonical tag?
  4. Does it fall to plain text when not registered?
  5. Is the filter idempotent (filter(filter(x)) === filter(x))?
  6. Does a round trip preserve the value (fromSourceHtml(toOutputHtml(x)) === x)?
  7. Do the key and click contracts properly block — or hand back — the key's own behaviour?
  8. Does the teardown undo everything setup made?
  9. Is there a commit(), or a true/'changed', everywhere the document changes?

Next