NABI NOTE
Docs

UI that asks first, and actions

Picking a wing from the toolbar or the @ menu toggles by default — bold turns on, a paragraph becomes a heading. But a table has to ask how many rows and columns, and an image has to ask for a URL. The fields on this page are where that happens.

FieldOn press
pickera size grid opens
prompta one-line input opens
activatethe wing handles everything
commands(not a button) actions appear on the context row
onClick(not a button) a click in the content is claimed

Any of the three blocks toggling

If picker, prompt or activate is present, toggle() returns false. That prevents an empty shell from being inserted while the size or URL is still unknown.


picker — the size grid

ts
interface GridPickerSpec {
  readonly kind: 'grid'
  readonly maxRows?: number       // default 8
  readonly maxColumns?: number    // default 8
  run(context: CommandContext, rows: number, columns: number): boolean
}

The grid you see when inserting a table. The wing declares a maximum size and a run function; core draws the DOM and handles mouse and keyboard.

ts
picker: {
  kind: 'grid',
  maxRows: 8,
  maxColumns: 8,
  run(context, rows, columns) {
    const created = createTable(context.document, rows, columns, true)
    if (!insertBlockAt(context, created)) return false

    // the caret goes to the first cell, not the trailing paragraph —
    // once a table exists you start filling it in
    const first = created.querySelector('tr > *')
    if (first) caretToStart(context.root, first as HTMLElement)
    return true
  },
},
  • run returns true if the document actually changed, and core then finishes the edit (normalisation, an undo point, onChange).
  • The grid opens with nothing selected. Boxes are usually opened with a key, and that key's twin (the second Enter an IME commit produces) arrives immediately after. That used to create a 1×1 table on the spot.

prompt — the one-line input

ts
interface PromptSpec {
  readonly kind: 'prompt'
  readonly placeholder?: WingNames
  readonly confirm?: WingNames
  validate?(value: string): boolean
  preview?(value: string, host: HTMLElement, context: CommandContext): void
  run(context: CommandContext, value: string): boolean
}

window.prompt is blocked in some iframe and extension environments, so this one is drawn directly.

ts
prompt: {
  kind: 'prompt',
  placeholder: { ko: '이미지 주소를 붙여넣으세요', en: 'Paste an image URL' },
  confirm: { ko: '넣기', en: 'Insert' },
  run(context, value) {
    const source = safeUrl(value)
    if (!source) return false
    return insertBlockAt(context, createImage(context.document, source))
  },
},

placeholder and confirm are locale dictionaries like names, not plain strings.

validate — may this value be committed?

ts
validate?(value: string): boolean

While it is false the confirm button does not fire and Enter does not get through either; the box stays open. Without it, only empty values are rejected.

ts
validate: (value) => youtubeVideoId(value) !== null

Checking once more inside run is the convention among the bundled wings — validate blocks it, but the second check is cheap.

preview — a preview under the input

ts
preview?(value: string, host: HTMLElement, context: CommandContext): void

Called on every keystroke. host is an empty area core has prepared, and the wing draws all of it.

ts
function renderPreview(value: string, host: HTMLElement, context: CommandContext): void {
  const doc = host.ownerDocument
  const id = youtubeVideoId(value)

  if (!id) {
    delete host.dataset['video']
    const hint = doc.createElement('p')
    hint.className = 'nabi-youtube__hint'
    const key = value.trim() === '' ? 'hintEmpty' : 'hintInvalid'
    hint.textContent = resolveName(TEXT[key], context.locale) ?? TEXT[key].en
    host.replaceChildren(hint)
    return
  }

  // the same id does not swap the iframe — otherwise it reloads on every keystroke
  if (host.dataset['video'] === id) return
  host.dataset['video'] = id
  host.replaceChildren(createEmbed(doc, id))
}
  • Keep state in host.dataset. A field on the wing would outlive the box.
  • context is the one from the moment the box opened (locale, document).
  • It fires on every keystroke. Do expensive work — refetching a resource — only when the value really changed.

