NABI NOTE
Docs

Writing an inline mark

Filling in inline makes the wing a format laid over characters. Bold, italic, underline, strikethrough, superscript, subscript and link are all built from this one field.

Unlike blocks, marks live anywhere — inside a paragraph, a heading, a table cell, a list item. Declaring as a block something that should be a mark makes the filter pull it out of the paragraph.

ts
interface InlineMarkSpec {
  readonly tag: string
  readonly attributes?: readonly string[]
  readonly escapeKeys?: readonly string[]
  claims(element: Element): boolean
  normalizeAttributes?(element: Element): Record<string, string>
}

tag — the canonical tag

The tag that remains after the filter. What goes out is always this tag.

ts
inline: { tag: 'b', /* … */ }

On the way in both <b> and <strong> are accepted; on the way out they converge.

  • When the meaning is the same, take the shorter oneb not strong, i not em, s not del/strike.
  • Which tags are accepted is decided by claims, not by tag. tag is the output shape.
  • Tags the filter drops by default come back to life once a wing declares them canonical. script, style and form can never be revived by anyone.

Two wings never own the same canonical tag

<a> has exactly one owner: the link wing. Uploads produce <a> too, but uploads declare no markup — they borrow the link wing's vocabulary. If two wings declare the same tag, behaviour flips depending on registration order.


attributes — what survives

The attribute names that survive on the canonical tag. Everything not listed is dropped.

ts
attributes: []                          // bold — keeps nothing
attributes: ['href', 'data-nabi-file']  // link
  • An empty array and omission mean the same thing. Writing the empty array is still preferred — it shows the field was left empty on purpose rather than forgotten.
  • Add class, style or data-* to a bold tag and only the tag survives.
  • Link keeps href and the attachment marker; target, rel, class, title and style all fall away.
  • Some attributes are blocked even if you allow them. Dangerous names such as on* never pass. The allow list is something you widen, not something you route around.

escapeKeys — the keys that turn the mark off

Keys that, while this mark is on, turn only the mark off. Written as KeyboardEvent.key values.

ts
escapeKeys: ['Escape']

Core treats no key as special. Even Escape has to be written here. Without it the only way to turn the mark off is the toolbar button.

  • Only the active marks that declared the key turn off, and the key's own behaviour does not happen.
  • The built-in marks declare Escape only. Enter splits the paragraph and the mark carries over as a pending mark — someone writing in bold keeps writing in bold on the next line.
  • Declaring Enter as an escape key means the first Enter does not split the paragraph, it just clears the mark. From the second Enter on it behaves as usual.

Pending marks

Pressing the button with a collapsed caret pends the mark: it applies from the next character you type. The reverse works too — turning bold off inside <b> does not rewrite the DOM, it moves the next character out of the mark. Core only touches that first character; after that it is plain browser input, because intercepting every keystroke breaks Korean IME composition.


claims — ownership

Looks at an arbitrary element and claims "this one is mine". The rule is in the name — the first to claim takes it.

ts
claims(element: Element): boolean
ts
// bold — tag names only
const tags = new Set(['B', 'STRONG'])
claims: (element) => tags.has(element.tagName)
ts
// link — an <a> whose URL does not pass is not ours (it falls to plain text)
claims: (element) =>
  element.tagName === 'A' && safeUrl(element.getAttribute('href') ?? '') !== null
  • Specs are asked in registration order and the first true takes the element. If nobody claims it, the element is unwrapped and only its text remains.
  • element.tagName is uppercase. Comparing against 'b' never matches.
  • Do not use getComputedStyle. It mistakes inherited values for its own — every normal character inside a bold heading would be claimed as bold. If you must look, look at the inline style only.
  • Even inline style checks are usually pointless: style is stripped wholesale on the way in, so such a check never fires on a real input path. That is why the built-in marks look at tags alone.
  • The predicate must be side-effect free. It can be asked more than once during a scan.

normalizeAttributes — rebuilding attributes

Recomputes the attributes when converting to the canonical tag. Without it the names listed in attributes are copied through as they are.

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

Use it when a value must not be passed through. Link is the example — it never trusts href and always rebuilds it through safeUrl.

ts
normalizeAttributes(element) {
  const href = safeUrl(element.getAttribute('href') ?? '')
  if (!href) return {}          // empty object → the mark is unwrapped to plain text

  const attributes: Record<string, string> = { href }
  // keep the attachment marker only when it is there — an empty value would draw the clip
  if (element.hasAttribute(LINK_FILE_ATTR)) {
    attributes[LINK_FILE_ATTR] = normalizeExtension(element.getAttribute(LINK_FILE_ATTR) ?? '')
  }
  return attributes
}
  • What you return becomes the element's entire attribute set. Names still go through the attributes list — returning an unlisted name drops it.
  • If you cannot build a value, return an empty object. That mark is unwrapped and only the text stays.
  • It is also the place where the representation changes — turning <span style="font-weight:700"> into <b>.

Marks that carry a value

Bold has no value, so a toggle is enough; a link has to carry href too. Do not hand-roll that with Range — the edge cases (partial selection, an already applied mark, the filler in an empty paragraph) would be duplicated once per wing. There is one door.

ts
prompt: {
  kind: 'prompt',
  placeholder: { ko: '주소를 입력하세요', en: 'Enter a URL' },
  confirm: { ko: '링크 걸기', en: 'Add link' },
  validate: (value) => safeUrl(value) !== null,
  run(context, value) {
    const href = safeUrl(value)
    if (!href) return false
    return applyMarkWithValue(context, 'a', { href }, href)
  },
},

applyMarkWithValue(context, tag, attributes, text) handles three cases at once.

SituationWhat happens
text is selectedthe mark is applied to that range
the caret is already inside that marknothing new is created — the value is replaced
there is only a carettext is inserted and the mark applied over it

Value boxes restore the selection from the moment they opened

When a grid or an input opens, the caret moves into that box. Core holds on to the CommandContext and a copy of the selection range from the moment the button was pressed and restores the range just before run. Marks that take a value can rely on this — without it there is no way to know which characters were the target.


State and commands

SurfaceWhat it is
editor.activeMarksthe wing ids currently on (pending + at the caret)
the markschange eventfired when that set changes
editor.toggle(id) · activateMark · deactivateMarkmanipulation

Actions attached to a mark — removing a link, say — are declared with commands, covered in UI and actions.


Easy mistakes

  • Do not make a block out of something that should be a mark. Blocks must be direct children of the root, so markup declared as a block gets pulled out of the paragraph. Link used to be a block, which sent attachments inside table cells out of the table.
  • Do not forget escapeKeys. Core does not treat Escape specially either.
  • Do not use getComputedStyle in claims. Inherited values cause false positives.
  • Before widening attributes, consider normalizeAttributes. If what must survive is the meaning rather than the literal value, rebuilding is safer.