Skip to content

0031. Subscript and superscript join the fixed style vocabulary

Status: Accepted; the Decision section's "the server enforces nothing about exclusivity" and the consequence that a run carrying both flags is never refused are superseded by 0037 (2026-10-31)

Date: 2026-10-31

Context

Feedback item 17 asked for subscript and superscript, the two styles a chemistry formula or a footnote marker needs and BlockNote's own default vocabulary does not carry: upstream's closed list is bold, italic, underline, strike and code (node_modules/@blocknote/core/src/blocks/defaultBlocks.ts), and each of the five is a Mark.create() wrapped with createStyleSpecFromTipTapMark(mark, 'boolean'), the same factory defaultBlocks.ts:136-140 calls for every one of them. Core's own Full HTML text format already permits <sub> and <sup>, so a site moving a field from a CKEditor 5 format to a BlockNote one loses a formatting choice its authors already had.

The list is closed in four places that have to change together: DocumentValidator::INLINE_STYLES (src/Document/DocumentValidator.php:60), InlineContentRenderer::STYLE_ELEMENTS (src/Render/InlineContentRenderer.php:29, the nesting order code, bold, italic, underline, strike), the styles mapping in config/schema/blocknote.schema.yml, and BlockNoteFilter's plugin definition defaults and form options (src/Plugin/Filter/BlockNoteFilter.php). The client side has three more: INLINE_STYLES in js/src/policy.ts, STYLES in js/src/stripped.tsx, and styleSpecsFor() in js/src/schema.tsx, which deletes an upstream style spec for each style the policy forbids. BlockNote::getJSSettings() derives policy.styles from DocumentValidator::INLINE_STYLES directly, so it needs no edit of its own once the constant grows.

@tiptap/extension-subscript and @tiptap/extension-superscript are not installed: node_modules/@tiptap/ holds bold, code, italic, strike, text, underline, core, extensions, pm and react, and no subscript or superscript package. Writing the two marks in the module, modeled on how tiptap's own extensions of the same name are built (Mark.create() parsing sub/sup and vertical-align: sub|super, rendering <sub>/<sup>, a keyboard shortcut), is a dozen lines each and avoids two new dependencies, bun.lock churn and two new THIRD_PARTY_NOTICES.md entries for a factory the module already depends on through @blocknote/core.

Measured against a headless BlockNote 0.54.2 editor: a mark built this way parses H<sub>2</sub>O, <span style="vertical-align: super"> and <sup> into {"subscript":true} and {"superscript":true} runs; with excludes: 'superscript' declared on the one and excludes: 'subscript' on the other, toggleStyles({superscript: true}) on subscripted text replaces the mark rather than stacking it; a run stored with both flags set still loads; and both external and internal HTML render <sub>/<sup> correctly. But excludes naming a mark the schema does not hold throws at editor creation, so a format permitting one of the two and not the other needs the spec built without the excludes option, and styleSpecsFor() has to compute that per policy rather than declaring it once.

A stored document holding an unknown style key is refused as InvalidInlineStyle (DocumentValidator::validateTextItem()), and a schema without the matching spec throws when it meets one (blockToNode.ts:37-40, "Error creating document from blocks passed as initialContent"), so js/src/policy.ts's load strip has to remove the key whenever the spec is absent, the same way it already removes any other forbidden style.

Whether a flag added to the vocabulary arrives permitted or forbidden on a format that already exists was not previously stated anywhere. docs/decisions/0016 and 0017 say only that a post-update re-saves formats; the inline content list arriving empty when a new type is added (0027) is the opposite rule, because an empty list there means none rather than everything. This record states the rule for a style flag, since none existed.

Decision

The vocabulary grows to seven: DocumentValidator::INLINE_STYLES becomes ['bold', 'italic', 'underline', 'strike', 'code', 'subscript', 'superscript'], in this order, because BlockNoteFilter::normaliseFlags() writes stored configuration in the constant's own order and a reordering would be a config diff on every format's next save. InlineContentRenderer::STYLE_ELEMENTS gains the same two names at the same position, so a run nests code, bold, italic, underline, strike, subscript, superscript, with the color <span> innermost of all: <sub> and <sup> sit inside <s> and outside the colour wrapper. A run stored with both flags renders <sub><sup>text</sup></sub>, which is deterministic and never refused, even though the editor's mutual exclusion never produces it.

