Build your own wing
A wing is one object. There is no class to extend and no registration ceremony — putting it in the wings array is the registration.
A wing you write runs on exactly the same terms as the built-in ones. Bold, tables and uploads are all made by filling in the very fields described here. There is a single door into core behaviour (the flower), and no back door that is open only on the inside.
The shortest wing
import { nabi, markIcon } from 'nabi-note'
import type { NabiWing } from 'nabi-note'
function highlight(): NabiWing {
return {
id: 'text-highlight',
group: 'mark',
names: { ko: '형광펜', en: 'Highlight' },
icon: markIcon('<path d="M3 12h10"/>'),
inline: {
tag: 'mark',
attributes: [],
escapeKeys: ['Escape'],
claims: (element) => element.tagName === 'MARK',
},
}
}
nabi.create('#editor', { wings: [highlight()] })That gives you a toolbar button, an entry in the @ menu, and <mark> surviving in the document. Without registration <mark> is unwrapped and falls to plain text.
Export a factory function, not an instance
A wing is a function that returns a fresh object every time it is called, the way bold() does. A page can hold several editors, and two editors sharing one object would share whatever state lives inside it.
// ✅ an independent object per editor
export function highlight(): NabiWing { return { /* … */ } }
// ❌ one object per module — both editors use the same one
export const highlight: NabiWing = { /* … */ }State belongs in the factory's closure. That is how the upload wing is built — the in-flight bookkeeping and the AbortController are all local variables inside createWing().
export function counter(): NabiWing {
let editor: EditorHandle | null = null // state for this editor only
return {
id: 'demo-counter',
names: { ko: '글자 수', en: 'Count' },
setup(handle) {
editor = handle
return () => { editor = null }
},
}
}Wings that take options have the same shape — heading(1), link({ allowLocalUrls: true }) and code({ highlight }) are all factories with arguments.
Registration and order
nabi.create('#editor', {
wings: [bold(), italic(), highlight()],
html: '<p>Initial content</p>',
onChange: (html) => console.log(html),
})Array order is scan order. When core decides who owns a piece of markup it asks claims in this order, and the first wing to answer true takes it. If nobody takes it, the element is unwrapped.
Order decides the outcome in these places.
| Field | How it splits |
|---|---|
inline.claims · block.claims | the first wing to claim takes the element |
keys | the first wing to consume takes the key |
onClick · pasteText · pasteHtml · handleFiles | the first wing to answer takes it |
fromSourceHtml | the first spec that recognises its own output fixes it |
| default toolbar layout | within each group, wings sit in registration order |
Two wings never own the same tag
The rule is one owner per canonical tag. Sharing split by an attribute — a bullet list (ul) and a checklist (ul[data-nabi-list="task"]) — is legitimate, and even then two conditions apply.
Every field you can fill
This is the whole of NabiWing. Only id and names are required; everything else is filled in when you need it.
| Field | What it does | Covered in |
|---|---|---|
id | globally unique identifier | this page |
names | per-locale display name | this page |
icon | what the toolbar button draws | this page |
group | toolbar visibility kind | this page |
styles | the CSS this wing carries | this page |
inline | a mark laid over characters | Inline marks |
block · blocks | a chunk that occupies a paragraph slot | Blocks and attributes |
blockAttribute | same tag, different property | Blocks and attributes |
picker | opens a size grid first | UI and actions |
prompt | opens a one-line input first | UI and actions |
activate | a fully custom action | UI and actions |
commands | extra actions on the context row | UI and actions |
onClick | claims a click in the content | UI and actions |
keys | claims a single key | Keys, rules, paste |
inputRules | transforms triggered by typing alone | Keys, rules, paste |
pasteText | one pasted plain-text token | Keys, rules, paste |
pasteHtml | a whole pasted HTML fragment | Keys, rules, paste |
handleFiles | takes incoming files | Keys, rules, paste |
prepareComposition | prepares the caret slot before IME composition | Keys, rules, paste |
setup | everything declarations cannot express | setup and flower |
id
A globally unique absolute identifier.
readonly id: stringIt is also the key hosts write in their own code — editor.toggle('text-bold'), editor.runCommand('table-row-insert-above') and toolbar: [['text-bold', 'text-italic']] all use this value.
- Lowercase and hyphens only:
text-bold·block-heading-1·table-row-insert-above. - By convention the kind comes first —
text-for marks,block-for blocks,align-for the alignment family. - Command ids inside
commandsfollow the same rule, and prefixing them with the wing id is recommended (link-remove·code-language·details-toggle).
Once shipped, an id is hard to change
Ids end up in the host's toolbar layout and in stored settings. Renaming one later makes the button quietly disappear from that host's toolbar — unknown ids are skipped.
names
The per-locale display name used for the button tooltip, the @ menu entry and search.
interface WingNames {
readonly ko: string
readonly en: string
readonly [locale: string]: string // add more languages if you like
}names: { ko: '굵게', en: 'Bold' }
names: { ko: '위에 행 추가', en: 'Insert row above' }The lookup goes exact match → language code only → en → the first remaining value. With locale: 'ko-KR' the ko entry is used; if no language matches, it falls back to en.
- Always fill in
koanden. Other languages are optional. names.enstarts with a capital letter. Several wings stand side by side in the toolbar and the@menu, so mixed casing shows up immediately.- The
@menu searches the current locale name, the other locale names and the id, soboldfinds the wing even on a Korean screen. - Messages with values baked in — upload errors, for instance — use
{param}placeholders, which the flower'st()fills.
icon
What the toolbar button draws. An icon is a URL.
readonly icon?: stringCore accepts three shapes and tells them apart itself.
| Value | How it is drawn |
|---|---|
a URL (data: · https: · /… · ./…) | laid on with mask-image, painted with currentColor |
markup starting with < | inlined as SVG (the legacy path, kept for third parties) |
| any other short string | used as a text label ('H1') |
| nothing | the first character of the display name |
Three helpers freeze SVG into a URL.
import { svgIcon, markIcon, blockIcon } from 'nabi-note'
markIcon('<path d="M2 8h12"/>') // stroke 1.6 — marks, alignment, lists
blockIcon('<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="1.5"/>') // stroke 1.4 — blocks
svgIcon('<path d="…"/>', 2) // when you want to set the stroke yourself- The wrapper (
<svg>,viewBox, stroke colour) is added for you. Pass strokes and shapes only. The grid is0 0 16 16. - There are two presets so the weights match. Block icons carry more strokes and need to be thinner (1.4); mark icons carry fewer and need to be slightly heavier (1.6).
- Filled shapes need an opaque fill. The mask looks at alpha, not colour, so an unfilled shape becomes a hole. That is why YouTube's play triangle carries
fill="#000". - Do not put colour in the SVG. Following
currentColoris what makes pressed, disabled and dark states come along for free. - Sometimes a text label is the better answer — headings use
icon: 'H1'.
Never put user input in an icon
The SVG string in icon is treated as code written by the wing author. A value that came from a user would be embedded as-is.
group
The kind the toolbar uses to show only the buttons that make sense right now.
type WingGroup = 'mark' | 'block' | 'insert' // defaults to 'insert'| Kind | Meaning | Examples |
|---|---|---|
mark | formatting laid over characters | bold · link · clear formatting |
block | converting or qualifying the current block | heading · list · align · code · drop cap |
insert | putting a new chunk in | image · table · YouTube · divider · upload |
Which kinds are visible is decided by where the caret is.
| Where | What that is | Visible kinds |
|---|---|---|
| writing text | paragraph · heading · list item | mark · block · insert |
| inside a table cell | td · th | mark only — a cell holds inline content |
| inside a code block | pre | none — code must stay literal |
| an object is fully selected | image · divider | none — bold on an image means nothing |
The @ menu reads the same table. There is no gap where the toolbar hides something the menu still inserts.
- Omitting the kind means
insert— being visible everywhere is the safe default. - This value governs visibility only. Whether the action actually applies is checked separately (drop cap's
appliesTo, a command'sisAvailable).
styles
The stylesheet this wing carries. One string.
readonly styles?: stringimport { NOTE_SCOPE } from 'nabi-note'
const DIVIDER_STYLES = `
${NOTE_SCOPE} > hr {
margin: 1.1em 0;
border: none;
border-top: 1px solid var(--nabi-border);
}
`
export function horizontalRule(): NabiWing {
return { id: 'block-divider', styles: DIVIDER_STYLES, /* … */ }
}The core sheet holds only what is needed with no wings at all — frame, colour tokens, buttons, floating boxes. Per-format rules travel with each wing. An editor without the heading wing never loads heading CSS.
- Injected after the core sheet on mount, so wings win at equal specificity.
- Identical strings are injected once per document. A family of wings sharing one constant never stacks copies —
heading(1)…heading(6)shareHEADING_STYLES, superscript and subscript shareSUBSUP_STYLES, the three alignments shareALIGN_STYLES. - The sheet is removed when the last editor holding it is destroyed.
- Pick the root by one question — should this rule also apply to
getHtml()output? Yes → prefix withNOTE_SCOPE(document formatting: what stored HTML looks like). No → prefix withEDITOR_SCOPE(editing-screen only: caret hints, ephemeral markup). Boxes your wing draws itself get their own block class instead —.nabi-<name>, parts as.nabi-<name>__<part>(kebab-case), states as.nabi-<name>--<state>. - If you declare your own tokens, declare them on
TOKEN_SCOPE— otherwise stored HTML rendered outside the editor has no values for yourvar(--nabi-…)references. - Colours, radii and shadows go through
var(--nabi-*)tokens. If you declare your own colour token you must reproduce all three blocks — light, dark and explicit light. Miss one and the colour leaks in that theme. - Do not put a backtick inside the string.
A wing sheet is document-wide
So a value like "three lines here, five lines there" must not be a wing option — with two editors in one document the later sheet wins for both and they drift apart silently. Expose values that need to vary per place as CSS variables. That is why drop cap takes its line count from --nabi-dropcap-lines.
Do not rely on browser defaults. If the host ships a reset (say ol, ul { list-style: none }) your bullets and numbers vanish entirely. The rule is that a wing carrying a sheet owns the appearance of its own markup, which is why the list wing declares its markers itself.
Next
- Inline marks — all of
InlineMarkSpec - Blocks and attributes —
BlockSpec·ContentSpec·blockAttribute - UI and actions —
picker·prompt·activate·commands·onClick - Keys, rules, paste —
keys·inputRules· paste · files - setup and flower — the lifecycle hook and the door into core