Skip to content

0017. The filter's settings govern alignment, heading levels and toggles, code block languages, and merged cells

Status: Accepted

Date: 2026-09-16

Context

This change adds five settings a site builder had no way to decide: the alignments a block or a table cell may hold, the heading levels a document may use, whether a heading may be made collapsible, the languages a code block's picker offers and the language a new one starts in, and whether table cells may be merged.

0016 already states the rule for where a setting lives: enforced on save, it belongs in the filter's policy or block_settings; only shaping what the editor offers, it belongs on the editor entity; shaping the input control itself, it belongs on the widget. This record does not restate that rule. It applies it to these five settings and adds their rows to 0016's table.

Every one of the five is enforced on save, so every one is filter configuration. Four of the five belong to a specific block, spread over three blocks, and Heading, CodeBlock and Table had no filter-side settings of their own before this change; Drupal\blocknote\BlockNoteBlockEditorSettingsInterface::getEditorSettings() was already fed from the filter's block settings, so no new seam was needed to reach the client, only new implementers.

Several upstream and core facts shaped the mechanism.

PropSpec describes a block's contract; it has no access to a format's settings, so it cannot itself be narrowed per format. The narrowing has to happen where BlockNoteBlockBase::validateProps() reads the schema, which is why this change adds propSchemaFor(array $settings), called with BlockValidationContext::$settings, defaulting to getPropSchema() and overridden by a block that narrows a property. getPropSchema() stays the block's contract with no narrowing applied, because a document saved under a wider format may still be read, migrated or exported under a narrower one, and tests/src/Kernel/BlockPluginContractTest.php already pins that contract with no narrowing applied. Amended 2026-10-31. propSchemaFor() is now public, as getPropSchemaFor() on BlockNoteBlockInterface and BlockNoteInlineContentInterface, so that the HTML converter builds a block from the values a format permits rather than copying them; this record's later mentions of propSchemaFor() name it by its old name. See 0038.

@blocknote/react's BlockTypeSelect.tsx gives its plain heading items props: { level, isToggleable: false } and its toggleable heading items props: { level, isToggleable: true }. defaultBlockTypeGuards.ts's editorHasBlockWithType() returns false for an item naming a prop that is absent from the schema. So narrowing the client schema to drop isToggleable when a format forbids collapsible headings, which the schema change already needs to do so BlockNote's own toggle button has nothing to toggle, would also silently empty every heading entry from the block type select, plain ones included, unless the module supplies its own items. The same select is also how a format's permitted levels are kept off the menu: an item naming a level the format forbids is dropped the same way.

@blocknote/core's code block renderer, createCodeBlock.ts, shows a language picker only when it is given more than one supported language, and throws Language X is not supported when a block's stored language is not a key of the map it is given. A format that narrows the language list therefore cannot stop at refusing an unlisted language on save; a document saved before the list narrowed, or written by another client, still has to load without the editor throwing, which is why the client strip of an unlisted language is load-bearing and lands with the schema change rather than after it.

