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.
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.
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 one —
bnotstrong,inotem,snotdel/strike. - Which tags are accepted is decided by
claims, not bytag.tagis the output shape. - Tags the filter drops by default come back to life once a wing declares them canonical.
script,styleandformcan 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.
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,styleordata-*to a bold tag and only the tag survives. - Link keeps
hrefand the attachment marker;target,rel,class,titleandstyleall 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.
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
Escapeonly.Entersplits the paragraph and the mark carries over as a pending mark — someone writing in bold keeps writing in bold on the next line. - Declaring
Enteras an escape key means the firstEnterdoes not split the paragraph, it just clears the mark. From the secondEnteron 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.
claims(element: Element): boolean// bold — tag names only
const tags = new Set(['B', 'STRONG'])
claims: (element) => tags.has(element.tagName)// 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
truetakes the element. If nobody claims it, the element is unwrapped and only its text remains. element.tagNameis 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 inlinestyleonly. - Even inline
stylechecks are usually pointless:styleis 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.
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.
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
attributeslist — 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.
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.
| Situation | What happens |
|---|---|
| text is selected | the mark is applied to that range |
| the caret is already inside that mark | nothing new is created — the value is replaced |
| there is only a caret | text 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
| Surface | What it is |
|---|---|
editor.activeMarks | the wing ids currently on (pending + at the caret) |
the markschange event | fired when that set changes |
editor.toggle(id) · activateMark · deactivateMark | manipulation |
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 treatEscapespecially either. - Do not use
getComputedStyleinclaims. Inherited values cause false positives. - Before widening
attributes, considernormalizeAttributes. If what must survive is the meaning rather than the literal value, rebuilding is safer.