The two marks are written in the module, not vendored as two new packages. js/src/portable/styles.ts builds subscriptSpec() and superscriptSpec(), each a Mark.create() modeled on tiptap's extension-subscript and extension-superscript (same author, MIT, credited in THIRD_PARTY_NOTICES.md's docblock even though the packages are not dependencies), parsing sub/sup tags and the vertical-align style, rendering <sub>/<sup>, and wrapped with createStyleSpecFromTipTapMark(mark, 'boolean'), the same factory the five existing styles use. Mark and mergeAttributes come from @tiptap/core, already reachable through @blocknote/core's own dependency on it. Amended 2026-09-26. The credit is in the comments of js/src/portable/styles.ts alone: THIRD_PARTY_NOTICES.md is generated from the packages compiled into the bundle, and neither package is among them.

Subscript and superscript exclude each other in the editor, the way Google Docs and Word behave: each mark declares excludes naming the other, but only when styleSpecsFor() finds both permitted by the format's policy, because excludes naming a mark the schema does not hold throws at editor creation. A format permitting only one of the two builds its spec with no excludes at all. The server enforces nothing about exclusivity: a hand-written document, or one from a schema that permitted only one style before the format changed, may carry both flags on one run, and it renders both elements nested, deterministic and never refused; the editor is simply the only place capable of producing it, and it never does. Amended 2026-10-31. Since BlockNote 0.57.0 the editor checks the document it is given against its schema and refuses to open one holding a text node whose marks exclude each other, "Invalid collection of marks". The policy walk that runs before the editor opens (js/src/policy.ts's stripStyles()) therefore takes subscript off a run that carries both flags while the format permits both, keeping superscript, which is the mark ProseMirror itself keeps when it adds the two in schema order, and the stripped-content notice names it as "subscript and superscript on the same text". The server still refuses nothing and still renders both; a document saved without an edit keeps both flags, as the notice's own hint says of every stripped document. Amended 2026-10-31. The hint said the opposite, that a save without an edit submits the stored document and is refused. 0037 makes that true: the server now refuses a run carrying both flags, and the notice names the pair among what the editor cannot show together, apart from what the format forbids.

The keyboard shortcuts are Mod-, for subscript and Mod-. for superscript, tiptap's own and Google Docs' bindings, added to README.md's keyboard table. No shortcut exists for a flag the marks do not have; a browser test is not owed to this beyond styles.test.ts's vitest coverage, since jsdom cannot deliver a real key chord for a browser-level collision to be measured against.

A flag added to the vocabulary arrives permitted on every format, new and existing. BlockNoteFilter's plugin definition default for subscript and superscript is TRUE, alongside the other five. Every shipped format and both recipe formats store both keys TRUE, the snippet format included. An existing format gains both TRUE through a plain re-save: blocknote_post_update_inline_style_vocabulary() runs ConfigEntityUpdater over every enabled BlockNote format with no per-format rule, the same mechanism blocknote_post_update_single_paragraph_surface() already uses, because BlockNoteFilter::setConfiguration()'s call to normaliseFlags() fills in an absent key as permitted for every member of a vocabulary, the same way alignments arrived when 0017 added it. A site that wants either style off on a particular format turns it off there, the same as any other style. A re-imported export that never met the post-update reads the same way at runtime, through normaliseFlags()'s own absence-means-permitted default, the same accepted gap blocknote_post_update_settings_surface()'s own docblock already names for heading levels.

The stripped-content notice names the two styles in words, js/src/stripped.tsx's STYLES gaining Drupal.t('Subscript') and Drupal.t('Superscript'), since upstream's own dictionary has no entry for either. Amended 2026-09-26. STYLES did not grow: it became DICTIONARY_STYLES, the five names upstream's dictionary has a toolbar tooltip for, and styleName() beside it names subscript and superscript with those two strings directly.

