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
setup?(editor: EditorHandle): (() => void) | voidCalled once, right after mount. The function you return is called on destroy() — whatever you made, undo it there.
setup(editor) {
const off = editor.on('change', sync)
return () => off()
}The upload wing holds and releases its instance state here.
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.
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.
| Field | What it is |
|---|---|
element | the editable area (contenteditable root) |
document | its document. Create new elements here |
locale | the display language |
readOnly | whether the editor is read-only |
locked | whether editing is locked (a state separate from readOnly) |
composing | whether an IME composition is in progress |
afterComposition(run) | defers until composition ends, or runs now if there is none |
ui | the 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.
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_ICON | the 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
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 missing | Where 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 →
onClickwas created. - No way to receive a file drop →
handleFileswas created. - The YouTube URL box was hand-drawn →
prompt.validateandpreviewwere created. - Inferring the caret entry point from
content/emptydiverged →caretEntrywas created. - Alignment buttons were "visible but did nothing" →
appliesToalso 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
- Is it a factory function? Does state live in the closure?
- Is the
idlowercase-and-hyphens only? Doesnames.enstart with a capital? - Does scanning claim the right tags? Is the output the canonical tag?
- Does it fall to plain text when not registered?
- Is the filter idempotent (
filter(filter(x)) === filter(x))? - Does a round trip preserve the value (
fromSourceHtml(toOutputHtml(x)) === x)? - Do the key and click contracts properly block — or hand back — the key's own behaviour?
- Does the teardown undo everything
setupmade? - Is there a
commit(), or atrue/'changed', everywhere the document changes?
Next
- Build your own wing — the basic fields
- Glossary — wing · flutter · soul · sourceHtml · outputHtml