Value boxes restore the position they opened from

When the box appears the caret moves into its input. Core holds the CommandContext and a copy of the selection range from the moment the button was pressed and restores the range just before run. Wings that insert blocks get by with context.block, but a mark wing like link has "which characters" as its very target and can do nothing without that restore.


activate — a fully custom action

ts
activate?(context: CommandContext, anchor: () => DOMRect | null): boolean

Handles what picker and prompt cannot express — opening a file dialog, building a structured block, or opening your own box through context.ui.

ts
// clear formatting — no UI at all. Strip what covers the caret and be done
activate(context) {
  if (!clearFormatAt(context)) return false
  context.commit()
  return true
},
ts
// fold box — a toggle cannot build it. Swapping the tag alone leaves a box with no summary
activate(context): boolean {
  const box = createDetails(context.document)
  if (!insertBlockAt(context, box)) return false

  // the caret goes to the summary, not the trailing paragraph
  const summary = summaryOf(box)
  if (summary) caretToStart(context.root, summary)
  context.commit()
  return true
},
ts
// upload — open a file dialog and pass the picked files to the same function as handleFiles
activate(context): boolean {
  const input = context.document.createElement('input')
  input.type = 'file'
  input.multiple = true
  input.style.display = 'none'
  input.addEventListener('change', () => {
    const files = [...(input.files ?? [])]
    input.remove()
    if (files.length > 0) handleFiles(files, context, { source: 'pick', range: null })
  }, { once: true })

  ;(context.document.body ?? context.document.documentElement).append(input)
  input.click()
  return true
},

activate does not go through the edit gate for you

If you changed the document you must call context.commit() yourself. Core cannot know when this hook changes things — right away, or after a file has been picked. Without it the edit cannot be undone and onChange never fires.

  • The return value means "opened a UI or did the work".
  • anchor is the pressed button (or where the menu was). Pass it straight to ui.popup as its anchor and the box appears beside it.

commands — actions on the context row

Things a single toggle cannot express — a table's "insert row", code's "language", a link's "remove".

ts
interface WingCommand {
  readonly id: string
  readonly names: WingNames
  readonly icon?: string
  isAvailable?(context: CommandContext): boolean
  render?(host: HTMLElement, context: CommandContext): void
  run?(context: CommandContext): boolean
}
ts
{
  id: 'link-remove',
  names: { ko: '링크 해제', en: 'Remove link' },
  icon: UNLINK_ICON,
  isAvailable: (context) => markAtCaret(context, 'a') !== null,
  run(context) {
    const mark = markAtCaret(context, 'a')
    if (!mark) return false
    mark.replaceWith(...mark.childNodes)   // unwrap only — the text stays
    return true
  },
}

isAvailable

Only commands that answer true appear on the context row. Without it a command is always available.

ts
isAvailable: (context) => tableAt(context.cell) !== null   // only inside a table
isAvailable: (context) => context.block?.tagName === 'PRE' // only inside code

Check the same predicate again inside run — the caret may have moved since the row was drawn.

render — drawing a control instead of a button

ts
render?(host: HTMLElement, context: CommandContext): void

host is an empty slot core has prepared. The language input on a code block is the example: a language is something you type, not something you pick, and the list is only a suggestion, so it must not be a closed set.

ts
render(host, context) {
  const doc = context.document
  // hold on to the code block from the moment it was drawn —
  // once focus is in the input there is no caret
  const block = context.block
  if (!block) return

  const input = doc.createElement('input')
  input.type = 'text'
  input.className = 'nabi__control-input'
  input.value = block.getAttribute('data-nabi-lang') ?? ''
  /* … on change, write to block and call context.commit() … */
  host.append(input)
},
  • When this field is present, run is not used.
  • The wing owns whatever it drew. Call context.commit() after changing the document.
  • The context row is redrawn only when the set of available commands changes, and core never redraws while focus is inside the control — an input does not vanish mid-typing.
  • A control with its own input sits outside the editable area, where core's IME gate cannot reach. Check isComposing on the first line of your listener.

