Numbering
Lists and multi-level numbering. A numbered paragraph stores no counter text of
its own. numberingPlugin (part of customPlugins, opt out with
numberingPlugin: false) walks the document on each change, computes every
counter, and keeps a counter_node in sync as the paragraph’s first child.
Two things are stored separately:
- Definitions:
doc.attrs.listConfigs, a map oflistRef → level configs(format,text,start,suffix,tabStop,indent,gender,casing). These are document-global. - Counters: derived per story and never stored. See Regions.
Resolution merges the built-in default lists (Default1, Default2,
DefaultBullet) under the doc’s own entries. A doc entry under a default ref
shadows the built-in, so a document can redefine a default. A DOCX import
never shadows one: a definition matching a built-in maps onto its ref, any
other keeps its own. removeListConfig
deletes the shadow and brings back the built-in.
Typing a prefix and a space at the start of an unnumbered paragraph starts a
list: 1., 1), i., I., a), A. and similar for numbers, and *, -
or • for bullets. An immediate Backspace restores the typed text.
Where a paragraph’s numbering comes from
Numbering resolves through a cascade. The numbering node attr can hold four
states, and null means inherit, not “unnumbered”:
numbering attr | Meaning |
|---|---|
null (default) | Inherit: numbered only if the resolved style has a numbering entry |
{ listRef: "X", … } | Direct numbering: the paragraph owns its list |
{ listRef: "__inherit", …overrides } | List from the style; the other fields override it for this paragraph |
{ listRef: null } | Off-sentinel: the style numbers the paragraph, but the user turned it off |
"__inherit" is the exported constant INHERIT_LIST_REF (from
@stesura/core/constants). It is reserved and can never be a key of
listConfigs: saveListConfigToDocAttrs, setStyleNumberingBinding and the
doc guards all reject it.
Anything that decides what to render, export or enable must read the effective numbering, not the raw attr:
import { getEffectiveNumbering, getEffectiveNumberingAtSelection } from "@stesura/core/helpers";
const effective = getEffectiveNumbering(state, node);
// → { listRef, level, instanceId, followBy?, tabStop?, fromStyle } | null
// listRef is always a real listConfigs key — never "__inherit".
getEffectiveNumberingAtSelection(state); // same, for the block at the selectionnodeIsNumbered(attrs) answers at the attr level only. It is true for any
listRef, including "__inherit", and false for null and the off-sentinel.
So a style-numbered paragraph with a null attr counts as unnumbered until the
plugin stamps it. numberingListRef(attrs.numbering) gives the real list behind
a stamped attr.
Style-linked numbering
A paragraph style can carry a numbering binding, as in Word’s legal templates.
Paragraphs of that style number themselves with no per-node state, and a whole
style family (Definitions L.1 … L.9) shares one listRef at different levels.
// doc.attrs.styleSheet
"Definitions-L1": {
name: "Definitions L.1",
basedOn: "Normal",
paragraphStyle: { numbering: { listRef: "legal", level: 1 } }, // optional followBy, tabStop
}- The binding inherits through
basedOnlike every otherparagraphStylefield, as a whole object (no field-level merge). - An explicit
nullresets a style that is based on a numbered style back to unnumbered. - The reverse map (
listRef → level → styleId) is derived, never stored separately. At most one style may bind a given(listRef, level).
import { setStyleNumberingBinding } from "@stesura/core/commands";
import { getResolvedStyleSheet, getStyleForListLevel } from "@stesura/core/helpers";
// Bind. Any style already holding the slot is unbound for you.
setStyleNumberingBinding("Definitions-L1", { listRef: "legal", level: 1 })(state, dispatch);
// Unbind.
setStyleNumberingBinding("Definitions-L1", null)(state, dispatch);
// Which style owns a level, if any.
getStyleForListLevel(getResolvedStyleSheet(state), "legal", 2);Because the level is bound to a style, promote/demote becomes a style switch.
adjustLevel on a style-numbered paragraph applies the style bound to the target
level and resets numbering to null. It only writes a level override when no
style is bound to the target level. Turning numbering on next to a
style-numbered list works the same way: the paragraph adopts the neighbour’s
style.
What the plugin stamps
The plugin is the only writer of the derived numbering attrs:
- Direct paragraphs get
{ listRef, level, instanceId, followBy, isBullet }(plus the user’stabStop, if set). - Style-numbered paragraphs keep
listRef: "__inherit"and get the derived values underresolvedListRef,resolvedLevel,resolvedInstanceIdandresolvedFollowBy, plusisBullet. Their plainlevel,instanceId,followByandtabStopslots hold user overrides only and are kept as they are.resolveEffectiveNumberingignores theresolved*values, so an edit to the binding always re-derives. Serialization and export read them as display values.
The plugin also heals: when a style stops numbering, a dangling "__inherit"
and a now-meaningless off-sentinel both revert to null.
Never write the derived attrs from a style-apply command, paste handler or importer. Set the style (or the direct list) and let the walk fill them in.
Commands
All of these come from @stesura/core/commands and act on the selection:
| Command | Effect |
|---|---|
toggleCounter({ buttonType }) / toggleCounter({ levelFormat }) | The list button ("number" / "bullet") or a format pick. Pass exactly one. Toggles off, switches type, or continues an adjacent list. |
applyListRef(listRef) | Apply a list as direct numbering. |
adjustLevel({ direction }) / adjustLevel({ level }) | "up" goes one level deeper and "down" one level shallower. "down" at level 1 removes the numbering. Clamped to the deepest level the list defines. |
restartNumbering / continueNumbering | Start a new run at the selected level-1 item, or rejoin the previous run of the same list. |
removeNumbering | Clear the numbering and its counter node. |
setNumberingAttrs(partial) | Merge attrs into every node of the selected instance. |
setFollowBy(followBy) / setListTabStop(px | null) | Write the level config (see cascades). |
updateNumbering({ listRef, instanceId, levels }) | Save edited levels and move the instance onto them. The ref is reused when an identical config exists, and forked when the old one is a default or shared. |
saveListConfigToDocAttrs(ref, levels) / removeListConfig(ref) | Write or delete a listConfigs entry directly. |
cleanupUnusedListRefs | Drop configs that no node or style references. |
Regions
Word evaluates numbering per story. Definitions are document-global and counters are not. The walk starts a new region at each container boundary:
| Region key | Story |
|---|---|
body | The body, across all sections |
footnotes | All footnote bodies together |
endnotes | All endnote bodies together |
hf:<sectionIndex>:<header|footer>:<variant> | One header/footer part |
The header/footer key uses the section’s 0-based index in the document, not
its id attr (headerFooterRegion(sectionIndex, kind, variant)). The region
constants and helpers are exported from @stesura/core/plugins.
A numbered list in a footnote therefore restarts at 1 instead of continuing the
body’s count, even when both use the same listRef. Style-derived instances
are scoped to a region too (styleNumberingInstanceId(listRef, region), which
gives style:<listRef>:<region>), so a style family is one continuous list
per story.
Instances and adjacency
An instance is a run of a list, not a list. Word’s rule is adjacency: a restart resets the definition’s counter, and everything after it on that list continues from the restart, whatever sits in between.
The walk tracks the run that is currently open for each (region, listRef). A
numbered paragraph’s instance is, in order:
- its own explicit
instanceId(the direct slot, or the override slot on"__inherit"), which is what a restart writes; - else the open run the paragraph sits in;
- else
styleNumberingInstanceId(listRef, region)when a style binds the list; - else a fresh id.
That instance then becomes the open run. Paragraphs of other lists and unnumbered paragraphs don’t break it. A region boundary does.
So restartNumbering stamps one paragraph, the head, and the rest of the
run follows. Later paragraphs are restamped only when they carry an explicit
instanceId of their own, meaning they head a later run that moves along with
it. A paragraph added to the run afterwards (typed, pasted, or restyled onto
the numbered style) joins it with no stamp at all.
On export each distinct instance becomes its own w:num pointing at the shared
abstract definition.
Sub-editors
Evaluation is root-side. The main editor’s plugin walks headers, footers and
note bodies, so their content numbers correctly whether or not a sub-editor is
open. Sub-editor views mount only numberingPropsPlugin, which moves typing at
the very start of a numbered paragraph after the counter. They never run a
second evaluating walk.
Definitions are not copied into a sub-editor’s doc attrs. Reads fall through
to the root editor’s store, and config writes (saveListConfigToDocAttrs,
removeListConfig, updateNumbering) are forwarded to the root doc as
doc-attr steps.
The followBy / tabStop cascades
Both use the same order as the rest of numbering: the paragraph attr beats the style binding, which beats the level config:
followBy: node attr → style binding → level configsuffix→"tab".tabStop: node attr → style binding → level configtabStop→ editor default.
setFollowBy and setListTabStop write the level config, which is the tier
DOCX exports. A default or shared config is forked first, so only the selected
instance changes. On a style-numbered paragraph they also clear the binding’s
copy of the field, so the config becomes the single source of truth. The new
value is then pushed onto the instance’s node attrs, so it shows immediately.
Word formats and language
cardinalText, ordinalText and ordinal spell the counter out. They render in
the document’s language (the paragraph’s effective proofingLanguage, not the
UI locale), so an Italian paragraph numbers Quarantaduesimo while its English
neighbour numbers Forty-Second.
Every format goes through one seam:
import { createListFormatOptions } from "@stesura/core/helpers";
const options = createListFormatOptions("it-IT", "feminine");
options.find((o) => o.value === "ordinalText")?.transform?.(42); // "Quarantaduesima"Pickable vs renderable
Eight formats are pickable: decimal, upper/lower Roman, upper/lower letter,
cardinal text, ordinal text and ordinal. These are what the level-format
dropdown offers, through createPickableListFormatOptions.
createListFormatOptions returns more: decimalZero, hex, chicago,
numberInDash, the enclosed ranges (①, ⑴, ⒈), full-width / Thai / Devanagari
digits, and Russian / Hebrew / Arabic lettering. These are render-only, so an
imported Word file displays its counters faithfully. Import and export pass
format through verbatim either way.
A format outside the table renders as decimal, which is ST_NumberFormat’s
own fallback. none (and bullet) render an empty counter and keep any literal
characters in the level text. A %N is never echoed into the document.
Words come from gendered-to-words.
Only the locales in NUMBER_LOCALES are bundled, through the library’s
per-locale entry points, and they match the language picker’s
SUPPORTED_LANGUAGES. Adding a language to the picker means adding it to
NUMBER_LOCALES too, or a spec fails. An unrecognised proofingLanguage
falls back instead of throwing. The order is: exact match → language+region
ignoring script/extension subtags → any region of the same language → en-GB.
Gender
Levels take an optional gender: "masculine" (the default) or "feminine".
This lets “Articolo Primo” and “Sezione Prima” share a document. It applies
per level, so "%1.%2" can mix the two. It affects the three word formats
and only the languages with distinct forms (es, fr, it, pt,
localeHasNumberGenderVariants). Other formats and languages ignore it.
Casing
Levels take an optional casing for cardinalText and ordinalText:
"capitalize" (the native output, “Forty-Two”), "uppercase", "lowercase"
or "firstLetter" (“Forty-two”).
gender and casing are local extensions with no w:lvl equivalent.
Unlike tabStop, they are stripped on DOCX export and don’t survive a round
trip.
DOCX
| Editor | OOXML |
|---|---|
paragraphStyle.numbering | w:numPr in the style’s pPr (styles.xml) |
| Style bound to a level | w:lvl/w:pStyle back-link in numbering.xml |
"__inherit" paragraph matching its style | no paragraph w:numPr — Word reads the style |
"__inherit" with an override | explicit paragraph w:numPr |
| Off-sentinel | w:numId 0 |
Level start | w:start |
listRef | the w:abstractNum — one counter per definition, as in Word |
instanceId | a w:num over that abstract |
Import: abstracts, not numIds
Word runs one counter per w:abstractNum. A w:num names that definition
and optionally restarts it; it is not a list of its own. So a listRef is the
resolved abstractNumId, and every w:num over it is classified:
- alias — no
w:lvlOverride, or overrides structurally identical to the abstract’s levels (Word writes a fullw:lvlclone with every restart, so the clone’s presence means nothing); - restart — identical clones plus a
w:startOverrideon ilvl 0 at the level’s ownstart. The restart is consumed on the num’s first use in the document (ECMA-376 §17.9.31); later uses continue the run. A num some style’sw:numPrnames is never a restart: it is the definition’s base run (Word writes no restart there; docx-js stamps one on every concrete num); - fork — a num that really redefines a level, restarts at some other value,
or restarts only deeper levels. It gets its own config
<abstract>.<numId>and anumbering_mismatchwarning.
A paragraph naming an alias or a consumed restart carries no instanceId when a
style binds the list — the plugin’s carry supplies it — so a paragraph with a
direct w:numPr and the style-only paragraph after it are one continuous run.
Import also resolves the w:numStyleLink indirection legal templates use, and
marks style-referenced definitions as used so the anti-garbage filter keeps the
configs the styles point at. A style carrying w:outlineLvl is not promoted
to a heading node when it is style-numbered — Word’s legal numbering styles use
the outline level for navigation only.
w:numPr with a w:ilvl and no w:numId is a level override on an
inherited binding, both on a style (resolved as a separate slot up the basedOn
chain) and on a paragraph (stamped as { listRef: "__inherit", level }). A
paragraph that must not be numbered — a dangling w:numId, w:numId 0, the
continuation fragment of a split paragraph — gets the off-sentinel when its style
is numbered: null means inherit, which would renumber it.