Skip to Content
SchemaGlobal Attributes

Global Attributes

A global attribute is a reusable bundle of node attributes, DOM serialization and parse rules, applied to a set of node types so paragraph, heading, table, etc. don’t repeat them.

How they work

stesuraSchema resolves the global attributes once, applies each to its bound node types, then constructs the Schema. A global attribute contributes:

  1. Attributes (attrs): new node attributes with defaults.
  2. DOM output (toDOM): extra attributes merged into the node’s own toDOM output. Classes are combined, styles joined, everything else spread.
  3. DOM parsing (getAttrs): merged into every parseDOM rule of the node.
import { stesuraSchema } from "@stesura/core/schema"; const schema = stesuraSchema(); // schema.nodes.paragraph already has id, numbering, indent, spacing, styleId, ...

new Schema({ nodes, marks }) with the raw exports adds none of these attributes. stesuraSchema is the supported entry point (see Bypassing stesuraSchema).

Configuring bindings — the nodes delta

Default bindings are the *_NODES lists exported from @stesura/core/constants. Adjust them through the globalAttributes.nodes option of stesuraSchema, as a delta on the defaults: true grants, false revokes, absent keeps the default. You only declare deviations, so your config stays valid when core defaults change.

const schema = stesuraSchema({ nodes: { callout: calloutSpec }, globalAttributes: { nodes: { spacing: { callout: true }, // grant: callout becomes a spaced block nodeComment: { table: true }, // grant: tables accept node comments numbering: { heading: false }, // revoke: headings lose list numbering }, }, });

The keys are typed (GlobalAttributeKey plus your custom definitions). Invalid bindings never throw: unknown attribute keys, unknown node names, and revocations of bindings that don’t exist are ignored with a console diagnostic.

uniqueId cannot be configured. Pagination requires a stable id on every block node; a delta touching uniqueId is ignored with an error.

Families share an anchor. Runtime gates key on one attribute per family, so a dependent granted without its anchor is ignored by commands and plugins, and logs an error:

AnchorDependents
formattingtextAlign, direction
styleIdblockStyling
trackChangestrackChangesModification

Registering custom global attributes

Register custom bundles under globalAttributes.definitions and bind them with the same nodes delta:

const schema = stesuraSchema({ globalAttributes: { definitions: { audit: { attrs: { audit: { default: null } }, toDOM: (node) => (node.attrs.audit ? { "data-audit": node.attrs.audit } : {}), getAttrs: (dom) => ({ audit: dom.dataset.audit ?? null }), }, }, nodes: { audit: { paragraph: true, heading: true } }, }, });

A custom definition whose key collides with a core attribute is discarded with an error; the core definition wins. Definitions have no default binding: without a nodes delta they apply to nothing.

GlobalAttributesConfig and GlobalAttributeKey are exported from @stesura/core/schema, GlobalAttributeDefinition from @stesura/core/types.

Reserved attribute names

Plugins and commands decide what a node does by checking whether it carries a gate attribute, so these names are reserved:

numbering · pagination · indent · spacing · styleId · proofingLanguage · trackChanges

A node spec or a custom definition that declares one makes stesuraSchema throw a RangeError at construction:

// ✗ throws: "callout" declares the reserved attribute "numbering" stesuraSchema({ nodes: { callout: { group: "block", content: "text*", attrs: { numbering: { default: 0 } } } }, });

Letting it through would either drop your attr (ProseMirror discards attrs absent from the spec, so stored values would vanish on load) or give your node behavior it never asked for: a numbering attr makes the numbering plugin treat it as a list item. Rename the attr.

Only the gate names are reserved. Other injected attrs (borders, bgColor, lineHeight, _borders, …) drive no membership test, so nodes may declare their own; table and table_cell do. Spreading an already-constructed spec back in ({ ...schema.nodes.toc.spec, … }) is also fine: injected attrs are tagged and never mistaken for authored ones.

Runtime gates: ask the schema, not the lists

The *_NODES arrays are defaults consumed at schema construction. A nodes delta can change them, so runtime code must test the constructed schema. @stesura/core/constants exports a predicate per gate (attr presence on the node type), plus a name-set getter memoized per schema:

import { isNumberedType, getSpacedNodeNames } from "@stesura/core/constants"; if (isNumberedType(node.type)) { /* … */ } // membership test const spaced = getSpacedNodeNames(state.schema); // whole set
PredicateName-set getterKeys on
isNumberedTypegetNumberedNodeNamesnumbering
isPaginatedTypegetPaginatedNodeNamespagination
isFormattedTypegetFormattedNodeNamesindent (from formatting)
isSpacedTypegetSpacedNodeNamesspacing
isStyledBlockTypegetStyledBlockNodeNamesstyleId
isProofreadTypegetProofreadNodeNamesproofingLanguage
isBlockTrackChangesTypegetBlockTrackChangeNodeNamestrackChanges

Available Global Attributes

numbering

Default nodes: NUMBERED_NODES (paragraph, heading)

attrs: { numbering: { default: null, // When set (NumberingAttrs in @stesura/core/types): { // listRef, level?, instanceId?, followBy?, isBullet?, // tabStop?, // per-instance override of the level's tabStop // resolvedListRef?, // + resolvedLevel/InstanceId/FollowBy: plugin-stamped caches // } } }

The attr has four states: null inherits (numbered iff the resolved style has a numbering entry); { listRef: "X", … } is direct numbering; { listRef: INHERIT_LIST_REF, … } takes the list from the style with per-field overrides; { listRef: null } is the off-sentinel (the style numbers it, the user turned it off). Never write the resolved* fields. See Numbering for the cascade.

A custom node granted numbering also needs a content expression that accepts a counter_node first child, as paragraph and heading do, or the numbering plugin cannot render its number.

formatting

Default nodes: FORMATTED_NODES (heading, paragraph, image, toc, horizontal_rule)

attrs: { indent: { default: null }, // { start?, end?, special?, by? } — partial; missing fields inherit lineHeight: { default: null }, // multiplier (1.5) or exact "Xpt" height lineHeightRule: { default: null }, // "atLeast": the "Xpt" height is Word's minimum (rendered as exact for now) tabStops: { default: null }, // { pos, align, leader }[]; null inherits the style's, [] means none _lineHeight: { default: null }, // derived: effective line height (spacing plugin) _numberedIndent: { default: null } // derived: indent inherited from the list level/style (numbering plugin) }

Tab stops are laid out by tabStopsPlugin: each tab is sized so the text after it lands on the next stop (its own or its style’s, then every half inch), aligned start, center, end or decimal, with an optional leader (dot, hyphen, underscore). pos is px from the text area’s start edge, as in Word.

textAlign / direction

Default nodes: FORMATTED_NODES

attrs: { textAlign: { default: null }, // "start" (null) | "center" | "end" | "justify" dir: { default: null }, // "ltr" | "rtl" | null (inherit) }

null alignment inherits from the paragraph style.

Text direction resolution

dir resolves through three levels, and only the last always answers:

paragraph.dir ?? section.dir ?? doc.dir

null at the paragraph or section level means inherit, not “ltr”. Read the effective direction with getResolvedDirection(node, state) from @stesura/core/helpers (it resolves inheritance at the current selection). node.attrs.dir sees only an explicit override.

To mount an RTL-by-default editor, set the document default rather than stamping dir on every node:

import { setDocDirection } from "@stesura/core/commands"; setDocDirection("rtl")(view.state, view.dispatch);

The doc attr is dir (default "ltr"), so it can also be seeded in the initial document JSON:

{ "type": "doc", "attrs": { "dir": "rtl" }, "content": [...] }

Paragraphs and sections that declare their own dir keep overriding it; setDirection(null) clears a paragraph’s override. Header, footer and note editors inherit it too, and setDocDirection run from one of them writes to the main document.

DOCX has no document-level direction: import leaves the host’s dir untouched, and export writes w:bidi only for sections and paragraphs that declare their own — an inheriting RTL document opens LTR in Word. Set dir on the sections if the file has to round-trip.

This is the direction of the document content. The editor UI’s direction is separate; see i18n.

spacing

Default nodes: SPACED_NODES (heading, paragraph, code_block, file, image, toc, horizontal_rule, table)

attrs: { spacing: { default: { before: null, after: null } }, // optional `contextual`: Word's "don't add space between paragraphs of the same style" _marginTop: { default: null }, // computed by spacingPlugin _paddingTop: { default: null }, // computed by spacingPlugin _paddingBottom: { default: null }, // computed by spacingPlugin }

spacing is the authored value and is not rendered directly. The spacingPlugin computes the _ attrs: margin collapse, and styling islands (bordered or filled blocks, where padding replaces margin so the border/background stays continuous). Never set them yourself. Whether spacing crosses a wrapper node’s boundary depends on its spacing role.

styleId

Default nodes: STYLED_BLOCK_NODES (paragraph, heading)

attrs: { styleId: { default: "Normal" }, // "Normal", "Heading1", …, "Heading9", or a custom style id }

Rendered as a pm-style-<id> class, targeted by the CSS that styleSheetPlugin writes into a <style> element scoped to the editor. doc.attrs.styleSheet is the definitions map the id points into. See Stylesheets.

blockStyling

Default nodes: STYLED_BLOCK_NODES

attrs: { bgColor: { default: null }, // CSS color borders: { default: null }, // authored borders _borders: { default: null }, // computed by spacingPlugin }

_borders lets borders run continuously across the blocks of a styling island.

pagination

Default nodes: PAGINATED_NODES (paragraph, heading, code_block)

attrs: { pagination: { default: null }, // When set: { keepWithNext?, keepLines?, widowOrphan?, pageBreakBefore? } — each boolean | null }

null fields inherit. keepLines is carried for the DOCX round trip; the pagination engine does not honour it yet.

proofingLanguage

Default nodes: PROOFREAD_NODES (paragraph, heading)

attrs: { proofingLanguage: { default: null } // BCP 47 tag }

The proofreadPlugin checks each block in its effective language (below), and “add to dictionary” files words under it.

null means inherit, not “no language”. The effective language of a block is

paragraph.proofingLanguage ?? style.language ?? doc.language

so an explicit value is stamped only by a user action (the review toolbar’s picker, or the status bar’s language menu) or by DOCX import, and only when the paragraph’s w:lang differs from what it inherits. The middle level is StyleDefinition.language: a paragraph style carries the language of the text it formats, as in Word. Resolve it with resolveLanguageAtPos(state, pos) from @stesura/core/helpers; the raw attr is null for almost every block.

It is not called language because a code block has its own codeLanguage attr, and gates key on attr presence: a shared name would make code blocks look proofreadable.

trackChanges / trackChangesModification

Default nodes: BLOCK_TRACK_CHANGES_NODES (heading, paragraph, table, table_row, table_cell, image, code_block, page_break, toc, horizontal_rule, file, math_display)

attrs: { trackChanges: { default: null }, // { type, id, date, userId, userName, previous? } trackChangesModification: { default: null }, // attr-change snapshot, used by reject }

Block-level suggestions. Text changes use the insertion / deletion / modification marks instead. type is "insertion", "deletion" or "deleteClosure".

nodeComment

Default nodes: none (opt-in per schema)

attrs: { nodeComments: { default: null } // [{ threadId }]; resolved state lives on the thread }

Comment threads attached to a whole node (anchored by its id) rather than a text range. Grant it with globalAttributes: { nodes: { nodeComment: { table: true } } }.

suppressLineNumbers

Default nodes: FORMATTED_NODES

attrs: { suppressLineNumbers: { default: false } }

Excludes a block from the section’s line numbering. As in Word, the block’s lines are skipped by the counter, not merely hidden: the next counted line takes the next number. Toggle it with toggleSuppressLineNumbers.

uniqueId

Nodes: every non-inline node. Not configurable.

attrs: { id: { default: null } // assigned by uniqueIdPlugin }

Ids form one document-wide namespace and are required by pagination, node comments, cross-references and the TOC. createNormalizeTransaction heals them on load; uniqueIdPlugin keeps them unique across edits, which is why removing that plugin is unsupported. Inline nodes that need an id (e.g. image_anchored) declare id in their own spec; the plugin covers any node whose spec declares it.

blockContentClasses

Default nodes: every non-code textblock, including custom ones (inferred from the spec’s shape).

No attributes. Renders classes derived from the block’s content (pm-blank, pm-trailing-break, pm-atom-end, …) so stylesheets don’t need :has() selectors. Rebind it with the nodes delta if the shape inference is wrong for a custom node.

DOM Serialization

To render global attributes outside ProseMirror (e.g. in a React node view), use getGlobalDOMAttributesMap(schema) from @stesura/core/schema: a map from node type name to a function returning that node’s merged global DOM attributes. It includes custom definitions when the schema was built by stesuraSchema.

The individual emitters (returnFormattingStyles, spacingToDom, returnAllBorderStyles) are exported from @stesura/core/helpers.

Next Steps

Last updated on