NABI NOTE
Docs

Blocks and block attributes

A chunk that occupies a paragraph slot is declared with block (one) or blocks (several). Something that changes only a block's property, not its tag, is a blockAttribute.

ts
interface BlockSpec {
  readonly tag: string
  readonly attributes?: readonly string[]
  readonly defaults?: Readonly<Record<string, string>>
  claims(element: Element): boolean
  readonly content?: ContentSpec
  readonly nestable?: boolean
  readonly empty?: boolean
  readonly holds?: 'inline' | 'object'
  caretEntry?(block: HTMLElement, back: boolean): HTMLElement | null
  normalizeAttributes?(element: Element): Record<string, string>
  toOutputHtml?(element: HTMLElement): void
  fromSourceHtml?(element: HTMLElement): boolean
}

The shortest block is this much.

ts
export function heading(level: 1 | 2 | 3 | 4 | 5 | 6): NabiWing {
  const tag = `h${level}`
  const upper = tag.toUpperCase()

  return {
    id: `block-heading-${level}`,
    group: 'block',
    names: { ko: `제목 ${level}`, en: `Heading ${level}` },
    icon: `H${level}`,
    block: {
      tag,
      attributes: [],
      claims: (element) => element.tagName === upper,
    },
  }
}

block and blocks

block is the convenience form for a single spec; a wing that owns several pieces of markup uses the blocks array. Scanning asks block first, then blocks.

ts
block: spec                 // = blocks: [spec]
blocks: [imageSpec, cardSpec]

The first spec represents the wing when toggling, and core resolves which spec actually claimed an element with scanBlockSpec().


tag — the canonical tag

Output is always this tag. h1 · ul · table · pre · hr · img · iframe · details are the tags the bundled wings own.

  • Tags the filter drops by default come back once declared canonical — that is how the YouTube wing revives iframe. script, style and form can never be revived.
  • A tag starting with nabi- cannot be canonical. That prefix is reserved for markup that exists only while editing (see below).

attributes and defaults

attributes lists the names that survive; defaults lists what to fill in when creating.

ts
// checklist — keeps the marker attribute and sets it on creation
attributes: ['data-nabi-list'],
defaults: { 'data-nabi-list': 'task' },
ts
// YouTube — only these names survive; the values are rebuilt by normalizeAttributes
attributes: ['data-nabi-embed', 'data-nabi-video', 'src', 'title', 'allowfullscreen', 'loading'],
  • Anything not listed is dropped. attributes: [] means "keep the tag only".
  • defaults fills values in; it does not validate. Cleaning up incoming values is normalizeAttributes's job.
  • Some attributes are blocked by the filter even if the wing allows them (on* and friends).

claims — ownership

ts
claims(element: Element): boolean

Specs are asked in registration order and the first to answer true takes the element. If nobody takes it, the element is unwrapped and its content settles into a paragraph.

ts
claims: (element) => element.tagName === 'BLOCKQUOTE'
ts
// image — the tag alone is not enough, the URL has to pass
claims: (element) =>
  element.tagName === 'IMG' && safeUrl(element.getAttribute('src') ?? '') !== null
ts
// YouTube — not the tag at all, but "can I extract a video id"
claims: (element) => videoIdOf(element) !== null

Sharing one tag

The rule is one owner per canonical tag. Sharing split by an attribute is legitimate, though — a bullet list (ul) and a checklist (ul[data-nabi-list="task"]). Two conditions then apply.

  1. The predicates must be mutually exclusive. No element may be claimed by both.

    ts
    // bullet — only a ul **without** the marker
    claims: (element) => element.tagName === 'UL' && !element.hasAttribute(TASK_FLAG)
    // checklist — only a ul **with** the marker
    claims: (element) => element.tagName === 'UL' && element.getAttribute(TASK_FLAG) === TASK_VALUE

    Done this way, neither steals from the other regardless of registration order.

  2. The declared attributes sets must differ. Only the tag and the allowed attributes survive in the output, so if the attributes match too there is no way to tell which spec it was when the stored HTML is read back.

Breaking the second condition makes core warn at registration time. The first cannot be checked — a predicate is an arbitrary function, so overlap is undecidable. Keeping it is the wing author's job.


holds — inline block or object block

What the block holds. The default is 'inline'.

ValueMeaningExamples
'inline'inline content (characters and marks), so the caret lives between themparagraph · heading · list item
'object'the whole block is one objectimage · divider · code · table · fold box

NabiWing.inline is a mark spec; this is a block's content model. The names overlap because both refer to "inline content".

ts
block: {
  tag: 'pre',
  holds: 'object',   // editable inside, an object at its boundary
  /* … */
}

empty: true implies 'object' without writing it. Some objects are edited inside — code and tables. Step in and you write as usual; only at the boundary are they treated as objects.

