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.

AttributeWhere it appearsWhat it encodes
data-spine-editorEditor host elementMarks the root of a SpineEditor instance.
data-spine-rootDocument root containerThe container whose children are top-level blocks.
data-spine-readonlyEditor hostPresent when the runtime is in read-only mode.
data-spine-focusedEditor hostPresent when the runtime has focus.
data-spine-nodeEvery branch elementThe node type name (paragraph, heading, bulletList, media, ...).
data-spine-kindBranch elementMirrors node.attrs.kind when the schema uses a secondary discriminator.
data-spine-levelHeading elementThe heading level 1–6, copied from node.attrs.level.
data-spine-alignParagraph/heading/list-itemOne of left, center, right, justify.
data-spine-indentMedia and list-itemInteger indent level (>= 1).
data-spine-selectedBranch elementPresent when the engine has a NodeSelection or BlockSelection covering this node.
data-spine-text-selectedBranch elementPresent when a non-collapsed text selection covers the node's text range.
data-spine-emptyBranch elementPresent when the block has zero children (rendered with a zero-width placeholder).
data-spine-markInline mark wrapperThe 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.

AttributeWhere it appearsWhat it encodes
data-spine-internal-node-pathEvery branch and text leafThe 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-leafEvery rendered text leafMarker attribute identifying a text-leaf span. The mapper uses this to distinguish a text node from a structural wrapper.
data-spine-internal-leaf-startText leafThe leaf's start Pos in the document's UTF-16 coordinate space.
data-spine-internal-leaf-endText leafThe leaf's end Pos. end - start equals the JS string length of the leaf.
data-spine-internal-placeholderZero-width span in empty blocksMarker for the \u200b placeholder that gives an empty block something to put a caret inside.
data-spine-internal-placeholder-posPlaceholderThe Pos the caret should resolve to when placed inside this placeholder.
data-spine-internal-atomic-nodeMedia, horizontal ruleMarks a node that behaves as a single selectable unit — the caret cannot enter it, only select it via NodeSelection.
data-spine-internal-mediaMedia elementDiscriminates media atoms specifically (used for row grouping and clipboard handling).
data-spine-internal-row-breakSynthetic <div> between blocksInserted by projectRuntimeInterRowBreaks when an inter-row paragraph needs an explicit visual row boundary. See Frontier.
data-spine-internal-list-marker-*List / list-itemTags for the custom list-marker projection (selected state, custom bullet glyph, ordered custom numbering, proxy element for overlay geometry).
data-spine-internal-selection-overlayOverlay rootThe absolutely-positioned layer that draws the caret and highlight rects.
data-spine-internal-selection-fragment, -connector, -markerOverlay childrenIndividual 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:

  1. Atomic short-circuit. If the selection anchor or focus sits inside a data-spine-internal-atomic-node element, return nodeSelection(path) using that element's data-spine-internal-node-path. Media can only be node-selected, so this disambiguates immediately.

  2. 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-path and return a BlockSelection(firstPath, lastPath). The mapper uses Range.intersectsNode-style comparisons against each sibling's range to decide which ones are covered.

  3. Text point mapping. Otherwise, map each endpoint independently with mapDomPoint:

    • If the endpoint sits inside a data-spine-internal-text-leaf element, read data-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, read data-spine-internal-placeholder-pos and 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.
  4. Combine. Anchor and focus collapse into the right Selection variant: two node points under the same parent become a BlockSelection; two text points become a TextSelection carrying anchorPathHint and headPathHint (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.