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.
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.
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.
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,styleandformcan 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.
// checklist — keeps the marker attribute and sets it on creation
attributes: ['data-nabi-list'],
defaults: { 'data-nabi-list': 'task' },// 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". defaultsfills values in; it does not validate. Cleaning up incoming values isnormalizeAttributes's job.- Some attributes are blocked by the filter even if the wing allows them (
on*and friends).
claims — ownership
claims(element: Element): booleanSpecs 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.
claims: (element) => element.tagName === 'BLOCKQUOTE'// image — the tag alone is not enough, the URL has to pass
claims: (element) =>
element.tagName === 'IMG' && safeUrl(element.getAttribute('src') ?? '') !== null// YouTube — not the tag at all, but "can I extract a video id"
claims: (element) => videoIdOf(element) !== nullSharing 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.
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_VALUEDone this way, neither steals from the other regardless of registration order.
The declared
attributessets 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'.
| Value | Meaning | Examples |
|---|---|---|
'inline' | inline content (characters and marks), so the caret lives between them | paragraph · heading · list item |
'object' | the whole block is one object | image · 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".
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-selectedand 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/Backspaceon a fully selected object swaps it for an empty paragraph. - Assistive announcement — since the caret hides, an
aria-liveregion 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.
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
findAdjacentBlockcarries that rule.
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.
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.
// list — one level
content: { tag: 'li', attributes: [] }// table — three levels
content: {
tag: 'tbody',
unique: true,
content: {
tag: 'tr',
rectangular: true,
content: { tag: 'td', alternates: ['th'], attributes: [] },
},
}| Field | What it does |
|---|---|
tag | the canonical tag at this level; other tags are converted to it |
alternates | other tags allowed at the same level — a table's th, a fold box's summary |
attributes | which attributes survive on this tag |
defaults | what to set when creating (a checklist item's data-nabi-checked="false") |
unique | merges same-tag siblings into the first one — for thead/tbody arriving split |
rectangular | equalises the child count across siblings, padding the short ones |
content | one 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.
nestable: true- It is real markup —
<ul><li>item<ul><li>child</li></ul></li></ul>, and the filter keeps that nesting. Tabmoves an item under its previous sibling,Shift+Tabmoves it after its parent, and outdenting at the top level produces a paragraph. But the wing claims those keys — core knows nothing aboutTab(you wireindentListItemsthroughkeys).- 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.
caretEntry?(block: HTMLElement, back: boolean): HTMLElement | nullThe caret lands at the start of the returned element (at the end if back).
// 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
}// code — the block itself. Whoever comes here usually wants to edit the code
caretEntry: (block) => block// 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
nullon purpose. - Core does not infer this from other fields.
contentandemptysay "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
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.
// 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) : {}
}// fold box — open="true" comes in, open="" is all that stays
normalizeAttributes(element) {
return element.hasAttribute('open') ? { open: '' } : {}
}// 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().
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>.
// 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) returnabove).
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.
| Frozen | Not frozen |
|---|---|
| alignment · image width · checked state · code colours | heading 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.
fromSourceHtml?(element: HTMLElement): booleanRecognise your own output, fix it to soul, and return true — the rest are not asked.
// 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 askingclaimsdirectly lets the wrong spec take it — aulwhose 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.
interface BlockAttributeSpec {
readonly attribute: string
readonly value: string
readonly appliesTo?: readonly string[]
toOutputHtml?(element: HTMLElement): void
fromSourceHtml?(element: HTMLElement): boolean
}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
attributeare mutually exclusive. Turning centre on turns right off. The three alignment wings share the singledata-nabi-alignslot. - 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.
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.
toOutputHtmlis called for every block carrying this attribute with this value. After it returns, core removes the attribute — you do not remove it yourself.fromSourceHtmlis called while the attribute is still absent. Returningtruemakes core attach it.
// 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.
| Rule | If 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 them | the filter unwraps the shell and keeps the content, so those words leak |
| ③ | Do not call commit() until it is over | the 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.blockwhen finding anemptyblock. The caret cannot enter it, so check the preceding sibling too (findAdjacentBlock). toOutputHtmlmust be idempotent. Put the "already converted" bail-out on the first line.- Never parse strings by hand in
fromSourceHtml. Always readelement.style. - Do not put object blocks in leaf cells. The filter pulls them out.
- Do not think
caretEntrycan be replaced bycontent/empty. They merely overlap. - Be careful claiming
Enterat 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 claimingEnterthroughkeysmust 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 makesEnterstack paragraphs above forever.