What core does for object blocks

Common behaviour a wing never has to implement.

  • Selection marker and hidden caret — a fully selected object gets data-nabi-selected and the caret hides. Objects with no interior are selected wholesale by core on click; the wing does not do it.
  • A faint border when the caret is inside (data-nabi-active) shows "I am writing inside this box". It is mutually exclusive with the solid border of a whole-object selection.
  • Arrow keys in and out — they move to the neighbouring line and create an empty paragraph when there is no neighbour. That is how you write above a document whose first line is an image, code block or table.
  • Escape commands — when the caret is inside an editable object, core itself puts "exit up" and "exit down" on the context row. Soft keyboards have no arrow keys, so without this a code box that is both the start and end of the document traps you. Core provides it so a third-party wing that forgets cannot recreate the trap.
  • The toolbar and menu withdraw — while an object is selected the toolbar hides entirely and the @ menu does not open.
  • Typing and deleting — typing never overwrites the object; a new paragraph is created instead. Delete/Backspace on a fully selected object swaps it for an empty paragraph.
  • Assistive announcement — since the caret hides, an aria-live region announces "image selected".

All of these markers are editor-only and never reach the output.


empty — blocks with no content

hr, images, embeds — blocks nothing goes inside.

ts
block: { tag: 'hr', attributes: [], empty: true, claims: (el) => el.tagName === 'HR' }
  • contenteditable="false" is applied while editing, so the caret cannot enter. It is not an allowed attribute, so it never reaches the output.
  • Because the caret cannot enter, a command looking for such a block must check the block itself and its immediately preceding sibling. The flower's findAdjacentBlock carries that rule.
ts
commands: [
  deleteBlockCommand({
    id: 'youtube-delete',
    names: { ko: '영상 삭제', en: 'Delete video' },
    find: (context) => findAdjacentBlock(context, isEmbed),
  }),
],

content — inner structure (ContentSpec)

The recursive structure of blocks that have children, like lists and tables.

ts
interface ContentSpec {
  readonly tag: string
  readonly alternates?: readonly string[]
  readonly attributes?: readonly string[]
  readonly defaults?: Readonly<Record<string, string>>
  readonly unique?: boolean
  readonly rectangular?: boolean
  readonly content?: ContentSpec
}

The level without a content is the leaf cell — where text is actually written.

