Data attributes
How the runtime tags rendered DOM so selection can round-trip through it.
A browser Range identifies a position with a (Node, offset) pair. The engine identifies a position with a Pos (UTF-16 offset into the whole document) or a NodePath (indices into children). Neither side can see the other's addressing scheme — and the DOM layout is rebuilt on every commit, so an in-memory map from DOM nodes to document positions would become stale the instant a re-render happens.
The runtime solves that by baking the addressing directly into the rendered DOM. Every rendered element that can be a selection endpoint carries one or more data-spine-* attributes that tell the selection mapper exactly which model location it represents. When the browser reports a selection, the mapper walks up from the anchor/focus Node, reads the nearest attributes, and reconstructs a semantic Selection without ever looking at the DOM's shape.
That design has two consequences:
- The DOM layout is free to change — the mapper only depends on the attributes, not on which element happens to wrap a text run.
- The attributes are a contract. Stripping or renaming them breaks selection mapping; styling should go through CSS classes or the public
data-spine-*hooks that are explicitly stable.
Two namespaces exist. Public attributes are a styling contract intended for host CSS to target. Internal attributes are runtime anchors read by the selection mapper and the overlay; they are not styling API. Both are emitted by renderer.ts; the names are centralized in packages/dom/src/attrs/.
Public attributes
Defined in SpineDomPublicAttributes (packages/dom/src/attrs/public.ts). These appear on the rendered block and mark elements and are safe to select in host CSS.
| Attribute | Where it appears | What it encodes |
|---|---|---|
data-spine-editor | Editor host element | Marks the root of a SpineEditor instance. |
data-spine-root | Document root container | The container whose children are top-level blocks. |
data-spine-readonly | Editor host | Present when the runtime is in read-only mode. |
data-spine-focused | Editor host | Present when the runtime has focus. |
data-spine-node | Every branch element | The node type name (paragraph, heading, bulletList, media, ...). |
data-spine-kind | Branch element | Mirrors node.attrs.kind when the schema uses a secondary discriminator. |
data-spine-level | Heading element | The heading level 1–6, copied from node.attrs.level. |
data-spine-align | Paragraph/heading/list-item | One of left, center, right, justify. |
data-spine-indent | Media and list-item | Integer indent level (>= 1). |
data-spine-selected | Branch element | Present when the engine has a NodeSelection or BlockSelection covering this node. |
data-spine-text-selected | Branch element | Present when a non-collapsed text selection covers the node's text range. |
data-spine-empty | Branch element | Present when the block has zero children (rendered with a zero-width placeholder). |
data-spine-mark | Inline mark wrapper | The mark type name (strong, em, link, fontSize, ...). |
Host CSS may rely on any of these. They are rewritten on every commit, so styles that react to them stay in sync with engine state without the host subscribing to anything.
Internal attributes
Defined in SpineDomInternalAttributes (packages/dom/src/attrs/internal.ts). These are the anchors the selection mapper reads. Host code should not depend on them.
| Attribute | Where it appears | What it encodes |
|---|---|---|
data-spine-internal-node-path | Every branch and text leaf | The NodePath encoded as dot-separated indices ("0.2.1" = [0, 2, 1]). "" is the root. Encoding and decoding live in encodeNodePath / decodeNodePath. |
data-spine-internal-text-leaf | Every rendered text leaf | Marker attribute identifying a text-leaf span. The mapper uses this to distinguish a text node from a structural wrapper. |
data-spine-internal-leaf-start | Text leaf | The leaf's start Pos in the document's UTF-16 coordinate space. |
data-spine-internal-leaf-end | Text leaf | The leaf's end Pos. end - start equals the JS string length of the leaf. |
data-spine-internal-placeholder | Zero-width span in empty blocks | Marker for the \u200b placeholder that gives an empty block something to put a caret inside. |
data-spine-internal-placeholder-pos | Placeholder | The Pos the caret should resolve to when placed inside this placeholder. |
data-spine-internal-atomic-node | Media, horizontal rule | Marks a node that behaves as a single selectable unit — the caret cannot enter it, only select it via NodeSelection. |
data-spine-internal-media | Media element | Discriminates media atoms specifically (used for row grouping and clipboard handling). |
data-spine-internal-row-break | Synthetic <div> between blocks | Inserted by projectRuntimeInterRowBreaks when an inter-row paragraph needs an explicit visual row boundary. See Frontier. |
data-spine-internal-list-marker-* | List / list-item | Tags for the custom list-marker projection (selected state, custom bullet glyph, ordered custom numbering, proxy element for overlay geometry). |
data-spine-internal-selection-overlay | Overlay root | The absolutely-positioned layer that draws the caret and highlight rects. |
data-spine-internal-selection-fragment, -connector, -marker | Overlay children | Individual rects composing the multi-line selection highlight, connectors between rects, and the caret marker. |
Why separate namespaces
Classes are a styling mechanism the host owns. Attributes here are a contract the runtime owns. If a host CSS rule inadvertently removes a data-spine-internal-* attribute (for example by swapping out an element in a DOM mutation observer), selection mapping breaks in subtle ways — clicking inside the affected region no longer resolves to a valid Pos. Keeping styling API on data-spine-* and runtime API on data-spine-internal-* makes the boundary obvious and allows the internal set to change between versions without breaking host CSS.
How selection mapping reads them
When the browser emits a selectionchange, the substrate asks domSelectionToModel to reconstruct a model Selection. The function never trusts the DOM layout directly; it uses the attributes as coordinates.
The core walk is:
-
Atomic short-circuit. If the selection anchor or focus sits inside a
data-spine-internal-atomic-nodeelement, returnnodeSelection(path)using that element'sdata-spine-internal-node-path. Media can only be node-selected, so this disambiguates immediately. -
Contiguous block range. If the range spans entire siblings under the top-level container (or touches a media atom at one endpoint), decode each covered sibling's
data-spine-internal-node-pathand return aBlockSelection(firstPath, lastPath). The mapper usesRange.intersectsNode-style comparisons against each sibling's range to decide which ones are covered. -
Text point mapping. Otherwise, map each endpoint independently with
mapDomPoint:- If the endpoint sits inside a
data-spine-internal-text-leafelement, readdata-spine-internal-leaf-start, compute the offset within the leaf's child text node (offsetWithinLeaf), clamp to[0, end - start], and return{ kind: "text", position: pos(start + offset) }. - If the endpoint sits inside a
data-spine-internal-placeholder, readdata-spine-internal-placeholder-posand return a text position at that offset. - If the endpoint sits on an element boundary with no text leaf, walk child nodes forward then backward looking for the nearest anchor (
findBoundaryAnchor), then — as a last resort — fall back to the first text leaf or media element under the root.
- If the endpoint sits inside a
-
Combine. Anchor and focus collapse into the right
Selectionvariant: twonodepoints under the same parent become aBlockSelection; twotextpoints become aTextSelectioncarryinganchorPathHintandheadPathHint(the enclosing block paths, useful for revalidating the selection after a normalize pass that moves blocks).
The reverse direction (model-to-dom.ts) is the same walk in reverse: given a Pos, find the text leaf whose [leafStart, leafEnd) interval contains it, then compute the browser (Node, offset) inside that leaf's child text node.
Everything that matters for this round-trip is in the attributes. The selection mapper does not cache DOM nodes between commits and it does not require the renderer to preserve DOM identity across re-renders.
Related
- Renderer — produces the attributes.
- Selection mapping — consumes them.
- Selection overlay — uses a parallel set of
selection-*internal attributes for its own geometry.