tables.splitCells, the one editor option BlockNote reads for table cells, gates merging and splitting together upstream: the formatting toolbar's merge button, the table handles' own cell menu's split button, and the block menu's split affordance (the module's own) all read it, and no part of @blocknote/core reads it for anything else. Leaving it on and removing only the module's own merge button would still leave upstream's cell handle offering Merge cells, because that handle's own menu is gated on the same flag; closing that gap without passing the setting through would mean the module supplying its own cell handle everywhere, which nothing else in the module needs. So the setting is passed through to tables.splitCells, which removes merging and splitting together from both the toolbar and the cell handle, and the module's own Alt+F10 block menu is the one place Split cell is still offered regardless, because a document may already hold a merged cell after the format stops permitting new ones and needs a way to take it apart.

nodeToBlock.ts writes every property whose schema declares a default, so a stored paragraph always carries textAlignment: "left" explicitly, the same as isToggleable: false on a plain heading. A policy that could refuse left would refuse the default state of every block that declares the property, which is not a real restriction a site builder could mean to express.

Decision

Alignment is a four-flag policy, alignments, beside styles, text_colors and background_colors on the filter. The flags are left, center, right and justify, in that order, each defaulting to permitted. left is never refused on save and never dropped at render, whatever the policy holds, because it is the default every block and table cell that declares textAlignment already carries; turning its flag off only removes its toolbar button. DocumentValidator::validateBlockAlignment() keys on whether a block plugin's declared property schema, getPropSchema(), holds textAlignment, not on a list of block types, so a block a later change gives the property to is covered without a corresponding change to the validator; the same check runs for a table cell's alignment. The renderer drops a forbidden alignment's class the way it drops a forbidden color's, through RenderPolicy::allowsAlignment() and StyleVocabulary::classes(). The editor's strip on load resets a forbidden alignment to left, by the same prop-name mechanism as the validator, extending 0010's strip walk and toolbar narrowing. Upstream ships no justify button in its formatting toolbar, only left, center and right, so the module adds none either; the justify flag governs save and render only.

Heading levels and collapsible headings are the heading block's own settings, allowed_levels and allow_toggle. Heading::propSchemaFor() narrows the level property to the format's permitted levels, with the lowest permitted level as the default so a new heading always starts inside the narrowed set; isToggleable stays declared at its full contract, because a document saved under a format that permitted toggles may still carry isToggleable: true under one that no longer does, and that has to keep being checked against something. Heading::validate() refuses a collapsible heading on top of the property checks when the format does not permit one. A stored level or toggle a format has since stopped permitting still renders as stored: changing a heading's level or collapsing it at render would change the document's structure, where dropping a color or an alignment only changes presentation, so this is refused on save and stripped on load, not adjusted on render. The module supplies its own block type select items, narrowed to the permitted levels and, when toggles are off, with isToggleable left out of every surviving item's props, because upstream's own items would otherwise be silently dropped by editorHasBlockWithType()'s prop-presence check.

Code block languages are the code block's own settings, languages and default_language. An empty languages list means unrestricted, the convention DrupalMedia's permitted view modes and bundles already use for an empty block setting; default_language is always permitted, listed among languages or not, and defaults to text. CodeBlock::propSchemaFor() narrows the language property to the configured list when one is configured. The client strips a stored language the list excludes back to the default on load, which is load-bearing rather than cosmetic: upstream's code block renderer throws when a stored language is not a key of the language map it is given, so a document saved before the list narrowed has to have that language stripped on load, or it cannot be loaded at all. The renderer emits no language-<id> class, and logs a debug message, for a language outside the format's list.

Merged cells are the table block's own setting, merged_cells. Table::validate() refuses a colspan or rowspan above 1 on any cell when merging is not permitted. The setting is passed through to tables.splitCells, which removes both the merge and the split affordances from BlockNote's own formatting toolbar button and table cell handle at once; the module's own Alt+F10 block menu keeps Split cell offered regardless of the setting, as the repair route for a cell a document already holds merged, and gains a matching gate on Merge cells, which had no gate at all before this change while Split cell on the same menu already read editor.settings.tables.splitCells. Rendering a stored span is unchanged: flattening it would produce a ragged table, worse for a screen reader than the span. Amended 2026-09-20. Two more table settings sit beside it, header_cells and cell_colors, both permitted by default. Table::validate() refuses headerRows or headerCols above zero when header cells are not permitted, and a cell textColor or backgroundColor other than default when cell colors are not permitted, each in the voice of the merged-cell refusal; a stored header or cell color still renders as stored, the posture this record already takes for a span. createEditor() passes header_cells to tables.headers, which had been hard-coded on, and cell_colors, AND-ed with each palette being non-empty, to tables.cellTextColor and tables.cellBackgroundColor; upstream's drag handle items, row and column handle buttons and cell handle color pickers read those flags, and the module's block menu already did. A pasted <th>, and a cell color arriving by paste, are clamped in the changed ranges by the guard 0024 records, and stripped on load with the notice naming header rows and columns, or cell colors. The table block's own textColor stays governed by the palette alone.

A post update re-saves every enabled BlockNote format. filter_settings.blocknote and the new blocknote.block_settings.* mappings are FullyValidatable, which makes every declared key required for a config whose schema is reached directly; filter.format.* itself, the absolute root a stored format resolves under, does not opt into FullyValidatable, and ValidKeysConstraintValidator checks only that absolute root's opt-in before it runs its required-key check at all, so a format stored before alignments and the three blocks' settings existed is not itself flagged by typed-config validation, whatever the filter's own settings declare. It is stored incomplete regardless. blocknote_post_update_settings_surface() re-saves every format with the filter enabled, which runs BlockNoteFilter::setConfiguration() and writes the missing keys at the defaults that keep the format's current behavior unchanged. Two further update functions fill in two keys an earlier change left required but unpopulated on sites that installed before that change: blocknote_post_update_editor_settings_surface() re-saves the editor entity's ui.paste_markdown, which editor.editor.* does opt into FullyValidatable for and so is genuinely refused by typed-config validation when absent, and blocknote_post_update_widget_settings_surface() re-saves the widget's placeholder, which core.entity_form_display.*.*.* does not opt into FullyValidatable for and so is not flagged the same way the filter's settings are not.

Alternatives considered

The code block language list on the editor entity instead of the filter. Rejected. The editor entity has no per-block settings seam of its own; getEditorSettings() is already fed from the filter's block settings, so putting the list there would mean inventing a second per-block surface. A language is also part of the document, reaching the public page as a class, which is the same posture styles and colors already take, not the lenient posture a view mode gets under 0015.

Leaving tables.splitCells on and removing only the module's own merge affordances. Rejected. Upstream's own table cell handle offers Merge cells gated on the same flag as the toolbar button, so this would leave that route open while every route the module owns was closed, and closing it would mean supplying the module's own cell handle everywhere, which nothing else needs.

A fixed catalog of code languages. Rejected. The module cannot know what grammar a site's own syntax highlighter recognizes; the id a format lists is exactly the class a theme's highlighter reads, so the list has to be the site builder's to write. Amended 2026-09-20. The list is still the site builder's to write, and the module still enforces nothing about which ids a format lists. The form gained a button, Fill with common languages, that appends CodeBlock::COMMON_LANGUAGES, about thirty id|Label lines whose ids highlight.js and Prism both accept, leaving out every id the textarea already holds and keeping the author's lines and order as typed, and the field's description tells the site builder to use the ids their theme's highlighter documents.

Refusing left on save when its flag is off. Rejected. Every block and table cell that declares textAlignment stores left explicitly by upstream's own default-writing behavior, so refusing it would refuse the unaligned state of content nobody asked to align.

Capping a stored heading level, or forcing a stored heading closed, at render. Rejected. Either changes the document's structure to fit a later policy change, where every other posture in this record leaves structure alone and only adjusts presentation or refuses on save.

A justify button on the formatting toolbar. Deferred. Upstream ships none, and adding a bespoke one is a larger step than this change's settings warrant; justify still governs save and render.

Alignment healed like a view mode instead of refused like a color. Rejected. A view mode is curation, a fact about what the site can still display, healed because the page, the preview and the client editor's select already agree on a fallback that shows the same thing the document already meant. An alignment is an editorial choice bound by the format's own rules under 0010, the same as a color or a style, so it is refused on save and stripped on load like they are, not healed.

Consequences

The three default blocks now store block_settings of their own, so the shipped format and the demo recipe are no longer empty there, and ShippedConfigurationTest, FilterConfigurationTest and EditorPluginTest fixtures that used heading as an example of a block storing nothing were changed to use a block that still does. Amended 2026-09-20. The heading block's own defaults and the shipped format permit levels 2 to 6, and the demo recipe's narrower format permits 2 to 4, because a document nearly always sits on an entity whose title the theme renders as the page's h1, the same reason core's Basic HTML editor enables heading2 to heading6 only; level 1 stays a level any format may permit, for an entity whose title is not rendered, the labels stay the level numbers as CKEditor 5's do, so an author used to Notion finds Heading 2 as the first entry, a stored level 1 heading under a format that no longer permits it is refused on save and opened at level 2 by the load strip like any other unpermitted level, and the post update above now fills a format that stored no heading settings with these defaults rather than with every level, which no site is affected by because no release preceded this change.

The runtime policy object sent to the client grew by two members, alignments and blocks, the latter carrying the three default blocks' own settings; RUNTIME_VERSION is unchanged, because the growth is additive.

left is a flag whose only visible effect is the toolbar button; a site builder turning it off does not narrow what a document may hold.

The alignment vocabulary, its validator and its client strip are shared with any other block that declares textAlignment, keyed on the declared property rather than a list of block types, so a later block gains the same enforcement the moment it declares the property, with no change to this mechanism.

A site that installed before this change has an incomplete stored format, editor entity or widget form display component until the post update runs; only the editor entity is actually refused by typed-config validation in the meantime, because filter.format.* and core.entity_form_display.*.*.* do not opt into FullyValidatable in this version of core, so ValidKeysConstraintValidator returns before its required-key check ever runs for either, while editor.editor.* does opt in, which is why only that one is flagged. Nothing was broken at render or in the editor either way, and the post update completes all three at once regardless of which validation would have caught.

References

@blocknote/core and @blocknote/react (vendored under node_modules/): src/blocks/Code/helpers/render/createCodeBlock.ts, src/blocks/defaultBlockTypeGuards.ts, src/api/nodeConversions/nodeToBlock.ts, src/editor/BlockNoteEditor.ts; src/components/FormattingToolbar/FormattingToolbar.tsx, src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx, src/components/FormattingToolbar/DefaultButtons/TableCellMergeButton.tsx, src/components/TableHandles/TableCellButton.tsx, src/components/TableHandles/TableCellMenu/DefaultButtons/SplitButton.tsx.

Drupal core: web/core/lib/Drupal/Core/Config/Schema/Mapping.php, for how FullyValidatable makes every declared key required; web/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ValidKeysConstraintValidator.php, for the required-key check that returns before it runs at all when the config's absolute root type has not opted into FullyValidatable.

This module: src/Plugin/Filter/BlockNoteFilter.php, src/Document/DocumentValidator.php, src/Render/RenderPolicy.php, src/Render/StyleVocabulary.php, src/BlockNoteBlockBase.php, src/Plugin/BlockNoteBlock/Heading.php, src/Plugin/BlockNoteBlock/CodeBlock.php, src/Plugin/BlockNoteBlock/Table.php, src/Plugin/Editor/BlockNote.php, config/schema/blocknote.schema.yml, blocknote.post_update.php, js/src/policy.ts, js/src/schema.tsx, js/src/toolbar.tsx, js/src/blockmenu.tsx, js/src/stripped.tsx, and the tests these files carry.

0002, 0005, 0010, 0011, 0015, 0016.