Skip to Content
Stylesheets

Stylesheets

Stylesheets hold the document’s named styles (“Normal”, “Heading 1”, …). Each block points at a style through its styleId attribute.

How Stylesheets Work

  1. Each styled block has a styleId attribute (default "Normal").
  2. Definitions live in doc.attrs.styleSheet, a sparse override map merged over the built-in DEFAULT_STYLE_SHEET. Only fields that differ from the defaults are stored.
  3. styleSheetPlugin resolves the merged sheet (following basedOn chains), publishes it to the editor’s stores and generates one CSS rule per style.
  4. Blocks render with a pm-style-<id> class (pm-style-Normal, pm-style-Heading1).
  5. The CSS goes into a <style> element in the document head, scoped to the editor through its data-pm-editor-id attribute.

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 idUse
NormalBody text, and the base of every other paragraph style
FootnoteFootnote body text
EndnoteEndnote body text (based on Footnote)
Heading1Heading9Headings, outline levels 1–9
TOC1TOC9Table-of-contents entries, one per level
HTMLCodeAppearance of the code mark (character style)
HyperlinkAppearance 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

  • paragraphStyle and characterStyle merge field by field. spacing and indent merge one level deeper.
  • borders, numbering and pagination are replaced as whole objects.
  • For numbering, outlineLevel, backgroundColor, tabStops, pagination and language, an explicit null resets the inherited value and an absent field inherits.
  • next is not inherited, the same as Word’s w: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 or next
  • may only be basedOn another character style, and doesn’t inherit from Normal
  • renders against its mark’s selector (code.code-mark, a.link) instead of .pm-style-<id>, which is why characterStyle.bgColor works 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); // unbind

Paragraphs 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:

  1. Inline marks (textStyle mark): font size, colour and family set directly on text.
  2. Node attributes: textAlign, indent, spacing and so on, set on the block.
  3. Stylesheet: the style named by styleId, resolved through basedOn.
  4. 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:

  • paragraph
  • heading

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 stored

getResolvedStyleSheet 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

Last updated on