Stylesheets
Stylesheets hold the document’s named styles (“Normal”, “Heading 1”, …). Each
block points at a style through its styleId attribute.
How Stylesheets Work
- Each styled block has a
styleIdattribute (default"Normal"). - Definitions live in
doc.attrs.styleSheet, a sparse override map merged over the built-inDEFAULT_STYLE_SHEET. Only fields that differ from the defaults are stored. styleSheetPluginresolves the merged sheet (followingbasedOnchains), publishes it to the editor’s stores and generates one CSS rule per style.- Blocks render with a
pm-style-<id>class (pm-style-Normal,pm-style-Heading1). - The CSS goes into a
<style>element in the document head, scoped to the editor through itsdata-pm-editor-idattribute.
The plugin also heals orphans: a block whose styleId matches no paragraph
style (for example after a paste, a programmatic insert, or a collaborator
deleting the style) is re-pointed at Normal, or at Heading<level> for
headings.
There is no plugin option for supplying styles. The plugin always reads the
document. To ship custom styles, put them in the initial document’s
doc.attrs.styleSheet, or create them at runtime with
upsertStyleDefinition.
Default Styles
The built-in styles (DEFAULT_STYLE_SHEET from @stesura/core/constants):
| Style id | Use |
|---|---|
Normal | Body text, and the base of every other paragraph style |
Footnote | Footnote body text |
Endnote | Endnote body text (based on Footnote) |
Heading1–Heading9 | Headings, outline levels 1–9 |
TOC1–TOC9 | Table-of-contents entries, one per level |
HTMLCode | Appearance of the code mark (character style) |
Hyperlink | Appearance of the link mark (character style) |
Normal is mandatory. resolveStyleDefinition falls back to it for unknown
ids and circular basedOn chains, and it throws if the sheet has no Normal.
Applying a Style
Use setStyleSheet. Heading-ness follows the style’s resolved outline level
(Word’s model), so applying a heading style converts the block to a heading
node, and applying a body style converts it back:
import { setStyleSheet } from "@stesura/core/commands";
setStyleSheet("Heading1")(view.state, view.dispatch);
// Optional second arg: strip inline formatting marks.
// true = always; "sameStyle" = only when the whole selection already has this style.
setStyleSheet("Normal", "sameStyle")(view.state, view.dispatch);It refuses character styles, and it refuses selections inside code blocks.
The StyleDefinition shape
StyleDefinition (from @stesura/core/types):
type StyleDefinition = {
name: string; // display name ("Heading 1")
type?: "paragraph" | "character"; // absent = paragraph
basedOn: string | null; // parent style id; unset fields inherit from it
paragraphStyle?: ParagraphStyle; // textAlign, spacing, indent, lineHeight, lineHeightRule,
// tabStops, borders, backgroundColor, outlineLevel,
// numbering, pagination
characterStyle?: TextStyle; // fontSize, fontFamily, color, strong, em, ...
next?: string; // style of the paragraph created on Enter (not inherited)
language?: string | null; // proofing language, see below
uiPriority?: number; // gallery sort order (ascending, unset sorts last)
quickFormat?: boolean; // shown in the quick-styles strip
semiHidden?: boolean; // kept out of the quick strip...
unhideWhenUsed?: boolean; // ...until a block uses it
link?: string; // Word's paired linked style
};The style’s id is its key in the stylesheet map. name is only the label.
Inheritance through basedOn
paragraphStyleandcharacterStylemerge field by field.spacingandindentmerge one level deeper.borders,numberingandpaginationare replaced as whole objects.- For
numbering,outlineLevel,backgroundColor,tabStops,paginationandlanguage, an explicitnullresets the inherited value and an absent field inherits. nextis not inherited, the same as Word’sw:next. When it’s absent, Enter stays in the same style.
Character styles
type: "character" marks an entry that formats runs, not blocks. There is no
general character-style model. The recognised set is exactly HTMLCode and
Hyperlink, which give the code and link marks their appearance. DOCX import
flattens every other Word character style onto its runs as direct formatting.
A character entry:
- never appears in the style picker and cannot be applied with
setStyleSheet - carries no
paragraphStyle, so it has no outline level, numbering ornext - may only be
basedOnanother character style, and doesn’t inherit fromNormal - renders against its mark’s selector (
code.code-mark,a.link) instead of.pm-style-<id>, which is whycharacterStyle.bgColorworks here and is ignored on a paragraph style
Editing HTMLCode therefore restyles every code run in the document. An
imported file’s own code or link appearance is stored as a delta over the
defaults.
Style language
language sits at the top level, not inside characterStyle, because it is a
proofing property, not a render one, and must never reach the generated
CSS. It is the middle tier of
paragraph.proofingLanguage ?? style.language ?? doc.language. It inherits
through basedOn, and an explicit null resets it to the document language.
In DOCX it round-trips through the style’s own w:rPr/w:lang, which is where
Word keeps it too.
Pagination properties
paragraphStyle.pagination holds keepWithNext, widowOrphan,
pageBreakBefore and keepLines, in the same shape as the block’s own
pagination attr. Each field resolves per paragraph as block attr → style →
fallback. The fallbacks are widowOrphan: true and false for the other
three. getStylePaginationProps(resolvedStyle) from @stesura/core/helpers
returns the style tier with the fallbacks applied. The default headings set
keepWithNext: true.
setPaginationAttrs({ keepWithNext: true }) from @stesura/core/commands
writes block overrides on the selection. null for a key clears that override,
and resetPagination() clears them all, so the stylesheet decides again.
The paginator doesn’t honour keepLines yet. It is carried through for the DOCX
round trip.
Defining and editing styles
All style writes are commands on the doc’s override map (from
@stesura/core/commands):
import {
upsertStyleDefinition,
createStyleDefinition,
updateStyleDefinition,
submitStyleDefChange,
deleteStyleDefinition,
} from "@stesura/core/commands";
// Deep-merges a partial definition into the existing override (same rules as
// basedOn). Fields matching DEFAULT_STYLE_SHEET are pruned, so only real
// deltas are stored. Ids are sanitized to be CSS-safe.
upsertStyleDefinition("LegalBody", {
name: "Legal Body",
basedOn: "Normal",
characterStyle: { fontSize: 11 },
paragraphStyle: { lineHeight: 1.5, spacing: { before: 0, after: 6 } },
})(view.state, view.dispatch);
// Create-only / update-only variants of the upsert:
createStyleDefinition("LegalBody", def)(view.state, view.dispatch); // fails if the id exists
updateStyleDefinition("LegalBody", { characterStyle: { fontSize: 12 } })(view.state, view.dispatch); // fails if missing
// Create / in-place update / rename in one transaction (one undo step).
// An in-place update REPLACES the stored override: omitted fields are cleared.
// A rename rewrites block refs and dependent styles' basedOn/next.
// Built-in ids cannot be renamed.
submitStyleDefChange("LegalBody", "LegalBody2", def)(view.state, view.dispatch);
// Delete a custom style. Blocks and dependent styles are re-pointed at
// replaceWith (default "Normal"). Built-in styles are undeletable — throws.
deleteStyleDefinition("LegalBody2", "Normal")(view.state, view.dispatch);upsertStyleDefinition takes a third options argument. { replace: true }
stores the definition whole instead of merging it.
Replacing style ids across the document
replaceStyleDefIds re-points every block using one style at another
(positional arguments, not an options object). Like setStyleSheet, it
converts node types where the outline level demands it:
import { replaceStyleDefIds } from "@stesura/core/commands";
replaceStyleDefIds("OldStyle", "NewStyle")(view.state, view.dispatch);
// second arg defaults to "Normal"Binding a style to a list level
A paragraph style can own a numbering level (paragraphStyle.numbering).
setStyleNumberingBinding writes the binding and enforces the invariant that
at most one style occupies a given (listRef, level) slot:
import { setStyleNumberingBinding } from "@stesura/core/commands";
setStyleNumberingBinding("Definitions-L1", { listRef: "legal", level: 1 })(view.state, view.dispatch);
setStyleNumberingBinding("Definitions-L1", null)(view.state, view.dispatch); // unbindParagraphs of the style then number themselves with no per-node state, and promote/demote becomes a style switch. See Numbering for the full style-linked numbering model.
Style Precedence
Highest to lowest:
- Inline marks (
textStylemark): font size, colour and family set directly on text. - Node attributes:
textAlign,indent,spacingand so on, set on the block. - Stylesheet: the style named by
styleId, resolved throughbasedOn. - Schema defaults: base defaults from the schema spec.
The styleId Global Attribute
The styleId global attribute adds the styleId attr. Its default binding is
STYLED_BLOCK_NODES:
paragraphheading
In HTML it serializes as data-style-sheet. See
Global Attributes for the global-attribute system.
Reading the resolved stylesheet
import {
getResolvedStyleSheet,
getResolvedStyleDefinition,
getStyleSheet,
getStyleSheetOverrides,
} from "@stesura/core/helpers";
getResolvedStyleSheet(view.state); // every style, basedOn chains merged
getResolvedStyleDefinition(view.state, "Heading1"); // one style; Normal (with a warning) if unknown
getStyleSheet(view.state); // defaults + overrides, chains NOT walked
getStyleSheetOverrides(view.state); // the sparse map as storedgetResolvedStyleSheet reads the plugin state, and in sub-editors (which don’t
mount the plugin) it falls through to the root editor’s store. Without a state,
use getResolvedStyleSheetFromDoc(doc) or resolveStyleSheet(sheet).
Next Steps
- Numbering — style-linked numbering, regions, and the attr cascade.
- Global Attributes — global attribute pipeline.
- Commands: Specialized —
setStyleSheetcommand reference. - Plugins — where
styleSheetPluginsits in the plugin stack.