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.
| Field | On press |
|---|---|
picker | a size grid opens |
prompt | a one-line input opens |
activate | the 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
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.
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
},
},runreturnstrueif 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
Enteran IME commit produces) arrives immediately after. That used to create a1×1table on the spot.
prompt — the one-line input
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.
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?
validate?(value: string): booleanWhile 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.
validate: (value) => youtubeVideoId(value) !== nullChecking 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
preview?(value: string, host: HTMLElement, context: CommandContext): voidCalled on every keystroke. host is an empty area core has prepared, and the wing draws all of it.
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. contextis 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
activate?(context: CommandContext, anchor: () => DOMRect | null): booleanHandles what picker and prompt cannot express — opening a file dialog, building a structured block, or opening your own box through context.ui.
// 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
},// 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
},// 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".
anchoris the pressed button (or where the menu was). Pass it straight toui.popupas itsanchorand 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".
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
}{
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.
isAvailable: (context) => tableAt(context.cell) !== null // only inside a table
isAvailable: (context) => context.block?.tagName === 'PRE' // only inside codeCheck the same predicate again inside run — the caret may have moved since the row was drawn.
render — drawing a control instead of a button
render?(host: HTMLElement, context: CommandContext): voidhost 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.
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,
runis 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
inputsits outside the editable area, where core's IME gate cannot reach. CheckisComposingon 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.
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
onClick?(event: MouseEvent, context: CommandContext): booleanFor actions where the user clicks the content itself rather than a command button — the checklist box, the fold box marker, the image sizing panel.
// 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
truetakes it, at which point core prevents the default behaviour and reports the change. truemeans "the document changed". If you only moved the caret or opened a box you may still answertrue, but returnfalsewhen you do not want an undo point. The fold box does exactly that — a click outside the marker only callspreventDefault()and returnsfalse.- When the marker is drawn with
::beforethere 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 hadpxbaked in and drifted at larger font sizes. - Selecting an object wholesale has already been done by core. The image wing's
onClickonly 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.
| Method | What 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 |
anchoris a function. It is called again whenever the screen moves so the box follows, and returningnullcloses it.- A notice
messageis always plain text. Other people's values (filenames) end up in it, so it is never interpreted as markup.toneis'info'(default) or'error';durationis in ms, and0keeps 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
| Field | What it is |
|---|---|
root | the editable root |
document | the document to create elements in. Do not use globalThis.document |
caret | the caret position computed at dispatch time (CaretBlock) |
block | the top-level block holding the caret (shortcut for caret.block) |
cell | the leaf cell holding the caret (td, li); the block itself for ordinary blocks |
registry | the document schema lookup — "what is this tag" |
ui | the UI service above |
locale | the 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.