ts
// list — one level
content: { tag: 'li', attributes: [] }
ts
// table — three levels
content: {
  tag: 'tbody',
  unique: true,
  content: {
    tag: 'tr',
    rectangular: true,
    content: { tag: 'td', alternates: ['th'], attributes: [] },
  },
}
FieldWhat it does
tagthe canonical tag at this level; other tags are converted to it
alternatesother tags allowed at the same level — a table's th, a fold box's summary
attributeswhich attributes survive on this tag
defaultswhat to set when creating (a checklist item's data-nabi-checked="false")
uniquemerges same-tag siblings into the first one — for thead/tbody arriving split
rectangularequalises the child count across siblings, padding the short ones
contentone level deeper; without it this is the leaf cell

rectangular exists because tables pasted from elsewhere do not arrive intact. colspan and rowspan are not allowed attributes and get stripped, and some sites vary the cell count per row to begin with. It only ever pads — cells with text are never removed, and every row is brought up to the longest one.

A leaf cell holds inline content only

Put an object block — an image, a divider — inside a table cell (td) and the filter pulls it out of the table (nothing is deleted, so no content is lost and the grid stays intact). List items are the one exception: they hold sub-lists, which is what nestable is.

How many levels deep the leaf sits also decides what Enter means. One level toggles with a paragraph; two or more and Enter only breaks the line.


nestable — list nesting

Whether a container may appear again inside a leaf item.

ts
nestable: true
  • It is real markup — <ul><li>item<ul><li>child</li></ul></li></ul>, and the filter keeps that nesting.
  • Tab moves an item under its previous sibling, Shift+Tab moves it after its parent, and outdenting at the top level produces a paragraph. But the wing claims those keys — core knows nothing about Tab (you wire indentListItems through keys).
  • Bullet, numbered and checklist all nest. Checked state is a per-item attribute, so it survives nesting.
  • Do not use it for multi-level structures (content.content) like tables.

caretEntry — when the caret arrives from outside

Decides where the caret goes when an arrow key reaches a neighbouring object or the caret pushes into an object from the gap.

ts
caretEntry?(block: HTMLElement, back: boolean): HTMLElement | null

The caret lands at the start of the returned element (at the end if back).

ts
// table — into the first (or last) leaf cell
caretEntry: (block, back) => {
  const cells = block.querySelectorAll<HTMLElement>('td, th')
  return (back ? cells[cells.length - 1] : cells[0]) ?? null
}
ts
// code — the block itself. Whoever comes here usually wants to edit the code
caretEntry: (block) => block
ts
// fold box — the summary when collapsed. Collapsed content is off screen,
// so sending the caret there makes it look like it vanished
caretEntry: (block, back) => {
  const summary = summaryOf(block)
  if (!block.hasAttribute('open')) return summary
  const cells = [...block.children] as HTMLElement[]
  return (back ? cells[cells.length - 1] : cells[0]) ?? summary
}
  • Not declaring it, or returning null, selects the object wholesale. That is the default for images, dividers and embeds.
  • Objects you can write inside but which are better selected — an embed card you delete far more often than you edit — return null on purpose.
  • Core does not infer this from other fields. content and empty say "which tags go inside" and "holds no content"; they do not say where the caret enters. The meanings merely overlap, and they diverge for a read-only table or a card whose entry point is its caption.

normalizeAttributes — rebuilding attributes

ts
normalizeAttributes?(element: Element): Record<string, string>

For values that must be rebuilt rather than passed through. Without it the names listed in attributes are copied as they are.

ts
// YouTube — the incoming src is not used; only the 11-character video id is extracted
// and the URL reassembled. Whatever arrives in src, the output shape is always the same.
normalizeAttributes(element) {
  const id = videoIdOf(element)
  return id ? embedAttributes(id) : {}
}
ts
// fold box — open="true" comes in, open="" is all that stays
normalizeAttributes(element) {
  return element.hasAttribute('open') ? { open: '' } : {}
}
ts
// image — wherever the <img> came from, its shape is filled in here
normalizeAttributes(element) {
  const source = safeUrl(element.getAttribute('src') ?? '')
  if (!source) return {}
  return {
    src: source,
    alt: element.getAttribute('alt') ?? '',
    'data-nabi-width': String(widthOf(element)),
    'data-nabi-align': alignOf(element),
  }
}

Return an empty object and the block does not survive — the right answer when the URL cannot be trusted.


toOutputHtml — putting the outer layer on

Moves soul to outputHtml. Called just before getHtml().

ts
toOutputHtml?(element: HTMLElement): void

"What does my markup look like out in the world" is known only to the wing. Image width goes out as style="max-width:…", checked state as <input type="checkbox" disabled>.

ts
// image — freeze the width inline and drop our own attribute
toOutputHtml(element) {
  const width = element.getAttribute('data-nabi-width')
  if (width === null) return          // already in output form — exporting twice must be identical
  element.setAttribute(
    'style',
    `display:block;height:auto;max-width:${widthOf(element)}%;` + (element.getAttribute('style') ?? ''),
  )
  element.removeAttribute('data-nabi-width')
}
  • Edit the element in place. Nothing is returned.
  • Once a soul attribute has been moved into outputHtml, remove it yourself.
  • Nested containers need no recursion — core visits every element once.
  • Running it twice must give the same result. Recognise the already-converted shape and bail out on the first line (the if (width === null) return above).

How self-contained the output is, is the wing's call

The test is "does the meaning change without it?" A document whose centring is lost is a different document; a table without borders is still a table.

FrozenNot frozen
alignment · image width · checked state · code coloursheading size · table borders · divider style · sub/superscript size · attachment clip

Some fields have no outer layer at all. Drop cap (data-nabi-dropcap), code language (data-nabi-lang) and the attachment marker (data-nabi-file) are markers, not appearance, so they stay in the stored value as-is. Round-tripping is then lossless for free.


fromSourceHtml — taking the outer layer off

Turns outputHtml back into soul. Called before the filter, for every incoming element, on each spec that declares it, in registration order.

ts
fromSourceHtml?(element: HTMLElement): boolean

Recognise your own output, fix it to soul, and return true — the rest are not asked.

ts
// checklist — recognise "a list whose items start with a checkbox"
fromSourceHtml(element) {
  if (element.tagName !== 'UL' || element.hasAttribute(TASK_FLAG)) return false

  const items = [...element.children].filter((child) => child.tagName === 'LI')
  const boxOf = (item: Element) => {
    const first = item.firstElementChild
    return first?.tagName === 'INPUT' && first.getAttribute('type') === 'checkbox' ? first : null
  }
  if (items.length === 0 || !items.some((item) => boxOf(item) !== null)) return false

  element.setAttribute(TASK_FLAG, TASK_VALUE)
  for (const item of items) {
    const box = boxOf(item)
    item.setAttribute(TASK_CHECKED_ATTR, String(box?.hasAttribute('checked') ?? false))
    box?.remove()
  }
  return true
}
  • There is a reason this is separate from claims. outputHtml is not the canonical form, so asking claims directly lets the wrong spec take it — a ul whose checklist marker was stripped would be taken by the bullet list.
  • Read values through the CSSOM (element.style). Parsing the raw string yourself opens the door to parser-difference attacks.
  • Anything this hook misses is stripped by the filter that runs after it.
  • It is also used to repair incoming values. The fold box inserts an empty <summary> into a foreign <details> that has none — without it there is nothing to click to fold.

blockAttribute — same tag, different property

Alignment is not a new kind of block. It is a marker that can attach to a paragraph, a heading or an image alike, so it is handled as an attribute rather than a tag.

ts
interface BlockAttributeSpec {
  readonly attribute: string
  readonly value: string
  readonly appliesTo?: readonly string[]
  toOutputHtml?(element: HTMLElement): void
  fromSourceHtml?(element: HTMLElement): boolean
}
ts
blockAttribute: {
  attribute: 'data-nabi-align',
  value: 'center',
  toOutputHtml: (element) => appendStyle(element, 'text-align:center;'),
  fromSourceHtml: (element) => element.style.textAlign === 'center',
}

attribute and value

  • Wings sharing an attribute are mutually exclusive. Turning centre on turns right off. The three alignment wings share the single data-nabi-align slot.
  • Pressing the value that is already on removes the attribute entirely.
  • The filter passes registered values only. Both the name and the value must be registered, so data-nabi-align="javascript:…" never survives.
  • Even for a default, declare an explicit value. Left alignment does — without a value there is no "back to left" to press, and the alignment is lost from the stored document.

appliesTo

The block tags this attribute may attach to. Leave it out for every block.

ts
appliesTo: ['p']   // drop cap — root-level paragraphs only
  • Button visibility reads this field too. With the caret in a heading or a list, the button hides in the toolbar and in the @ menu. No "visible but does nothing" slot appears.
  • Leaf cells (li, td) never keep block attributes at all — the filter keeps only what that cell's spec allows.

toOutputHtml and fromSourceHtml

Same rules as the block spec hooks of the same name, but they fire at different moments.

  • toOutputHtml is called for every block carrying this attribute with this value. After it returns, core removes the attribute — you do not remove it yourself.
  • fromSourceHtml is called while the attribute is still absent. Returning true makes core attach it.
ts
// alignment — text blocks freeze as text-align, object blocks as margins
toOutputHtml: (element) => {
  if (MOVED.has(element.tagName)) appendStyle(element, MOVED_MARGINS[value])
  else appendStyle(element, `text-align:${value};`)
},
fromSourceHtml: (element) => {
  if (MOVED.has(element.tagName)) {
    const { marginLeft, marginRight } = element.style
    if (value === 'center') return marginLeft === 'auto' && marginRight === 'auto'
    /* … */
  }
  return element.style.textAlign === value
},

One attribute is translated in one place. That is why the image spec freezes only the width and leaves alignment alone — the remaining data-nabi-align is turned into margins by the alignment wing.

Choosing to have no outer layer

Drop cap declares neither hook. It is a marker rather than appearance, so the attribute goes out and comes back untouched. The call was that the exported value does not need to look like a drop cap in another editor.


Markup that lives only while editing

Markup that must exist on screen but never in the output — an upload placeholder — has four rules. Miss one and the temporary markup leaks into the document.

RuleIf broken
Use a nabi- prefixed tag (EPHEMERAL_PREFIX)an ordinary tag becomes a registered block and sails through the filter
No text nodes. Put the words in an attribute and let CSS draw themthe filter unwraps the shell and keeps the content, so those words leak
Do not call commit() until it is overthe placeholder shows up in undo snapshots and in onChange
Remove it before releasing the lock (on success, on failure and on teardown alike)the leftover box disappears silently at the next filter pass

Get ① right and core backs up the rest — the filter strips prefixed tags anywhere, normalisation never wraps them in a paragraph, and a console warning fires if one is still alive when the last lock releases.

The exception to ② is element children. A preview <img> is fine — it is not text, so unwrapping the shell leaves no words behind.


Easy mistakes

  • Do not look only at context.block when finding an empty block. The caret cannot enter it, so check the preceding sibling too (findAdjacentBlock).
  • toOutputHtml must be idempotent. Put the "already converted" bail-out on the first line.
  • Never parse strings by hand in fromSourceHtml. Always read element.style.
  • Do not put object blocks in leaf cells. The filter pulls them out.
  • Do not think caretEntry can be replaced by content/empty. They merely overlap.
  • Be careful claiming Enter at the start of a block. Core does not split there, it pushes — an empty paragraph appears above and the block goes down intact. A wing claiming Enter through keys must hand it back at the start. Except for empty blocks — in an empty block the caret is always at the start, so handing it back makes Enter stack paragraphs above forever.