Block-delete commands are stamped out

Deleting an object block the caret cannot enter would leave nowhere for the caret to go. The flower provides one factory so that rule is not copied per wing.

ts
commands: [
  deleteBlockCommand({
    id: 'youtube-delete',
    names: { ko: '영상 삭제', en: 'Delete video' },
    find: (context) => findAdjacentBlock(context, isEmbed),
  }),
],

It swaps in an empty paragraph and puts the caret inside it. The icon is shared too (TRASH_ICON).


onClick — claiming a click in the content

ts
onClick?(event: MouseEvent, context: CommandContext): boolean

For actions where the user clicks the content itself rather than a command button — the checklist box, the fold box marker, the image sizing panel.

ts
// checklist — a click on the box in front of an item
onClick(event, context) {
  const block = closestBlock(context.root, event.target as Node)
  if (block?.getAttribute(TASK_FLAG) !== TASK_VALUE) return false

  const item = closestItem(block, event.target as Node)
  if (!item?.hasAttribute(TASK_CHECKED_ATTR)) return false
  if (!inCheckboxArea(item, event.clientX)) return false

  const checked = item.getAttribute(TASK_CHECKED_ATTR) === 'true'
  item.setAttribute(TASK_CHECKED_ATTR, String(!checked))
  return true
},
  • Wings are asked in registration order and the first true takes it, at which point core prevents the default behaviour and reports the change.
  • true means "the document changed". If you only moved the caret or opened a box you may still answer true, but return false when you do not want an undo point. The fold box does exactly that — a click outside the marker only calls preventDefault() and returns false.
  • When the marker is drawn with ::before there is no element to hit, so you have to measure coordinates. Compute the width in the same unit as the CSS. A checkbox hit test once had px baked in and drifted at larger font sizes.
  • Selecting an object wholesale has already been done by core. The image wing's onClick only opens its own panel.

context.ui — opening a box yourself

UI that picker and prompt cannot express is opened inside activate or a command. Positioning, following the scroll, Escape, click-outside-to-close and cleanup are all core's responsibility.

MethodWhat it is
ui.popup({ anchor, render, className?, onClose? })an arbitrary box. { modal: true } covers the whole screen (lightbox)
ui.grid({ anchor, onPick, maxRows?, maxColumns? })the size grid
ui.prompt({ anchor, onSubmit, placeholder?, confirm? })the one-line input
ui.notice({ message, tone?, duration? })a notice that appears and fades
  • anchor is a function. It is called again whenever the screen moves so the box follows, and returning null closes it.
  • A notice message is always plain text. Other people's values (filenames) end up in it, so it is never interpreted as markup. tone is 'info' (default) or 'error'; duration is in ms, and 0 keeps it until closed.
  • By convention these boxes carry no × button.
  • Prefer the declarative form when it fits. The YouTube URL box was hand-drawn before it moved to prompt.validate/preview.

CommandContext — what every hook receives

FieldWhat it is
rootthe editable root
documentthe document to create elements in. Do not use globalThis.document
caretthe caret position computed at dispatch time (CaretBlock)
blockthe top-level block holding the caret (shortcut for caret.block)
cellthe leaf cell holding the caret (td, li); the block itself for ordinary blocks
registrythe document schema lookup — "what is this tag"
uithe UI service above
localethe display language
commit()call after changing the document directly

context.caret goes stale after a mutation

If you still need the caret after changing the document, measure it again there. That re-read is not waste; it is reading the DOM you just changed. The checklist input rule is the precedent — it looks the item up again after creating the list.

registry deliberately cannot enumerate wings. That would be a back door for one wing to rummage through another's commands and hooks.