NABI NOTE
Docs

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

ts
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.

ts
// ✅ 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().

ts
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

ts
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.

FieldHow it splits
inline.claims · block.claimsthe first wing to claim takes the element
keysthe first wing to consume takes the key
onClick · pasteText · pasteHtml · handleFilesthe first wing to answer takes it
fromSourceHtmlthe first spec that recognises its own output fixes it
default toolbar layoutwithin 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.

FieldWhat it doesCovered in
idglobally unique identifierthis page
namesper-locale display namethis page
iconwhat the toolbar button drawsthis page
grouptoolbar visibility kindthis page
stylesthe CSS this wing carriesthis page
inlinea mark laid over charactersInline marks
block · blocksa chunk that occupies a paragraph slotBlocks and attributes
blockAttributesame tag, different propertyBlocks and attributes
pickeropens a size grid firstUI and actions
promptopens a one-line input firstUI and actions
activatea fully custom actionUI and actions
commandsextra actions on the context rowUI and actions
onClickclaims a click in the contentUI and actions
keysclaims a single keyKeys, rules, paste
inputRulestransforms triggered by typing aloneKeys, rules, paste
pasteTextone pasted plain-text tokenKeys, rules, paste
pasteHtmla whole pasted HTML fragmentKeys, rules, paste
handleFilestakes incoming filesKeys, rules, paste
prepareCompositionprepares the caret slot before IME compositionKeys, rules, paste
setupeverything declarations cannot expresssetup and flower

id

A globally unique absolute identifier.

ts
readonly id: string

It 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 commands follow 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.

ts
interface WingNames {
  readonly ko: string
  readonly en: string
  readonly [locale: string]: string   // add more languages if you like
}
ts
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 ko and en. Other languages are optional.
  • names.en starts 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, so bold finds the wing even on a Korean screen.
  • Messages with values baked in — upload errors, for instance — use {param} placeholders, which the flower's t() fills.

icon

What the toolbar button draws. An icon is a URL.

ts
readonly icon?: string

Core accepts three shapes and tells them apart itself.

ValueHow 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 stringused as a text label ('H1')
nothingthe first character of the display name

Three helpers freeze SVG into a URL.

ts
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 is 0 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 currentColor is 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.

ts
type WingGroup = 'mark' | 'block' | 'insert'   // defaults to 'insert'
KindMeaningExamples
markformatting laid over charactersbold · link · clear formatting
blockconverting or qualifying the current blockheading · list · align · code · drop cap
insertputting a new chunk inimage · table · YouTube · divider · upload

Which kinds are visible is decided by where the caret is.

WhereWhat that isVisible kinds
writing textparagraph · heading · list itemmark · block · insert
inside a table celltd · thmark only — a cell holds inline content
inside a code blockprenone — code must stay literal
an object is fully selectedimage · dividernone — 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's isAvailable).

styles

The stylesheet this wing carries. One string.

ts
readonly styles?: string
ts
import { 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) share HEADING_STYLES, superscript and subscript share SUBSUP_STYLES, the three alignments share ALIGN_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 with NOTE_SCOPE (document formatting: what stored HTML looks like). No → prefix with EDITOR_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 your var(--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