Alternatives considered

Leaving the style seam parked and adding an inline content plugin instead, the seam 0005 mentions and says is not yet built. Rejected: subscript and superscript are BlockNote-shaped marks that tiptap itself ships a first-party factory for, not a case that needs a plugin architecture; building the seam for two styles when the base vocabulary can hold them directly would be scaffolding nobody asked for. Amended 2026-09-26. The seam 0005 planned is for inline content, and it exists since 0027, as 0005's own amendment of 2026-09-21 says; what 0005 keeps out of scope is custom styles. The reason given stands either way.

Leaving subscript and superscript out of 1.0. Rejected by the maintainer: the module has no consumers yet beyond the local demo site, so this is the cheapest moment to widen the vocabulary, before any site's stored formats have to be migrated in the field.

Making the two styles off by default on an existing format, on for a new one. Considered and not taken: with no consumers yet, there is no format whose author-facing surface the maintainer needs to keep stable, so the extra rule the post-update and 0031 would have to state (a per-format default depending on whether the format already existed) buys nothing. See "Decisions made by Nodar, 2026-09-26" for the record of this choice.

Vendoring @tiptap/extension-subscript and @tiptap/extension-superscript. Rejected. Each package is a thin wrapper around Mark.create() with the same shape this module already writes for its own marks; adding two dependencies, the bun.lock churn and two more THIRD_PARTY_NOTICES.md entries buys nothing over writing the dozen lines directly, and this module already writes its own tiptap-shaped marks elsewhere.

Consequences

The format's settings form grows two checkboxes, "Subscript" and "Superscript", in the Styles list, and README.md's style list and keyboard table grow to match.

Every existing format's toolbar gains two buttons after the post-update runs, with nobody having chosen that on the format's own form. That is the accepted cost of "on everywhere": the alternative, off by default on an existing format, was rejected above because there is no installed base yet whose authoring surface this would disturb.

A run carrying both subscript: true and superscript: true, which the editor never produces but a hand-written or migrated document might, renders <sub><sup>text</sup></sub>, nested and deterministic; no browser draws that combination in a way a reader would call sensible, and nothing refuses it, the same posture the module already takes toward every other style combination it does not forbid outright.

docs/rendering.md section 4's first two paragraphs, the nesting order and the nesting example, and section 9's style count, both move from five to seven. README.md's style list at the Text tab gains the two names. The translation extraction test's pin for Drupal.t() strings moves in the same commit that rebuilds dist/ with the new toolbar button labels.

Golden fixture inline-content.json gains runs for subscript alone, superscript alone, subscript inside a link, and the combined-order case with all seven styles and a color, regenerated with BLOCKNOTE_UPDATE_FIXTURES=1 and read line by line so that the diff is exactly the runs the fixture gained.

References

  • BlockNote 0.54.2: @blocknote/core src/blocks/defaultBlocks.ts:136-140, src/schema/styles/createSpec.ts (createStyleSpecFromTipTapMark), src/api/nodeConversions/blockToNode.ts:37-40.
  • tiptap: @tiptap/extension-subscript and @tiptap/extension-superscript (not installed; modeled, not vendored), @tiptap/core (Mark, mergeAttributes).
  • src/Document/DocumentValidator.php, src/Render/InlineContentRenderer.php, src/Plugin/Filter/BlockNoteFilter.php, config/schema/blocknote.schema.yml, blocknote.post_update.php.
  • js/src/portable/styles.ts, js/src/schema.tsx, js/src/policy.ts, js/src/stripped.tsx, js/src/toolbar.tsx.
  • 0005, the seam left parked for custom inline content, not custom styles. Amended 2026-09-26. That seam is built, 0027; 0005 keeps custom styles out of scope.
  • 0016, 0017, the arrival rule for a settings surface added to an existing format.
  • 0032, the second half of this wave, which reuses the same style carrier for a link's new-tab choice.