Extending BlockNote¶
This is the contract a module outside this repository may build on. A block type is declared twice, once in PHP and once in JavaScript, and the two halves have to agree: the PHP plugin is what authorises a block to be stored and what renders it on the public page, and the client specification is what lets an author edit it.
Everything below exists in the tree and is exercised by tests. The one worked example that proves it end to end is tests/modules/blocknote_test, described at the end. docs/rendering.md is the public HTML contract and is a separate document.
The client runtime is at contract version '1' and the module has not been released, so the version has not yet had to be raised for a change. Once 1.0 is tagged, anything an extension can notice about the shape of Drupal.blocknote raises it.
1. The PHP block plugin¶
A block type exists for Drupal only if a plugin declares it. Plugins live in Plugin/BlockNoteBlock/ of any module, extend Drupal\blocknote\BlockNoteBlockBase and carry the #[BlockNoteBlock] attribute. Two ids are the module's own and no plugin may take either: blocknote_unsupported, the editor's carrier for a block a format cannot open, and blocknote_unconverted, the converter's block for markup it could not convert (0040). The plugin manager leaves out a plugin a module declares for either or alters in, logs it as an error and says so on the status report.
#[BlockNoteBlock(
id: 'sample_callout',
label: new TranslatableMarkup('Sample callout'),
description: new TranslatableMarkup('A short aside set apart from the text.'),
content: 'inline',
version: 1,
library: 'my_module/block.sample_callout',
template: 'blocknote-block--sample-callout',
)]
class SampleCallout extends BlockNoteBlockBase {}
| Attribute property | Meaning |
|---|---|
id |
The block type, spelled exactly as BlockNote writes it in the document JSON. The same string identifies the block on both sides. |
label |
The name an administrator sees on the text format form. |
description |
Optional. What the block is for. |
content |
The kind of content the block holds, in BlockNote's own vocabulary: inline, none, table or plain. Defaults to inline. |
version |
The block's contract version, raised whenever the property schema or the stored shape changes in a way the client specification has to change with. Defaults to 1. |
library |
The Drupal library that registers the client specification. The base module's own blocks, the fourteen default blocks and the table of contents, declare none, because their specifications are in the editor bundle. |
template |
Optional. The base name of a Twig file, without .html.twig, in the providing module's templates/ directory. NULL means the block renders through the generic blocknote-block template. See 4. |
variables |
Optional. The theme variables the block's template needs beyond the standard ones, as an array of default values keyed by variable name. See 4. |
A block type holds no colon, and the attribute has no deriver property. BlockNote puts a block type into a CSS selector without quoting it to recognize pasted HTML, a colon makes that selector invalid, and one such type would break pasting for every block in the editor. A deriver names its plugins base_id:derivative_id, so blocks cannot use one yet. Discovery stops with an exception that names the type and the module that declares it, wherever the definition comes from, at a cache rebuild or the first request after one. The restriction is planned to go once BlockNote quotes that selector, as BlockNote #3285 asks. Inline content is not affected, and its attribute keeps deriver.
The interface¶
Drupal\blocknote\BlockNoteBlockInterface is what the rest of the module calls. Every method has a working default on BlockNoteBlockBase, so a plugin overrides only what it changes.
| Method | What it answers |
|---|---|
label() |
The human-readable name. |
getContentKind() |
inline, none, table or plain. |
getListType() |
ul, ol or NULL, which is how the renderer groups a run of list items into one list element. |
getBlockVersion() |
The contract version, from the attribute. |
getLibrary() |
The client library, from the attribute. |
getPropSchema() |
The properties a block of this type may carry, as PropSpec objects keyed by property name. |
validate($block, $context) |
Checks one stored block and reports what is wrong with it. |
getReferencedEntities($block) |
The entities one block references, which is the seam file-usage accounting is built on. |
build($block, $context) |
The render array for the public page, or [] to render nothing. |
Drupal\blocknote\BlockNoteBlockManager is the plugin manager. Beside the usual plugin manager methods it answers getBlockIds(), getBlockLabels(), getBlockVersion(), getBlockLibrary() and getBlockTemplate(). getBlockIds() and getBlockLabels() list every plugin; getBlockVersion(), getBlockLibrary() and getBlockTemplate() each answer for one block id, and answer 1, NULL and NULL for a type no plugin provides.
Injecting services¶
A plugin that needs a service takes it through ContainerFactoryPluginInterface and a create() method, the way core plugins do. Do not declare the injected properties readonly. BlockNoteBlockBase and BlockNoteInlineContentBase both extend core's PluginBase, which uses DependencySerializationTrait, and below PHP 8.4, which includes the PHP 8.3 Drupal 11 still supports, that trait's __wakeup() cannot initialize a readonly property declared in a subclass. Such a plugin works until something serializes it and then fails with Cannot initialize readonly property ... from scope Drupal\Core\Plugin\PluginBase; the text format form is one such place, because it caches its form state between the request that builds it and the request that saves it. Either leave the properties writable, or use DependencySerializationTrait in the plugin class itself, which puts __wakeup() in the scope that declares them and is what this module's own plugins do.
Declaring properties¶
BlockNote expresses a property as a primitive default with an optional list of permitted values. Nothing richer is available, because ProseMirror stores properties as node attributes and drops any attribute the node specification does not declare. Drupal\blocknote\Document\PropSpec says the same thing in PHP:
public function getPropSchema(): array {
return static::defaultProps() + [
'tone' => PropSpec::string('tone', 'info', ['info', 'warning', 'danger']),
'collapsed' => PropSpec::boolean('collapsed', FALSE),
];
}
PropSpec::string() and ::number() take the property name, a default, optionally the permitted values, and whether the property may be absent or null. ::boolean() takes the name, a default and the optional flag only, because a boolean's permitted values are already fixed; pass the flag by name, optional: TRUE, as Heading does. BlockNoteBlockBase::defaultProps() returns the backgroundColor, textColor and textAlignment triple that most of the default blocks share.
An undeclared property on a stored block is refused, not dropped. ProseMirror already drops undeclared attributes on the client, so an undeclared property arriving at the server means version drift or a hand-written payload, and dropping it would contradict the rule that content is never lost silently.
One property is never declared: blockStyle, which holds the style a block carries (0046). DocumentValidator checks it for every block against the styles the format lists for the block's type, and takes it off the block before validate() and the property schema see it, so a block another module provides takes styles with no code of its own, and a site lists them in the format as it does for the module's own blocks. Do not declare a property of that name.
A stored value outside a declared property's values list is refused on save the same as an undeclared property. On the client, a block a module registers that holds a value outside the values its specification declares has the property's default instead when the document loads, and the notice above the editor names the change, as it names a color the format forbids, in the words the registration's propLabels gives (Naming a block's props to the author); a specification built for one format, such as BlockNote Extras' callout declaring the tones the format offers with the first as the default, is how a block's own format setting gets that treatment. An editor extension keyed on the built schema (js/src/policy.ts's blockPropValuesPolicyExtensions()) resets such a value to the default as soon as a change brings one in, so a value the editor's own conversions or a paste bring in never reaches a save.
Validating a block¶
BlockNoteBlockBase::validate() already checks the property schema and the content kind. A plugin that adds checks of its own calls the parent first, then reports into the context:
public function validate(array $block, BlockValidationContext $context): void {
parent::validate($block, $context);
if (($block['content'] ?? []) === []) {
$message = $this->t('it needs a title or some content', [], [
'context' => 'Follows "The <block> block cannot be saved:"',
]);
$context->error('sample_callout_needs_title_or_content', [], $message);
}
}
Drupal\blocknote\Document\BlockValidationContext holds error kinds and their placeholder arguments, never finished messages, so the layer that turns them into violations decides the wording and a kind can be counted, grouped or suppressed. It carries four public readonly properties:
blockId, the id of the block being checked, so a plugin never repeats it in a message.blockType, the block type.settings, the text format's settings for this block, which isblock_settings[<block id>]from the format's policy and[]for a block with none. This is what lets a per-format setting be enforced at validation, not only offered in the editor.occurrence, which block of its type this one is in the document, counting from 1 in document order, a block before its children. A block a document may hold once refuses every one after the first, as the table of contents does withtable_of_contents_repeated.
error(string $kind, array $args = [], ?TranslatableMarkup $message = NULL) reports one problem. The context's own kinds are UNKNOWN_PROP, INVALID_PROP_TYPE, INVALID_PROP_VALUE and DANGEROUS_MEDIA_URL, each of which the document validator has a message for. A kind a plugin defines for itself has no such message, so pass the third argument: it becomes the @problem placeholder of the generic block_refused violation, 'The @type block cannot be saved: @problem.', where @type is the block's own label. Write the message as a sentence of its own, which reads right without the words before the colon, lower case because it follows one, and with no trailing period, which the frame adds: the example above reads "The Sample callout block cannot be saved: it needs a title or some content." Refer to the block as "it" rather than by name, and never write a sentence that continues the frame's: a language that marks a noun's case has to decline the block's label differently for "has no media item" and for "uses a media type", and the frame cannot do that. When there is something the author can do, say it after a semicolon in the same sentence, as the media block's "it has no media item; choose one with Select media, or delete the block" does, and name a control by the words on it. Pass the context shown above on the t() call: a translator working from the extracted string alone has no way to see what it follows. Without a third argument the author is shown the kind's machine name in that same frame, which reads badly and is not a message to ship. Every kind of your own is coded block_refused on the violation, and the kind itself is the violation's cause, which is how a workflow tells one of your refusals from another (docs/workflows.md section 4 lists the codes): name it after your plugin, as Heading's heading_forbidden_toggle is, so that it is never another plugin's too. 0023 is the record for this and for every other author-facing string's wording.
Narrowing a property to the format's settings¶
A PropSpec cannot see the format's settings, because it describes the block's contract, not one format's use of it. A block whose format settings narrow what a property may hold overrides getPropSchemaFor(array $settings) instead of getPropSchema():
public function getPropSchemaFor(array $settings): array {
$schema = $this->getPropSchema();
$schema['level'] = PropSpec::number('level', static::defaultLevel($settings), static::permittedLevels($settings));
return $schema;
}
validateProps() calls getPropSchemaFor($context->settings) where it used to call getPropSchema() directly, so the override must return the same keys getPropSchema() does, only with narrower values or a different default, never a different key set: a stored document may still carry a property the client schema no longer offers, and it has to keep being checked against something. Heading narrows level to the format's permitted levels this way, and CodeBlock narrows language to the format's language list when it configures one.
The HTML converter builds a block from getEditorPropSchemaFor(array $settings), the properties the editor writes for the block in the format, so that the editor opens a converted document unchanged. It returns getPropSchemaFor() unless overridden. A block whose client schema leaves a property out in some formats overrides it to leave the property out too: Heading leaves out isToggleable where the format permits no collapsible heading, as the editor's heading declares it only where it does.
Healing a stored property on save¶
Some properties are not policy in the way validate() enforces one: a stored value can drift from what a format currently offers without being wrong, the way a view mode a format has since stopped permitting is not a corrupt document but a fact the page already works around. For a property like that, refusing the save is the wrong answer; rewriting the stored value to what the format permits, once, is the right one. A block plugin that needs this implements Drupal\blocknote\BlockNoteBlockNormalizableInterface:
public function normalizeBlock(array $block, array $settings): array {
// Read the stored property, resolve it against $settings, and return the
// block unchanged unless it needs to be healed.
}
normalizeBlock() is called from DocumentNormalizer::normalize(), itself called from DocumentUpdater, after the version is stamped and before the document is validated, so what is validated and stored is already healed. It is never called on the widget's form save and never called by the editor: writing a property when a block merely loads would mark a document changed for being opened, which is why healing is a write-path concern and not a read-path one. $block is one stored block and $settings is this block's settings as the format stores them, the same array validate() receives as BlockValidationContext::$settings. The result must keep the block's type, id, content and children, must be idempotent, and must be returned as the argument, unchanged, when there is nothing to heal, so a caller can tell whether anything changed. A value that is not of the declared type is left alone, for validate() to refuse.
The media block's view mode is the worked example: a viewMode the format no longer permits is rewritten to the default view mode, the same one the page, the preview and the editor's view mode control already fall back to, so normalizeBlock() only ever stores what is already being shown.
Declaring referenced entities¶
getReferencedEntities($block) returns the entities one block instance references. BlockNoteBlockBase answers with an empty array. The answer feeds file usage and nothing else: the rows the module writes when a host entity is saved, so a file a document names is not deleted by cron. It reaches no render path. A block that points at an entity resolves it in its own build(), through resolveMediaUrl() for a file identity or through a lazy builder as the media block does, and decides for itself what a missing or inaccessible entity shows.
A block that under-reports its references silently breaks file usage accounting for its own content. The module's own blocks are tested; a third-party block is responsible for its own.
Building the public output¶
build($block, $context) returns a render array, or [] to render nothing. $context is a Drupal\blocknote\Render\BlockRenderContext, and it is how a block reaches everything around it:
| Member | What it is |
|---|---|
buildChildren($block) |
The render array for the children of the block being built, all of them and in order, which is how a block renders its children: themedBlock() puts it in #children. A heading among them gets its id from its position among them, so this is what keeps each one on its own id. |
buildBlocks($blocks) |
The render array for a list of blocks, counted as the children of the block being built. It must be given that block's whole children list, in order: a slice or a reordered list is counted from its first member, and its headings render with the ids of others. Use buildChildren() instead. |
buildInlineContent($content) |
The render array for a block's inline content, with the module's escaping and link policy applied. |
buildPlainContent($content) |
The string for plain content, which is what a code block holds. |
resolveMediaUrl($url) |
A ResolvedFileUrl when the URL is a file:<uuid> identity, NULL when it is not and should be sanitised as before. See 8. |
outline |
The DocumentOutline of the document being rendered: headings() lists every heading the page renders, in document order, each an OutlineHeading with its level, its plain text, whitespace collapsed and trimmed, and its id. It is computed before the first block is built, so a block sees the headings after it as well as those before. The table of contents block, TableOfContents, is built on it. |
headingId() |
The id of the heading being built, or NULL for any other block and in a context not built by DocumentRenderer::build(). A heading is any block the outline holds, as the next section describes. The heading block's own build() is the one caller in the module. |
isEnabled($type) |
Whether the format enables a block type. |
debug($message, $arguments) |
Logs to the blocknote channel at debug level. |
urlSanitizer |
The UrlSanitizer, which is the only way a URL should reach output. |
policy |
The RenderPolicy: what the format permits inside a document. |
filterSettings |
The BlockNote filter's settings, including block_settings[<block id>]. |
langcode |
The language the document is rendered in. |
enabledBlocks |
The block types the format enables, or NULL outside a format. |
fileUrlResolver |
The file resolver, or NULL in a context built without one. |
BlockNoteBlockBase::themedBlock($block, $context, $extra = []) is the usual way to answer: it builds the theme element for this block type with the standard variables, the block classes and the children, and adds $extra beside them. It is a union rather than a merge, so $extra supplies variables the standard set does not have and cannot replace one it does: #theme, #block_type, #props, #attributes, #content_attributes, #content and #children in $extra are discarded. To change a standard variable, assign on the array themedBlock() returns, as Heading and CodeBlock do. To put classes on the outermost element, override the protected blockAttributes($type, $props, $policy) and append to what parent::blockAttributes() returns, which is what the sample callout in tests/modules/blocknote_test does. parent::blockAttributes() ends with the classes of the block's style, when the format lists it, so a block that builds its outer element through it carries its style on the page, and once build() returns the renderer puts them back at the end of #attributes['class'], so they stay last whatever the block appends; one that builds the element otherwise emits none, and a template that does not print attributes drops them.
Taking a place in the document's outline¶
A block whose instances are headings of their document, such as a chapter title kept in a property, implements Drupal\blocknote\BlockNoteBlockOutlineEntryInterface:
public function outlineEntry(array $block, InlineContentRenderer $inline_content): ?array {
$title = $block['props']['title'] ?? '';
return $title === '' ? NULL : ['level' => 2, 'text' => $title];
}
Before the first block is built, the renderer asks every block the page renders whose plugin implements it, in document order, a block before its children, and each one that answers with a level and a text is a heading of the outline. Its level is clamped to 1 to 6 and its text has its whitespace collapsed; an id is made from the text the way a heading's is (docs/rendering.md section 5), numbered when it repeats any heading's before it, and headingId() answers it while the block is built, for the block to print on its element. The table of contents lists it with the module's own headings. $inline_content->plainText() reads the words of a run of inline content, a link's and a custom inline item's included, which is what the heading block itself answers with. NULL leaves the block out of the outline. The module's own Heading is an implementation on the same terms.
Rendering children otherwise than they are stored¶
A block that refuses some blocks among its children on save, as the callout of BlockNote Extras refuses a heading, may still meet one in a document written without validation, such as one a migration saved. It implements Drupal\blocknote\BlockNoteBlockRenderedChildrenInterface to say what the page renders instead:
public function renderedChildren(array $block): array {
return is_array($block['children'] ?? NULL) ? static::withoutHeadings($block['children']) : [];
}
The renderer asks it for every block of the type it renders, while it lists the document's headings and again before it calls build(), which receives the block holding the children the answer names. A heading the answer no longer holds, such as one it turns into a paragraph, is neither built as a heading nor listed in the outline or a table of contents, and the headings it keeps are counted by their place among the children it returns. The stored document is not changed, and the save still refuses what the block refuses.
Declaring a caption on the server¶
A block whose inline content is a caption to something else it shows, such as the media submodule's drupalMedia, implements Drupal\blocknote\BlockNoteBlockCaptionInterface, which has no methods. It is the server's side of inlineContent: 'caption' in the client specification (Declaring a caption), and the Trimmed formatter reads it: it shows such a block whole and counts none of its caption, as it counts nothing of an image, whose caption is a property. Any other block counts by its content: inline content is text the formatter counts and may cut, table and plain content is text it counts and never cuts, and none counts nothing. A block that implements BlockNoteBlockOutlineEntryInterface and answers with an entry is a heading to it, counted, and cut only when nothing but headings would be shown otherwise. docs/rendering.md section 12 has the rest.
2. Per-format block settings¶
A block that needs a site builder's decision implements Drupal\blocknote\BlockNoteBlockConfigurableInterface. Its settings live inside the BlockNote filter's own configuration, under filter_settings.blocknote.block_settings.<block id>, so they are part of the format and travel with it through export, import and recipes.
| Method | What it does |
|---|---|
defaultBlockSettings() |
Names every key the block reads, with its default. The filter fills a stored array in against this on every save, so a key added in a later release arrives at its default rather than absent. |
blockSettingsForm($form, $form_state, $settings) |
Returns form elements. It is called for every block that has settings, whatever the format's saved block list, with the stored settings or, where the format stores none for the block, the defaults. The filter wraps the elements in a details element of its own, shown as a tab beside its other settings while the block is ticked, and they are already inside the filter's settings tree, so do not set #parents. Returning [] shows no tab at all. |
validateBlockSettings($settings, $element, $form_state) |
Called only when the filter is enabled on the format being saved and the block is ticked, after the block's own elements validated; what an unticked block's tab holds is dropped from the submission and never stored. $element is the block's details element, so errors go on $element['<child>'], and $form_state->setValueForElement() is how a submitted shape is normalised into the stored one. |
calculateBlockSettingsDependencies($settings) |
Dependency names keyed by type, as a configuration entity's calculateDependencies() returns them, for example ['config' => ['core.entity_view_mode.media.wide']]. |
onBlockSettingsDependencyRemoval($settings, $dependencies) |
Receives the dependencies being deleted keyed by type, configuration dependencies as entity objects and modules and themes as names, and returns the settings to store instead, or NULL when nothing changes. |
Declaring a dependency obliges you to handle its removal, and getting that wrong destroys data. FilterFormat::onDependencyRemoval() removes only disabled filters whose provider is being uninstalled; any other dependency removal deletes the format outright, and the fields whose allowed_formats named it go with it. BlockNoteFilter implements RemovableDependentPluginInterface and routes each removal to the blocks that declared it, so a block that drops the deleted name from its settings keeps the format alive. Returning settings that still name the deleted configuration only postpones the deletion.
A block that stores settings must declare blocknote.block_settings.<block id> in its own module's config/schema. The base schema resolves the key as blocknote.block_settings.[%key] with a FullyValidatable fallback that declares no keys, so a block storing something without a schema of its own fails configuration validation loudly rather than silently.
Heading, CodeBlock and Table are the worked examples that ship in the module itself: permitted heading levels and whether headings may be collapsible, a code block's language list and default language, and whether a table permits merged cells, header rows and columns, and cell colors. blocknote_media's DrupalMedia is a submodule's example, for the media types and view modes a format permits, and blocknote_extras' Callout another, for the tones a format offers, which it enforces by narrowing its tone property in getPropSchemaFor().
A block's form may carry a submit button of its own, the way CodeBlock's Fill with common languages does. The filter opens the block's details element again on the rebuild when the triggering element sits inside it, so the button's result is not hidden in a collapsed section; a button with #limit_validation_errors => [] and its own #submit handler rebuilds the form without the format's own validation running, and its value never reaches the stored settings, because the format form cleans it out before the filter's configuration is set.
3. What the editor is told about a block¶
A block that needs to put something in front of the author implements Drupal\blocknote\BlockNoteBlockEditorSettingsInterface, as Heading, CodeBlock, Table and DrupalMedia all do:
public function getEditorSettings(EditorInterface $editor, array $block_settings): array;
It is called once per format for each enabled block that implements it, and what it returns lands at drupalSettings.editor.formats[<format>].editorSettings.blockSettings[<block id>]. A block returning [] is left out, and the whole blockSettings key is absent when no enabled block returned anything.
Nothing returned here is a check. It is what the author is shown. The server decides what may be stored, in validate(), where the same settings arrive as BlockValidationContext::$settings.
editorSettings itself carries thirteen keys, and a client extension may read any of them off the attach context:
| Key | What it holds |
|---|---|
blocks |
The block types the format enables. |
blockVersions |
Each enabled block's PHP contract version, keyed by type. |
blockLabels |
Every block plugin's label, keyed by type, including the blocks the format does not enable, which is how a notice names a block the format cannot hold. |
inlineContent |
The inline content types the format enables. See 13. |
inlineContentVersions |
Each enabled inline content type's PHP contract version, keyed by type. |
inlineContentLabels |
Every inline content plugin's label, keyed by type, including a type the format does not enable, which is how a notice names one the format cannot show. |
ui |
The eight ui toggles: formatting_toolbar, side_menu, slash_menu, link_toolbar, table_handles, file_panel, paste_markdown and click_into_links. All default to on. The first six decide which surfaces the editor renders; paste_markdown decides whether pasted plain text is read as Markdown and preferred to pasted HTML when it looks like Markdown; click_into_links decides whether a plain click on a link places the caret in its text, with Ctrl+click or Cmd+click opening it, or whether a click opens the link as BlockNote does on its own. |
schemaVersion |
The document envelope version. |
policy |
The permitted styles, colours and alignments, whether links are permitted and with which schemes, whether a link may open in a new tab, which is never so while links are not permitted, and blockStyles, the block styles the format lists, keyed by block type, each its classes and its label. |
limits |
maxBytes, maxBlocks, maxDepth, maxTableCells, maxTableColumns and maxDocumentTableCells. The three table members are null when the site sets no such limit, which is the default. |
blockSettings |
What each block's getEditorSettings() returned, keyed by block id. |
inlineContentSettings |
What each inline content type's getEditorSettings() returned, keyed by type. |
singleParagraph |
enabled and allowBreaks: whether this format restricts itself to a single paragraph and, when it does, whether a line break may be inserted. The element the paragraph renders as, wrapper, is a render concern the client has no need of and is not sent. See 0030. |
Two of the editor options an alter receives are already built from policy. links.isValidLink is derived from the permitted link schemes, and BlockNote asks it when a paste, an autolink or an HTML import makes a link. On some of those paths it checks the text that matched while BlockNote stores a different target, so it narrows what the editor offers and does not enforce the policy. tables.headers follows the table block's header_cells setting, and tables.cellTextColor and tables.cellBackgroundColor follow its cell_colors setting AND-ed with the format permitting at least one color of that kind, because upstream offers each as all or nothing. Split and merged cells instead follow the table block's own merged_cells setting: tables.splitCells is set to whatever the format permits, which upstream reads for both its formatting toolbar's merge button and the table handles' own cell menu, so turning merged_cells off removes the merge and split affordances from both surfaces together. The validator refuses a merged cell, a colspan or rowspan above 1, on save when the setting is off. An alter that widens splitCells back on puts the merge and split affordances back in the editor: the server's answer does not move, and neither does the policy extension below.
How the editor keeps to the policy¶
After the alters have run, the module appends its own extensions to options.extensions. In the ranges every transaction changes, the link and colour mark policy removes each link mark whose target the format does not permit and each text or background color mark whose color the format does not permit, as part of that same transaction, so one undo takes back both the change and the removal. Whatever an alter sets, a link or a color mark the format forbids does not stay in the document. The same guard takes the newTab style off a run that carries it while the format forbids a new tab at all, and off a run that carries it while sitting outside any link mark or content link, which typing at the end of a link produces, since the style mark is inclusive and the link mark is not; and, in every link and content link the changed ranges touch, it applies the first run's own flag to every other run in it, so a paste into the middle of a new-tab link gains the flag and a link whose runs disagree is normalized back to what its first run says. A color set as a block's or a table cell's property is a node attribute, not a mark, and the same guard resets one the format does not permit to default in the same ranges, so a color that arrives by paste, or through upstream's row and column handle menu, never stays in the document; a table cell's color is checked against the table block's cell_colors setting as well as the palette. The same guard turns a table header cell into a plain cell in the same ranges when the table block's header_cells setting is off, so a pasted <th>, or a header upstream's row and column handle menu makes, never stays either. In the same ranges, a code block whose language is outside the format's list, which the `` input rule, an HTML paste and a Markdown fence can all produce, is rewritten to the format's default language, because upstream's code block throws on a language it was not given. A block prop that declares avalueslist, such as a heading's level, is clamped back to its default the same way once a value outside that list enters the document after load. A block's style is kept to the format's list in the same ranges: a block whose type changed keeps it only where the new type lists the same classes, written as that type lists them, and a style pasted from outside the editor that the block's type does not list is taken off; the same extension puts each block's style on its element as a decoration, which a paragraph below describes. A format restricted to a single paragraph ([0030](decisions/0030-one-paragraph-formats-and-the-element-they-render-as.md)) gets two more, and a format that is not gets neither: a key guard that consumes Enter, and Shift+Enter as well where the format permits no line break, ahead of the default keymap, and announces the first key it consumes after each focus; and a guard that, in the range a transaction changed, joins the blocks a paste or a drop left beside the one it landed in back into that one paragraph, with a line break between them, or a space where breaks are not permitted, and turns a line break the format does not permit into a space, in the same transaction, so one undo takes back both the change and the join. A block or a line break the transaction did not reach is left as stored. For such a formattrailingBlockis alreadyfalsein the options an alter receives. BlockNote leaves out every extensionoptions.disableExtensionsnames, whatever built it. The link and colour mark policy isblocknoteLinkPolicy, the code block language guard isblocknoteCodeBlockLanguagePolicy, the block style guard and its decoration areblocknoteBlockStylePolicy, the block prop values clamp isblocknoteBlockPropValuesPolicy, the click-to-select rule isblocknoteBlockSelect, the caption key guard of [Declaring a caption](#declaring-a-caption) isblocknoteCaptionKeys, the rule of [Declaring the types a block's children may be](#declaring-the-types-a-blocks-children-may-be) isblocknoteBlockChildren, the single-paragraph key guard isblocknoteSingleParagraphKeys, the single-paragraph paste and break guard isblocknoteSingleParagraphGuard, and the announcement Escape's departure from the editor makes isblocknoteEscapeAnnouncement; each is taken back out ofoptions.disableExtensions` after the alters have run, with a console warning. Every other key an alter put there stands, so an alter may still disable anything BlockNote built.
The link toolbar and the formatting toolbar's link button are the module's own, not BlockNote's. A typed target is read by CKEditor 5's rule, as core configures it: a bare email address gains mailto:, a value that names no scheme and starts with a letter, digit or underscore gains https://, and nothing else is rewritten. The result is checked against the format, and a target the format does not permit is refused in the form, which stays open and names the scheme. The link toolbar and the content link's own toolbar both order their buttons Edit link, Open link, Remove link (or Unlink): Open link opens the target in a new browser tab for the author's own convenience, whatever the document stores. Whether a visitor's own click opens the page's rendered anchor in a new tab is a checkbox inside the link form itself, shown only while the format permits it (policy.links.newTab) and applied with the rest of the form on Save or Enter; 0010 records what each form edits, and 0032 how the choice is stored and kept uniform across a link. No runtime member replaces either surface in 1.0.
A block type the format lists styles for (0046) has the blockStyle property added to its specification's propSchema, { default: undefined, type: 'string' }, before the schema is built, whether BlockNote, the module or an extension provides it, so a registered block takes styles with no code of its own. BlockNote renders a block built through React with the prop schema it was created with, so such a block keeps its style in the document but never writes it to its element; the module draws every block's style as a ProseMirror node decoration instead, its classes on .bn-block-content for a block BlockNote renders and on the .react-renderer wrapper one level further out for a React block. A stored style the format does not list for the block's type is taken off before the document is loaded and named in the notice above the editor, as a forbidden color is, and one holding a listed style's classes in another order is written as the format lists them, with no notice. The formatting toolbar's Style select and the block menu's Style group offer the styles; neither is a runtime member an extension replaces.
When the format does not permit every palette color, the side menu's Colors item and the table cell menu offer only the colors it permits, and so does the Alt+F10 block menu. The table's row and column handle menu keeps BlockNote's full list, because its color items are not exported, and a color the format forbids chosen there is taken back in the same transaction, so the pick does nothing (see 0024). With the table block's cell_colors setting off, no surface offers a cell color at all, through the tables flags section 3 describes. The six surface toggles under ui decide which of these surfaces render.
4. Templates and theme hooks¶
The hook and template naming is docs/rendering.md section 7.
A block ships its own markup by declaring template: on the attribute. The registration then also carries the providing module's templates/ directory as its path, so the file is found wherever the module lives. A theme overrides it the ordinary way, by putting a file of the same name in its own templates/.
A new template needs a theme registry rebuild. The registry is built once and cached, so a template added to a module that is already installed is not found until drush cache:rebuild. That is core's behaviour for every module template and is worth knowing before you conclude the hook name is wrong.
A block declares the theme variables its template needs in variables:. Every blocknote_block__<type> hook is registered with one standard variable set, which is what lets a template written against blocknote_block work for any type, and ThemeManager copies only declared variables out of the render element, so a variable the hook does not name is dropped without a message. The variables property on the attribute is an array of default values keyed by name, and it is added to the standard set when the block's hook is registered. A name the standard set already has is ignored, so a block cannot change what the base template receives, the same rule themedBlock() applies to its $extra argument. The block passes the value through $extra, keyed with a leading #. Drupal\blocknote_media\Plugin\BlockNoteBlock\DrupalMedia declares variables: ['media' => NULL] and passes '#media', and tests/modules/blocknote_test holds a block that does the same with no hook_theme() and no hook_theme_registry_alter() anywhere. ADR 0034 records the decision, which replaces the hook_theme_registry_alter() that ADR 0006 first used for the media block.
The variables every block hook receives, and what a list, a cell and the inline hooks receive, are listed in docs/rendering.md section 7.
5. Uninstalling a module that provides blocks¶
Block providers are not configuration dependencies of a text format, for the reason in 2: removing a dependency deletes the format, which would take the fields pointing at it with it. Instead BlockProviderUninstallValidator refuses to uninstall a module while any enabled text format that has the BlockNote filter turned on still enables one of its blocks or one of its inline content types (13), saying which format and which blocks or inline content. The administrator disables the block or the inline content type in each of those formats first. A block counts when the format's stored block list names it, so a format restricted to a single paragraph (0030), which permits paragraph alone, still refuses for every other block its hidden list names, because turning the restriction off names them again.
Clearing the checkbox is not free, and the administrator should be told what it costs before doing it. In the editor, documents that still contain the block follow the ordinary unsupported-block rule and are carried unchanged until an author removes them. On the public page they do not: DocumentRenderer skips a block whose type the format does not enable and logs one message on the blocknote channel at debug level, which a default site does not record. Every already-published document loses that block's content the moment the checkbox is cleared, with nothing on the format form saying so, and the only copy is the stored JSON, which is unchanged and comes back when the block is enabled again. The status report counts the documents that hold it once cron has checked them, as 0035 describes.
The base module is a block provider too, and the step above cannot be taken for it. blocknote provides the fourteen default blocks, paragraph among them, and the table of contents, and the format form refuses a selection that leaves paragraph out, because the editor creates a paragraph on every Enter. Uninstalling blocknote therefore means turning the BlockNote filter off on each format that has it on, rather than emptying their block lists. The validator reads the filter's status, so a format whose filter is off holds nothing back.
Core's field and filter modules refuse too: the first while any blocknote_document field exists, a deleted field whose values are not yet purged included, and the second while an enabled format has the BlockNote filter on. The order that removes both modules is:
- Remove
drupalMediafrom the block list of every format that enables it, then uninstallblocknote_media. - Delete every
blocknote_documentfield, then run cron until the deleted fields are purged. - On every format that has the BlockNote filter on, set the text editor to None and turn the filter off in one save.
BlockNoteHooks::validateFormat()refuses either change on its own, because the editor and the filter imply each other, and it exempts only the editor select's own AJAX rebuild. - Uninstall
blocknote.
Each format survives without the BlockNote filter. editor.editor.blocknote is deleted by core's own submit handler as soon as the format's text editor is set to None, and a format that reached step 4 still carrying the editor loses it there, because the editor entity depends on the module that provides its plugin. A format whose only filter was the BlockNote one is then an enabled format with no filters, which prints what is typed into it unchanged, and use text format <id> outlives the uninstall, so the administrator disables that format or revokes the permission. Purging a field removes the file usage its documents recorded, through the field type's item list, and dispatches no blocknote.document_deleted, because core purges field values without invoking entity hooks. Disabling a format does not replace step 3: a disabled format whose BlockNote filter is still on is deleted when blocknote is uninstalled, because FilterFormat::onDependencyRemoval() removes only a filter that is turned off.
A disabled format is not checked. BlockProviderUninstallValidator lets a module go while a disabled format still enables its blocks, because nothing renders through or saves into a disabled format. That format keeps the block's block_settings after the provider is uninstalled, with no schema left to describe them, so it fails configuration validation until the settings are removed.
6. The client runtime¶
The editor bundle publishes exactly one global, Drupal.blocknote. It is created on first use and guarded, so loading the bundle twice does not replace a runtime that already has blocks registered against it. Nothing is published as window.React.
An extension resolves React and BlockNote through the runtime rather than bundling its own copies, because two React instances on one page do not work and two ProseMirror schemas do not either.
| Member | What it is |
|---|---|
version |
The runtime contract version, the string '1'. |
blockNoteVersion |
The BlockNote release the bundle was built from, the string '0.57.0'. |
React |
React 19. |
ReactDOM |
react-dom, which is createPortal and flushSync. |
ReactDOMClient |
react-dom/client, which is createRoot. |
jsxRuntime |
react/jsx-runtime. |
core, react, mantine |
@blocknote/core, @blocknote/react and @blocknote/mantine. |
instances |
A Map of attached editors, keyed by the id the bundle gives the textarea. Each entry carries the editor, its textarea element, the surfaces, policy and limits it was built with, resolved, and files, the page's map of uploaded file sources by uuid. The shipped blocknote-runtime.d.ts types each entry as BlockNoteEditorInstance. |
registerBlock(spec, meta) |
Registers one block specification or specification factory. Returns true when it was taken. |
getBlockSpecs() |
The registered specifications, keyed by block type, sorted by type. |
getRegisteredBlocks() |
The same, each entry { meta, spec }. |
registerInlineContent(spec, meta) |
Registers one inline content specification or specification factory. Returns true when it was taken. See 13. |
getRegisteredInlineContent() |
The registered inline content entries, keyed by type, sorted by type, each entry { meta, spec }. |
registerSlashMenuItem(factory, meta) |
Registers one slash-menu item factory. Returns true when it was taken. |
getRegisteredSlashMenuItems() |
The registered item factories, each entry { meta, factory }. |
alterEditorOptions(callback) |
Adds an alter that runs before each editor is created, and returns a function that removes it. |
showRefusal(editor, message) |
Tells the author why a change to the editor's document was refused: speaks the translated message at once and shows it above the editor until the document next changes. See Altering the editor's options. |
applyEditorOptionAlters(), buildSlashMenuItems() and listenForRefusals() are on the object too. They are the bundle's own calls into the registry, not an extension's, and are documented here only so that reading the object is not a surprise.
Take the runtime once, at the top of your entry point, and fail loudly when it is not there:
const blocknote = Drupal.blocknote;
if (!blocknote) {
throw new Error('my_module: this library has to depend on blocknote/editor.');
}
The shipped js/preset/blocknote-runtime.d.ts declares blocknote as an optional member of Drupal, because a page that never loaded the editor bundle does not have it. A TypeScript source under strict that calls Drupal.blocknote.registerBlock() directly is therefore a TS18048, "'Drupal.blocknote' is possibly 'undefined'", and the guard above is what narrows it. The fences below spell Drupal.blocknote out in full so each one reads on its own; both worked examples, tests/modules/blocknote_test/js/sample-callout.tsx and modules/blocknote_media/js/index.tsx, take the guard instead.
Registering a block specification¶
Drupal.blocknote.registerBlock(spec, {
id: 'sample_callout',
runtime: '1',
version: 1,
provider: 'my_module',
});
spec is a BlockNote block specification, normally built with Drupal.blocknote.react.createReactBlockSpec(). It may instead be a factory, a function taking the attach context and returning a specification:
Drupal.blocknote.registerBlock(
(context) => buildSpec(context.format.editorSettings),
{ id: 'sample_callout', runtime: '1', version: 1, provider: 'my_module' },
);
The context is { element, format: { format, editorSettings } }: the textarea the editor is attaching to, the format id, and what the PHP editor plugin computed for that format, including this block's blockSettings entry. The factory is called once per attach, so two editors on one page using different formats get two specifications. Register a factory when the block's editing surface depends on the format's settings, and a plain specification when it does not.
The meta object is the contract:
idis the block type as it appears in the document JSON, and it is the same string as the PHP plugin's id.runtimeis the runtime version the extension was built against.versionis this block's contract version, and it has to equal theversionthe PHP attribute declares. Omitted means1, which is the PHP default.provideris the module name, for diagnostics.inlineContentis optional,'body'or'caption', and is meaningful only for a specification withcontent: 'inline'. Omitted means'body'.'caption'says what a caption refuses: see Declaring a caption.formattingToolbarItemsis optional, the components this block adds to the formatting toolbar. See Contributing to the formatting toolbar and the block menu.blockMenuGroupsis optional, a factory for this block's own groups in the Alt+F10 block menu and the side menu's drag handle menu. See the same section.blockMenuSubscribeis optional, a subscription that tells an open block menu or drag handle menu when somethingblockMenuGroupsreads outside the document has changed. See the same section.childTypesis optional, the only block types the block's children may be, for a block whose children are its items. See Declaring the types a block's children may be.propLabelsis optional, the names the author knows the block's props and their values by, keyed by prop:{ tone: { label: Drupal.t('Tone'), values: { danger: Drupal.t('Danger') } } }. See Naming a block's props to the author.
Registering a block does not authorise it. A block is stored only when a PHP plugin declares it and the text format enables it; a specification registered for a block the format does not enable is never put in the schema.
A registration cannot replace one of the fourteen default block types either. registerBlock() refuses a registration under one of those ids, returns false and names the conflict in a console warning, because BlockNote's own specification goes into every schema before the registered ones and a second one under image or table would never be the one an editor uses. Altering a default block's editing surface is alterEditorOptions(), not registerBlock().
The library that calls registerBlock() has to depend on blocknote/editor and has to be named by the PHP plugin's library attribute, which is what makes the editor plugin attach it for the formats that enable the block and for no others.
Naming a block's props to the author¶
The notice above the editor names a stored value outside the values a block's specification declares, which the editor replaced with the default when the document loaded (Declaring properties). It names the block by its PHP plugin's label, and the prop and the value by propLabels, when the registration gives them, in the words the block's own controls use:
Drupal.blocknote.registerBlock(spec, {
id: 'sample_callout',
runtime: '1',
propLabels: {
tone: {
label: Drupal.t('Tone'),
values: { info: Drupal.t('Info'), warning: Drupal.t('Warning') },
},
},
});
A stored tone the specification does not declare, such as loud, then reads "Sample callout blocks with Tone: loud", and one it declares and a format has stopped offering, for a specification built per format, reads "Sample callout blocks with Tone: Warning". label is the prop's name and values the names of its values, keyed by value as the document stores it, a number written as text; every label is translated and plain text, and Drupal.t() may be called where the registration runs, since core loads its translations before any library. A registration that gives no propLabels, or none for a prop or a value, has that prop or value named by its key in the document instead, "Sample callout blocks with tone: loud", as every registration was before propLabels existed. A propLabels that does not map each prop to an object with a non-empty label, and any values to non-empty text, refuses the registration with a console error, as the other members do. BlockNote Extras names the callout's tones and the media submodule the media block's alignments and the gallery's column counts this way.
Registering a slash-menu item¶
Drupal.blocknote.registerSlashMenuItem(
(editor) => ({
title: Drupal.t('Sample callout'),
subtext: Drupal.t('A short aside set apart from the text.'),
aliases: ['sample', 'callout', 'aside'],
group: Drupal.t('Basic blocks'),
onItemClick: () => insertSampleCallout(editor),
}),
{
id: 'my_module.sample_callout',
runtime: '1',
blockType: 'sample_callout',
provider: 'my_module',
},
);
The factory is called with the editor and with the attach context the editor's block specification factories were built from, the { element, format: { format, editorSettings } } of Registering a block specification, so an item can read its block's blockSettings entry the way the block's own factory does; a factory that needs only the editor may take that alone, as above. It returns one item in BlockNote's own shape, or null to offer nothing. meta.blockType is what gates it: an item whose block type is not in that editor's schema is never offered, so an item does not have to check the format itself. An item joins the group its group names, ahead of the items the bundle offers in that group; one naming a group of its own starts that group after every group the bundle offers. Registered items keep meta.id order among themselves, so the menu does not depend on script order.
Declaring a caption¶
inlineContent: 'caption' says that a block's inline content is a caption to something else it shows, rather than text the block exists to hold, the way a block declared with no inlineContent (or inlineContent: 'body') holds text.
The slash menu is one thing it gates. Inside a block declared this way the slash-trigger character is typed as text instead of opening the menu, because the blocks the slash menu inserts cannot go inside a caption: choosing one would either insert after the block or, when the caption is empty, silently convert the block itself and drop what it was captioning. The side menu's Add block button reads the same declaration: it treats a block declared this way as never empty, even while its caption is content: [], the shape upstream's own check cannot otherwise tell from a block with nothing to lose, so clicking Add block inserts a new block after the captioned one and opens the slash menu there instead of converting the captioned block itself. Everything else about editing the caption is unchanged: styles and links work as they do in any other inline content. The formatting toolbar for a text selection inside the caption offers the text tools alone, without Nest block, Unnest block or the block's own items; see Contributing to the formatting toolbar and the block menu.
A click is the third thing it drives. A plain click on such a block's own content, outside its caption's .bn-inline-content and outside any button, select, input, textarea or a the block draws, node-selects the block and focuses the editor, so upstream draws its selection ring and a contributed toolbar item sees one block of this type selected; the block's formattingToolbarItems are withheld only for a text selection inside the caption, and for every other selection, including this node selection, they are mounted as before, with the item's own check deciding whether the selection is its block. A shift-click, a non-primary button and a click that starts editing the caption are left to ProseMirror. The rule is js/src/portable/blockselect.ts, registered as blocknoteBlockSelect, and options.disableExtensions cannot remove it.
A caption also refuses every block conversion rule and shortcut upstream ships: typing #, >, -, 1., [], a ``` fence or --- at the start of the caption, or pressing a heading, paragraph, quote or code block shortcut (Mod-Alt-0 to -6, Mod-Alt-q, Mod-Alt-c) or a list shortcut (Mod-Shift-6 to -9) with the caret there or the block node-selected, leaves the caption's own block alone and the typed characters as text, rather than converting it the way the same trigger converts an ordinary paragraph. This is js/src/caption-guard.ts's guardCaptionConversions(), called from js/src/schema.tsx before the schema is built; it wraps every other registered block's own conversion rules and shortcuts, so a module author who declares inlineContent: 'caption' gets it for free and does nothing further.
Backspace and Delete at a caption's own boundary are refused the same way, by one more extension: upstream's default keymap binds both directly, outside any block's own rules or shortcuts, and would otherwise revert, merge or drop a caption block the same way its conversion rules would. js/src/portable/caption-keys.ts node-selects the caption instead; it is registered as blocknoteCaptionKeys, which options.disableExtensions cannot remove either. The join a guard looks at is the one upstream would make, nested blocks included: the block's first child, next sibling or an ancestor's next sibling for Delete, and its previous sibling's last descendant, or its parent, for Backspace. Backspace at the start of a block upstream resets first, such as a heading or a list item, which becomes a paragraph, is left to upstream whatever is before it, because the reset joins nothing; the next press is the one the guard answers. A second Backspace or Delete then deletes the selected block, and one undo restores it. Enter with a caret is upstream's own: at the start of a caption that holds text, upstream inserts an empty paragraph before the block and leaves the block, its caption included, as it was. Enter with text selected from the caption's start, which Ctrl+A or Cmd+A in a caption gives, would split the block keeping its type and props on both halves, so the same extension deletes the selection first and upstream's Enter for a caret runs on what is left. A selection that runs on from the caption into the blocks after it is deleted without taking the caption block along, so the caption stays and takes in what is left of the block the selection ends in. A selection that runs back from the caption's start to partway through a block before it joins the caption into that block, losing what it captioned, and Backspace or Delete with a selection from the caption's start to past its end, or back to partway through the block before it, loses it the same way.
The formatting toolbar's block type select is guarded too: a selection that starts in another block and runs into a caption converts every selected block except the caption (js/src/toolbar.tsx, PolicyBlockTypeSelect).
A caret in a table cell is refused the same way, by upstream's own rule, without any declaration: a cell holds inline content too, and the slash menu does not open there either.
Declaring the types a block's children may be¶
BlockNote lets any block be nested under any other, and a block's specification cannot narrow what it holds. A block whose children are its items, such as BlockNote Media's gallery, whose children are media blocks, names the types they may be instead:
Drupal.blocknote.registerBlock(albumSpec, {
id: 'album',
runtime: '1',
provider: 'my_module',
childTypes: ['picture'],
});
The editor keeps the rule in the same transaction as the change that broke it, so one undo takes back both. A child of another type, dropped or nested into the block, moves out to right after it, with its own children. A child's own children move up beside it: inside the block when their type is one of childTypes, and out after it otherwise. A selection inside a block the rule moves stays in that block, at the same place, so text typed after Enter splits a child goes into the paragraph that moved out. The block goes on the change that takes its last child away, since it holds nothing else, and where it was the one block of its document an empty paragraph takes its place. A block that holds no children before a change, because it was inserted empty to be filled later or stored that way, stays, whatever the change. A carrier, the read-only block standing for one the format cannot open, stays where it is. The rule is js/src/portable/children.ts, registered as blocknoteBlockChildren, and options.disableExtensions cannot remove it.
The block menu treats the children as the block's items: with the caret in one, it offers the block's own blockMenuGroups after the child's, no Indent, which would nest the child under the one before it, and no Outdent, which would take it out of the block. Move up and Move down reorder the children and are not offered at either end, and a block right after the block gets no Indent, which would make it a child. A block next to the block moves past it as a whole, where BlockNote's own move would put it inside. Shift+Mod+ArrowUp and Shift+Mod+ArrowDown move blocks the same way.
The rule does not reach BlockNote's keyboard, which moves a child out a level on Shift+Tab and on Enter in its empty content, taking the children after it along. A block whose children should stay in it says so in its specification, with BlockNote's experimental keyboard option, as the gallery does:
const albumSpec = Drupal.blocknote.react.createReactBlockSpec(albumConfig, {
render: Album,
experimental_keyboard: { childrenCanOutdent: false, emptyChildEnter: 'stay' },
})();
Enter in a child's empty content then gives the child a paragraph after it, which the rule moves out to right after the block, with the caret. The editor keeps the rule for the author and nothing more, so the PHP plugin's validate() refuses a block holding any other, as the gallery's does.
Contributing to the formatting toolbar and the block menu¶
Drupal.blocknote.registerBlock(spec, {
id: 'sample_callout',
runtime: '1',
version: 1,
provider: 'my_module',
formattingToolbarItems: [ToneButton],
blockMenuGroups: (editor, block) => [
{
key: 'tone',
label: Drupal.t('Sample callout'),
entries: [
{
key: 'warning',
label: Drupal.t('Warning'),
role: 'menuitemradio',
checked: block.props.tone === 'warning',
run: () => editor.updateBlock(block, { props: { tone: 'warning' } }),
},
],
},
],
});
formattingToolbarItems is an array of components, rendered where upstream draws a block's own buttons: after the file buttons and before the text style buttons, whichever styles the format permits. Each is rendered for every editor whose schema holds the block, and renders null unless the selection is exactly one block of this type, which it decides by reading the editor with useBlockNoteEditor() and the selection with useEditorState(), the way upstream's own FileCaptionButton does. For a block declared inlineContent: 'caption' the bundle goes further: its items are withheld only for a text selection inside the caption, where upstream's text tools render alone instead; for every other selection, including the node selection a click on its content outside the caption or Backspace at the caption's start produces, they are mounted as before (0025). Ahead of that placement rule, the button is drawn through useComponentsContext() on Drupal.blocknote.react, so it carries the kit's markup, classes and data-test attributes, the same as every other toolbar button.
blockMenuGroups is a factory called on every Alt+F10 open with the editor and the block holding the caret, and returns the block's own groups, which the bundle places ahead of its own, each group key prefixed with the block type so it cannot collide with the bundle's own. The same groups open the drag handle menu, the menu the side menu's drag handle opens beside the block under the pointer: the factory is called with that block whenever the menu opens, and its groups, then the Style group and, for a child of a block registered with childTypes, that block's groups, come in the block menu's order ahead of BlockNote's Delete and Colors. A pointer thereby reaches every function the block offers the keyboard, including on a new block with nothing selected, where BlockNote shows no formatting toolbar. Each group is drawn as the block menu draws it, as a named group with its label above its items, a radio or checkbox entry keeps its role and checked state, an entry with a submenu opens it beside the menu, and choosing an entry runs its run and gives focus back to the editor, so run serves both menus unchanged. An open block menu calls it again, with the block as it now is, whenever the document or the selection changes, and an open drag handle menu whenever the document changes, so an entry checked from a prop follows an undo or a change from another source, and the menu keeps focus on the item that had it by the item's group and entry keys: return the same key for the same entry every time, and a key that is unique within its group. The factory must not change the document: it runs on every open and every change, so a write made while it builds would run again with each rebuild. A change reported while a menu builds makes it build once more afterwards, so a factory that writes asks for another build every time: the menu stops after three such further builds in a row, shows the last and logs an error, and every write is an undo step. Make the change in an entry's run instead. 0011 requires a keyboard route for every function a toolbar item offers, so a block that contributes a toolbar item gives the same function a block menu entry too. The shipped blocknote-runtime.d.ts types an entry as BlockNoteBlockMenuEntry and a group as BlockNoteBlockMenuGroup. It types the factory's own two parameters as well: editor as BlockNoteEditor<any, any, any> and block with the broad per-format schema types, because an editor carrying those broad types would refuse editor.updateBlock(block, { props: ... }), the call the example above exists to make; 0014 records why. js/src/runtime.ts's BlockMenuGroupsFactory is the same pair in the bundle's own source, and modules/blocknote_media/js/library.ts's MediaEditor meets the same limit outside a menu factory, typing updateBlock()'s update as a loose record inside a structural type of its own.
blockMenuSubscribe is for a factory that reads anything the document does not hold, such as what a request has said about the item a block names. Either menu calls it when it opens for a block of this type, with a callback and the editor, and calls the function it returns when it closes; the block calls the callback whenever what its factory reads changes, and the open menu is built again. A call made while the menu is building, as from inside the factory when a store learns something as it is read, does not start a build inside that one: the menu builds once more when it is done, however many calls were made meanwhile. It has the shape of the subscribe React's useSyncExternalStore() takes, with the editor after the callback, so a store's own subscribe function can be passed as it is:
// detailsOf(url) reads what the server has said about an embedded URL so far,
// and subscribeDetails(listener) calls the listener whenever it learns more,
// returning a function that removes the listener.
Drupal.blocknote.registerBlock(spec, {
id: 'my_embed',
runtime: '1',
provider: 'my_module',
blockMenuGroups: (editor, block) =>
embedGroups(editor, block, detailsOf(block.props.url)),
blockMenuSubscribe: subscribeDetails,
});
The block menu closes, giving focus back to the editor, once the caret is no longer in the block it was opened for, as when a change from another source removes the block. modules/blocknote_media/js/blockmenu.ts's subscribeMediaBlockMenu() is the worked example: the media block's group gains Alternative text, and its View mode entry, as soon as the preview request says what the item is, even when the author pressed Alt+F10 before it answered.
A toolbar item or a block menu factory that offers only what the format permits reads the resolved policy off its instance: iterate Drupal.blocknote.instances for the entry whose editor is the one in hand, which exists before the toolbar first renders. Its policy is the wire policy of section 3 plus blocks, built from blockSettings: blocks.heading.levels, defaultLevel and allowToggleHeadings, blocks.codeBlock.defaultLanguage and languages, blocks.table.mergedCells, headerCells and cellColors. policy.alignments may leave out left, which is always permitted. blocknote-runtime.d.ts types it as BlockNoteEditorInstance['policy']; modules/blocknote_media/js/settings.ts's policyAlignments() is the worked example.
A component that throws while rendering, or a factory that throws while building groups, is caught: the item or the group is left out and the rest of the toolbar or menu is built, the same way a throwing slash-menu item factory is isolated. A blockMenuSubscribe that throws is caught the same way: the menu opens and follows the document, but not what the factory reads outside it. A function it returns that throws when the menu closes is logged and the rest of the menu is ended as usual. Once the menu has closed, a callback a subscription left registered does nothing, so a subscription that threw after adding its listener cannot close a menu opened later. See the refusal table below for what is logged.
Altering the editor's options¶
Drupal.blocknote.alterEditorOptions((options, context) => {
// context.element is the textarea, context.format is what core passes to
// Drupal.editors.blocknote.attach(): { format, editorSettings }.
});
An alter may add BlockNote extensions of its own to options.extensions, for the formats it reads off context. BlockNote Extras adds one, for a format that enables its callout, that refuses a change putting a heading inside a callout: modules/blocknote_extras/js/headings.ts subscribes to BlockNote's own onBeforeChange() when the editor mounts and cancels the transaction, which needs nothing from ProseMirror an extension cannot import. It tells a heading the document already held apart from one the change brings by where the heading stood before the change, read through the transaction's own mapping, rather than by its id, which a copied block keeps until BlockNote gives it one of its own.
A refused transaction leaves the document as it was, which an author who sees only the editor cannot tell from a key that did nothing, so an extension that refuses one calls Drupal.blocknote.showRefusal(editor, message) with the reason, translated and in plain text. The runtime speaks it assertively through Drupal.announce() and shows it in a notice above the editor, .blocknote-editor__refusal, which goes with the next change to the document; a second refusal replaces the first. Write the message by 0023's rule, with ! placeholders, since it is shown as text. The callout's guard says "A callout cannot hold a heading."
The alters run in registration order, before the editor is created and before the document is walked for blocks the schema cannot hold. That ordering is deliberate: an alter that brings its own block specifications gets its blocks rather than carriers. An alter that throws is caught and logged, and the remaining alters still run. The function alterEditorOptions() returns removes the alter for the editors attached after it is called; an editor already created keeps what the alter did to its options, and calling the function again does nothing.
An alter may replace options.schema, but the schema it leaves behind must contain both the blocknote_unsupported block type and the blocknote_unsupported_inline inline content type. Those are the carriers a block or an inline content item the format cannot open is wrapped in (13), and without either such content would have nowhere to go, so a schema that lacks one makes the attach fail loudly with the raw value left in front of the author rather than silently dropping content. Extend the schema you are handed rather than building a new one.
Decoration the policy forbids is handled the same way and reported separately. A style or a colour the format does not permit is left out of the document the editor loads, the author is shown a notice naming what was removed, and the stored value is not rewritten, so nothing is lost until the author saves. A key that is no mark at all, a forbidden style or new-tab flag set to false, or default or an empty name on a color the format permits none of, is left out the same way but not named, because a save accepts it as stored. The created editor reports that as stripped, beside excluded, which names the marks it left off a run because another mark on the same run excludes them (0037), and carried, which names the block types the format cannot hold at all. Which marks exclude which is read from the schema the editor is built with, after the alters have run, so a style an alter registers meets code the way a built-in style does: the run loads without code, and excluded names it. The server's list of the pairs it refuses is fixed, because the server refuses a style key it does not know on its own. After a refused save, while the field is in error, the stripped document is written into the textarea at once, because the textarea then holds the refused submission and not stored data, and the notice's hint says the next save stores it this way.
What is refused, and what it looks like in the console¶
The distinction the bundle draws is that a defect in an extension is an error and a refusal this contract documents is a warning, because the module's own browser tests treat a console error as a page defect and are expected to provoke the documented refusals.
That detection is the module's own, not core's. Core's browser-test log, js_testing_log_test, records console.warn and uncaught window errors and neither a console.error call nor an unhandled promise rejection, which fires unhandledrejection rather than error. tests/modules/blocknote_console_test records both into a store of its own, and BlockNoteEditorTestBase::assertNoConsoleErrors() reads that store, so a console error or an unhandled rejection does fail a browser test here. An extension's own browser tests inherit nothing of this from core: enable the same test module, or install a spy of your own before the editor attaches.
| What happened | Level | Outcome |
|---|---|---|
registerBlock() with no meta.id, or with a spec that is neither a function nor an object |
console.error |
Refused. |
registerBlock() with an inlineContent other than 'body' or 'caption', a formattingToolbarItems that is not an array of components, a blockMenuGroups or blockMenuSubscribe that is not a function, a childTypes that is not a list of block types, or a propLabels that does not give each prop a label and each named value text |
console.error |
Refused. |
registerBlock() with meta.runtime other than '1' |
console.warn |
Refused, naming both versions. |
registerBlock() for an id already registered |
console.warn |
The second registration is ignored. |
registerBlock() for one of BlockNote's own block types |
console.warn |
Refused, naming the conflict. |
registerInlineContent() with no meta.id, or with a spec that is neither a function nor an object |
console.error |
Refused. |
registerInlineContent() for one of BlockNote's own inline content types, text or link |
console.warn |
Refused. |
registerInlineContent() with meta.runtime other than '1' |
console.warn |
Refused, naming both versions. |
registerInlineContent() for a type already registered |
console.warn |
The second registration is ignored. |
registerSlashMenuItem() with no id, no blockType, or a factory that is not a function |
console.error |
Refused. |
registerSlashMenuItem() with the wrong runtime, or a duplicate id |
console.warn |
Refused. |
| A slash-menu item factory throws | console.error |
That item is not offered; the menu is built. |
A blockMenuGroups factory throws |
console.error |
That block's groups are left out; the menu is built. |
A blockMenuGroups factory changes the document, or what it reads, in every build |
console.error |
The open menu stops building again after three further builds in a row and shows the last; the next change builds it again. |
A blockMenuSubscribe throws |
console.error |
The menu opens without following what that block's groups read outside the document. |
The function a blockMenuSubscribe returned throws when the menu closes |
console.error |
The menu closes and its other subscriptions end. |
| A contributed formatting toolbar item throws while rendering | console.error |
That item is left off; the toolbar is built. |
A specification version disagrees with the format's blockVersions |
console.warn |
The specification is left out of the schema, so the author gets the read-only carrier instead of an editable block the server would refuse. |
| A specification factory is registered but the schema is built with no attach context | console.error |
The block is carried. |
| A specification factory throws | console.error |
The block is carried. |
| A specification factory returns no specification | console.error |
The block is carried. |
An inline content specification's version disagrees with the format's inlineContentVersions |
console.warn |
The specification is left out of the schema, so the item is carried unchanged instead of an editable item the server would refuse. |
| An inline content specification factory is registered but the schema is built with no attach context | console.error |
The item is carried unchanged. |
| An inline content specification factory throws | console.error |
The item is carried unchanged. |
| An inline content specification factory returns no specification | console.error |
The item is carried unchanged. |
| An editor options alter throws | console.error |
The remaining alters still run. |
An editor options alter lists blocknoteLinkPolicy, blocknoteCodeBlockLanguagePolicy, blocknoteBlockPropValuesPolicy, blocknoteBlockSelect, blocknoteCaptionKeys, blocknoteBlockChildren, blocknoteSingleParagraphKeys, blocknoteSingleParagraphGuard or blocknoteEscapeAnnouncement in options.disableExtensions |
console.warn |
The key is taken out of the list, the extension is created, and the rest of the list stands. |
| A widget attribute is not valid JSON | console.warn, once |
The attribute is ignored and the editor is built as though it were absent. |
What an extension may rely on inside the editor¶
Everything the editor draws may change in any release unless it is listed here. That includes BlockNote's bn- classes, the markup of the module's own surfaces and notices, and the media block's bn-drupal-media and blocknote-media-block classes.
| Hook | Where | What it is |
|---|---|---|
data-blocknote-id |
The textarea | The id that keys Drupal.blocknote.instances, and the editorId of the client events. |
data-blocknote-document |
The textarea | The document's identity, as docs/workflows.md section 3 describes it. |
data-blocknote-langcode |
The textarea | The langcode the formatter renders the document in, which a block sends with a request for a server-rendered preview, as the media block does. For a field that is not translatable it is the default translation's, whichever translation the form edits, where the identity's langcode is the translation's. |
data-blocknote-upload |
The textarea | The upload entry for each format, described in 8. |
data-blocknote-files |
The textarea | The files the stored document names, resolved for the current account, which resolveFileUrl() reads. |
data-blocknote-entity-links |
The textarea | The titles and addresses of the document's content links, resolved for the current account and keyed <entityType>:<uuid>, which readEntityLinkPrefetch() reads. |
blocknote:ready, blocknote:change, blocknote:destroy |
The textarea | The lifecycle events of 12. |
| The classes of a block's style | .bn-block-content of a block BlockNote renders, the .react-renderer wrapper of one rendered through React |
Where a stylesheet the default theme names under blocknote-stylesheets finds a block's style in the editor (0046). |
.blocknote-editor__missing |
Above the editor | The notice counting the files the editor could not resolve, with the count in data-blocknote-missing. |
.blocknote-editor__upload-error |
Above the editor | The notice reporting a refused upload, from a paste, a drop or the file panel. |
.blocknote-editor__refusal |
Above the editor | The notice an extension's showRefusal() shows, until the document next changes. |
7. Building an extension bundle¶
A module's client half may be a plain script that takes React and BlockNote from Drupal.blocknote, calling Drupal.blocknote.React.createElement() where a built bundle writes JSX, and builds nothing: the preset below is a convenience, not a requirement.
js/preset/vite.preset.ts is the shipped Vite preset, and js/preset/README.md beside it is generated from the pins this module was built with, so the versions it names are the ones the runtime actually publishes. Copy both files plus js/preset/blocknote-runtime.d.ts into your own repository, replace the preset's one re-export of this module's contract.ts with the two constants it holds, and add blocknote-runtime.d.ts to your tsconfig.json include so Drupal.blocknote is typed.
import { defineConfig } from 'vite';
import { blocknoteExtension } from './vite.preset.ts';
export default defineConfig(
blocknoteExtension({
entry: 'js/src/index.tsx',
name: 'myModuleBlockNote',
outDir: 'dist',
fileName: 'my-module.js',
}),
);
It builds one classic IIFE, minified, with no code splitting and no source map, and writes the stylesheet beside the script under the same base name. Seven module ids are external and read off the runtime:
| Module id | Read from |
|---|---|
react |
Drupal.blocknote.React |
react-dom |
Drupal.blocknote.ReactDOM |
react-dom/client |
Drupal.blocknote.ReactDOMClient |
react/jsx-runtime |
Drupal.blocknote.jsxRuntime |
@blocknote/core |
Drupal.blocknote.core |
@blocknote/react |
Drupal.blocknote.react |
@blocknote/mantine |
Drupal.blocknote.mantine |
Those ids and no others. Anything else you import is bundled into your file. The preset's build-time guard refuses any other entry point of those five packages by name, because @blocknote/core/extensions, @blocknote/core/blocks and their siblings are separate modules and importing one would put a second copy of ProseMirror on the page. If you need something that lives only in one of those entry points, ask the runtime for it rather than importing it. The same guard refuses prosemirror-* and @tiptap/* at every entry point: the editor bundle carries ProseMirror and publishes none of it on Drupal.blocknote, so such an import has nothing to resolve onto and would put a second ProseMirror on the page.
@mantine/core and @mantine/hooks are deliberately not external, so importing either bundles a second Mantine into your file. An extension that wants chrome styled like the editor's own asks Drupal.blocknote.react.useComponentsContext() for the components the editor is already rendering with.
Icons are the extension's own. Neither the editor's icons nor react-icons, which BlockNote's own buttons draw from and which @blocknote/react inlines rather than exposes, is on Drupal.blocknote. An extension that copies a third-party glyph keeps it the way this module keeps its own (0033): an unmodified .svg file in a directory that also holds its license and a README.md naming its source, version and license, as drupal.org requires of any third-party asset, inlined into the bundle with Vite's ?raw import. The media submodule is the worked example: modules/blocknote_media/js/icons/remix/, read by modules/blocknote_media/js/icon.tsx.
The built file opens with a guard that throws when Drupal.blocknote is missing, naming your bundle. That is what a library which forgot its dependency gets instead of an anonymous type error. Declare the built script and stylesheet in your *.libraries.yml with dependencies: [blocknote/editor], and name that library in the library property of your #[BlockNoteBlock] or #[BlockNoteInlineContent] attribute.
The preset quotes the strings a bundle translates. Core's locale module finds a Drupal.t() or Drupal.formatPlural() string in a script only when it is a quoted literal, and the minifier writes every string as a template literal. Once the bundle is minified, the preset rewrites each such string argument, and the context option of either call, as a quoted literal. A string no quote can hold unchanged fails the build, which names it: one with a ${} expression, a backslash, a line break, both kinds of quote, or a quote, a + and another quote in a row. Put the variable part of such a string in a placeholder instead.
8. Uploading and resolving files¶
Two editor options carry files, and both are ordinary options set before the alters run, so an extension can replace either.
uploadFile(file, blockId) is set only when the widget's textarea carries a data-blocknote-upload entry for the format the editor attaches in. The attribute is {"formats": {"<format id>": {"url": "…", "maxSize": 1048576, "extensions": ["png", "pdf"]}}}, with one entry for each format the field offers that the account may use and that enables at least one of the four file blocks. It is absent when the field does not permit uploads, when the field's extension list is empty, when the account lacks the permission, or when no format has an entry. url is the upload route for that format and field, with its token; maxSize is the field's limit in bytes, or null when the field sets none; extensions is the field's extension list. A format switch attaches the editor again, and it takes the entry for the new format. Leaving uploadFile unset is what makes BlockNote hide the file panel's Upload tab; the Embed tab stays either way, because a URL typed into it is content and not an upload. The module's implementation refuses an extension or a size the field will not take before anything goes on the wire, posts the file to the upload route, and resolves with a partial block whose url is the file's identity.
resolveFileUrl(url) is set whenever the editor has an upload entry for its format or the textarea carries data-blocknote-files, and turns a stored identity back into something the browser can load.
The identity a document stores is file:<uuid>: a lower-case scheme and a lower-case RFC 4122 UUID and nothing after it. It is stored in the url property because BlockNote's four file blocks carry no property the module could add an identity to, and the uuid is what survives a file being moved, renamed or served from another scheme. The public renderer resolves it when the page is built and then runs the result through the same media allowlist an author-typed URL goes through; that allowlist refuses the file: scheme permanently, so an identity nothing resolved never reaches a page. docs/rendering.md section 5 is the full contract.
An identity the client cannot resolve renders a transparent 1x1 data URI rather than an empty src, and is counted in the notice above the editor, .blocknote-editor__missing, whose data-blocknote-missing attribute carries the count. The stored document is not touched: the author is told, and saving changes nothing about the reference.
An alter that replaces uploadFile takes over the error reporting. The module's implementation reports a refused paste or drop into .blocknote-editor__upload-error above the editor instead of rejecting, because BlockNote's paste and drop path calls uploadFile() with no catch and a rejection there is an unhandled rejection. Replacing the option replaces that guard, the pre-send refusals and the notice along with it. ADR 0007 records why the two paths differ.
9. The off-canvas style fence¶
The editor's container carries data-drupal-ck-style-fence, with no value.
Core's off-canvas dialog ships core/misc/dialog/off-canvas/css/reset.css, which applies all: revert to every element inside #drupal-off-canvas-wrapper at identifier specificity. Inside an off-canvas tray that strips the editor of everything its own stylesheet declares: no background, no padding, the tray's text colour and the tray's font. The reset exempts the descendants of an element carrying this attribute, and that is the only opt-out it offers.
The attribute is named for CKEditor 5 and is documented nowhere except CKEditor 5's own JavaScript, and the stylesheet that reads it is marked @internal. This module uses it anyway, because the alternative is an editor that is unusable in an off-canvas tray. An extension that builds its own container inside the editor does not need to set it again: the exemption covers descendants. A core issue asking for a neutral name or a documented contract is filed as #3634399, with #3291797 as a related issue. It awaits an answer, and the module keeps setting the attribute meanwhile.
Note that core's donut scoping of #drupal-off-canvas rules, the second half of what CKEditor 5 does, runs only in ckeditor5.js and only when a CKEditor 5 instance is attached. This module gets the reset exemption and not that scoping.
10. The worked example¶
tests/modules/blocknote_test holds a sample callout block, sample_callout, built the way a module outside this repository would build one, and it is the proof that this contract is sufficient rather than merely written down.
src/Plugin/BlockNoteBlock/SampleCallout.phpdeclares properties of its own on top of the shared triple, ships its own template from its own module, adds a class the default vocabulary has no rule for, and refuses a shape only it knows is wrong through a plugin-defined error kind.js/sample-callout.tsximports only@blocknote/coreand@blocknote/reactby name, registers the specification, a slash-menu item and aCallout toneblock menu group, the in-tree example of a block menu contribution without a toolbar item, and never touches this module's sources.js/sample-callout.tsxalso names the block's own controls, Callout tone and Callout title, andtests/src/FunctionalJavascript/BlockControlsAccessibilityTest.phpreaches them with Tab from the text before the block and checks that no key nests or moves a block. Tab reaches a block's controls only from a block that cannot be indented, because BlockNote's Tab indents a block that can (canNestBlock(),nestBlock.tsL242-L244, runs the nest command on a transaction it discards, and that command succeeds only when the block has a previous sibling that is an ordinary block container,sinkItemL45-L60; every block this module ships sits among such siblings); the Callout tone block menu group above is the reliable route around that limit. Callout title has none, because a menu entry is a discrete choice,menuitem,menuitemcheckboxormenuitemradio, and a title is free text; Tab, limited as it is, stays its only keyboard route.vite.config.tsis the shipped preset and nothing else.src/Plugin/BlockNoteBlock/SampleCalloutMismatch.phpdeclares version 2 against a client registration of version 1, so the documented version refusal happens to a real block.src/Plugin/BlockNoteInlineContent/Badge.phpis the same proof for inline content (13): atoneand auserproperty, a per-format setting that permits or forbids its accent tone, and agetReferencedEntities()naming the user itsuserprop references.js/badge.tsxregisters the badge's specification the same wayjs/sample-callout.tsxregisters the block's, built from its own Vite configuration,vite.badge.config.ts, intodist/badge/, because a bundle grows with every entry point it carries and the test module keeps this proof in a file of its own. The sample callout builds intodist/sample-callout/for the same reason its configuration is its own file: the preset empties its wholeoutDiron every build, so two extensions sharing one output directory would let either build wipe the other's committed bundle.src/Plugin/BlockNoteInlineContent/BadgeMismatch.phpis the inline counterpart toSampleCalloutMismatch.php: version 2 against a client registration of version 1.src/Plugin/BlockNoteBlock/VariableSample.phphas no client specification. It declares a theme variable of its own invariables:and passes a value for it, and a kernel test shows it reaching the template with no theme hook implemented in the module.tests/src/FunctionalJavascript/ExtensionTest.phpdrives it in a browser: the library is attached only for the formats that enable the block, the slash menu offers it, the typed props and content round trip to the public page, the mismatch is carried with exactly one console warning, a registered block the format does not enable is carried and refused on save, and the page ends up with one React instance and one copy ofreact-dom.- The sample callout declares no
inlineContent, because its text is the body of an aside and not a caption to something else, andExtensionTest::testTheSlashMenuOpensInsideTheSampleCallout()shows the slash menu opening inside it, which is upstream's ordinary rule for any block whose content is not a caption.
The test module builds from this repository's own node_modules and imports the preset by relative path, because that makes a change which breaks an extension build fail here before it reaches anyone's contributed module. That alone would not prove that the copy-the-preset-into-your-own-repository instructions in 7 work from an out-of-tree package with its own lockfile, so scripts/verify-extension-build.ts, run as bun run extension:oot, checks that too. It copies the preset with the one edit section 7 describes, the runtime types and the sample callout into a directory of their own, writes the package.json and vite.config.ts that js/preset/README.md documents, installs with a lockfile of its own, builds and type-checks, and compares the output with the committed sample callout build byte for byte. The dist-reproducible CI job runs it after bun run verify.
11. Checklist for a new block¶
- Write the PHP plugin: the attribute with
id,label,content,versionandlibrary, plustemplateif it ships its own markup, thengetPropSchema()andbuild(). - Add
validate()if the block has a rule of its own,getReferencedEntities()if it points at an entity,BlockNoteBlockNormalizableInterfaceif a stored property should be rewritten to what the format permits rather than refused, andBlockNoteBlockRenderedChildrenInterfaceif the page should render a child the save refuses as something else. - If it needs a site builder's decision, implement
BlockNoteBlockConfigurableInterface, declareblocknote.block_settings.<block id>in your ownconfig/schema, and handle the removal of anything you declare as a dependency. - If the editor needs to be told something, implement
BlockNoteBlockEditorSettingsInterface. - Write the client specification, build it with the preset or write it as a plain script (section 7), and register it against the runtime from the library the attribute names, with
meta.idandmeta.versionequal to the plugin's. - Declare
inlineContent: 'caption'when the block's text is a caption to something else it shows rather than the body of the block, and implementBlockNoteBlockCaptionInterfaceon the PHP plugin for the same block, and give every function a toolbar item offers a block menu entry too, so it has a keyboard route. A block that keeps a control inside the editable, as the sample callout does, needs ablockMenuGroupsentry for it as well, because Tab reaches a control drawn inside a block only when the block cannot be indented (0011), not reliably; give it one whenever the control is a discrete choice a menu item can represent. A control that is free text cannot be a menu entry's own choice, so give it an entry that opens a form, as the media block's Alternative text and BlockNote Extras' callout's Title do. The sample callout keeps its title field inside the editable with Tab as its only route, which section 10 says, as a block that does so has to say rather than leave the gap unstated. - Declare that library in your module's
*.libraries.ymlwithblocknote/editoras a dependency. - Rebuild the theme registry if you added a template, and declare any extra theme variable the template needs in
variables:on the attribute. - Enable the block in a text format. Until it is enabled there, it is registered and unusable, which is the intended behaviour.
12. Document events and the client lifecycle¶
docs/extending.md is how a module adds content to a document, docs/workflows.md is how a module observes and changes a document it did not author. This section is the vocabulary; that document is the ordering, the failure behaviour and what is not atomic.
The module dispatches two events, named by constants on Drupal\blocknote\Event\BlockNoteEvents.
BlockNoteEvents::DOCUMENT_SAVED,'blocknote.document_saved', dispatched asDrupal\blocknote\Event\DocumentSavedEvent.BlockNoteEvents::DOCUMENT_DELETED,'blocknote.document_deleted', dispatched asDrupal\blocknote\Event\DocumentDeletedEvent.
Both extend DocumentEventBase, whose readonly members are identity, previousIdentity, actorId, origin, previousRevisionId, newRevisionId, previousHash, newHash, schemaVersion and eventId. DocumentDeletedEvent adds cause, a Drupal\blocknote\Event\DeletionCause: EntityDeleted, TranslationRemoved or ItemRemoved. docs/workflows.md sections 5, 7 and 10 give what each member holds, the delete event's shape, Drupal\blocknote\Document\ChangeOrigin and when a subscriber runs.
No entity and no field item is on any event. A subscriber that needs one loads it through blocknote.document_repository, which is what makes the revision it reads its own decision rather than an accident of what the save happened to hold.
namespace Drupal\my_module\EventSubscriber;
use Drupal\blocknote\Event\BlockNoteEvents;
use Drupal\blocknote\Event\DocumentSavedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DocumentMirror implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
BlockNoteEvents::DOCUMENT_SAVED => 'onSaved',
BlockNoteEvents::DOCUMENT_DELETED => 'onDeleted',
];
}
public function onSaved(DocumentSavedEvent $event): void {
// $event->identity->uuid is the document; $event->eventId makes a retry
// safe. Load the document itself through blocknote.document_repository.
// A draft fires this too: check isDefaultRevision on what the repository
// reads.
}
}
On the client, the editor announces itself with three DOM events on the textarea, bubbling and not cancelable: blocknote:ready, blocknote:change and blocknote:destroy. docs/workflows.md section 8 gives their detail and their ordering.
docs/workflows.md is the document for the rest: the order the hooks, the commit and the dispatch run in, what happens when a hook or a subscriber throws, and what this seam does not make atomic.
13. Inline content¶
Custom inline content is declared the same way a block is, mirroring 1 member for member where the two contracts agree, and departing where they do not: an inline content item lives inside a block's content rather than in the top-level blocks list, and its content kind is styled, plain or none rather than a block's inline, none, table or plain.
The PHP plugin¶
A plugin lives in Plugin/BlockNoteInlineContent/ of any module, extends Drupal\blocknote\BlockNoteInlineContentBase and carries the #[BlockNoteInlineContent] attribute.
#[BlockNoteInlineContent(
id: 'badge',
label: new TranslatableMarkup('Badge'),
description: new TranslatableMarkup('A short label set beside the text.'),
content: 'styled',
version: 1,
library: 'my_module/inline.badge',
template: 'blocknote-inline--badge',
)]
class Badge extends BlockNoteInlineContentBase {}
| Attribute property | Meaning |
|---|---|
id |
The inline content type, spelled exactly as BlockNote writes it in the document JSON. The same string identifies the type on both sides. |
label |
The name an administrator sees on the text format form. |
description |
Optional. What the type is for. |
content |
The kind of content the item holds, in BlockNote's own vocabulary: styled, plain or none. Defaults to styled. |
version |
The type's contract version, raised whenever the property schema or the stored shape changes in a way the client specification has to change with. Defaults to 1. |
library |
The Drupal library that registers the client specification. |
template |
Optional. The base name of a Twig file, without .html.twig, in the providing module's templates/ directory. NULL means the type renders through the generic blocknote-inline template. |
deriver |
Optional, and the ordinary plugin deriver seam. |
variables |
Optional. The theme variables the type's template needs beyond the standard ones, as an array of default values keyed by variable name. See Templates and theme hooks. |
Drupal\blocknote\BlockNoteInlineContentInterface is what the rest of the module calls. Every method has a working default on BlockNoteInlineContentBase, so a plugin overrides only what it changes.
| Method | What it answers |
|---|---|
label() |
The human-readable name. |
getContentKind() |
styled, plain or none. |
getInlineVersion() |
The contract version, from the attribute. |
getLibrary() |
The client library, from the attribute. |
getPropSchema() |
The properties an item of this type may carry, as PropSpec objects keyed by property name, the same shape Declaring properties describes for a block. |
validate($item, $context) |
Checks one stored item and reports what is wrong with it. |
getReferencedEntities($item) |
The entities one item references, feeding file usage the same way a block's do. |
build($item, $context) |
The render array for the public page, or [] to render nothing. |
Drupal\blocknote\BlockNoteInlineContentManager is the plugin manager. Beside the usual plugin manager methods it answers getInlineContentIds(), getInlineContentVersion($id), getInlineContentLibrary($id), getInlineContentTemplate($id) and getInlineContentLabels(), on the same terms BlockNoteBlockManager answers for blocks.
Validating an item¶
BlockNoteInlineContentBase::validate() already checks the property schema against getPropSchema(), through the same PropSpec shape and the same three refusals a block's properties get: an undeclared property, a value of the wrong type and a value outside a declared values list. A plugin that adds a rule of its own calls the parent first, then reports into the context:
public function validate(array $item, InlineValidationContext $context): void {
parent::validate($item, $context);
if (($item['props']['tone'] ?? NULL) === 'accent' && !($context->settings['allow_accent'] ?? TRUE)) {
$message = $this->t('it cannot be accented in this text format', [], [
'context' => 'Follows "The <inline content> in the <block> block cannot be saved:"',
]);
$context->error('badge_accent_not_permitted', [], $message);
}
}
A type whose format settings narrow what a property may hold overrides getPropSchemaFor(array $settings) instead of getPropSchema(), on the terms Narrowing a property to the format's settings gives a block: the same keys, with narrower values or a different default.
Drupal\blocknote\Document\InlineValidationContext extends the same Drupal\blocknote\Document\ValidationContext a block's BlockValidationContext extends, so it collects error kinds and their placeholder arguments the same way, never a finished message. It carries three public readonly properties:
inlineType, the type of the item being checked.blockType, the type of the block the item appears in, so a plugin never repeats it in a message.settings, the format's settings for this inline content type, which isinline_content_settings[<type>]from the format's policy and[]for a type with none, or a document validated outside a format.
error(string $kind, array $args = [], ?TranslatableMarkup $message = NULL) reports one problem, the same way BlockValidationContext::error() does. Without a message argument the author is shown the kind's machine name in the sentence frame this contract gives an inline content plugin's own refusal, a second frame beside the block one: 'The @type in the @block block cannot be saved: @problem.', where @type is the item's own label and @block is the label of the block it appears in. Write the message the same way a block plugin's is written: a sentence of its own, lower case, with no trailing period, so the example above reads "The Badge in the Paragraph block cannot be saved: it cannot be accented in this text format." Pass the context shown above on the t() call, for the same reason Validating a block gives one. A kind of your own is coded inline_refused, with the kind as the violation's cause, as a block's is. 0023 is the record for the wording.
Per-format settings and what the editor is told¶
A type that needs a site builder's decision implements Drupal\blocknote\BlockNoteInlineContentConfigurableInterface, mirroring BlockNoteBlockConfigurableInterface member for member: defaultInlineContentSettings(), inlineContentSettingsForm($form, $form_state, $settings), validateInlineContentSettings($settings, $element, $form_state), calculateInlineContentSettingsDependencies($settings) and onInlineContentSettingsDependencyRemoval($settings, $dependencies). Its settings live inside the BlockNote filter's own configuration, under filter_settings.blocknote.inline_content_settings.<type id>, on a tab of its own beside the format form's Inline content tab, shown while the type is ticked. Declaring a dependency here obliges the same handling of its removal 2 describes for a block, for the same reason: any other dependency removal deletes the format.
A type that stores settings must declare blocknote.inline_content_settings.<type id> in its own module's config/schema. The base schema resolves the key as blocknote.inline_content_settings.[%key] with a FullyValidatable fallback that declares no keys, the same shape blocknote.block_settings.[%key] resolves to for a block.
A type that needs to put something in front of the author implements Drupal\blocknote\BlockNoteInlineContentEditorSettingsInterface:
public function getEditorSettings(EditorInterface $editor, array $inline_content_settings): array;
It is called once per format for each enabled type that implements it, and what it returns lands at drupalSettings.editor.formats[<format>].editorSettings.inlineContentSettings[<type id>]. A type returning [] is left out, and the whole inlineContentSettings key is absent when no enabled type returned anything. As with a block's own settings, nothing returned here is a check: the server decides what may be stored, in validate(), where the same settings arrive as InlineValidationContext::$settings, and they arrive again in build() as InlineRenderContext::$settings, for a type that enforces them on output as well, the way the content link does for its permitted list.
Templates and theme hooks¶
An inline content type ships its own markup by declaring template: on the attribute, exactly as a block does. The registration carries the providing module's templates/ directory as its path. A type that declares no template renders through templates/blocknote-inline.html.twig, registered as the base hook blocknote_inline; a type that declares one is registered as blocknote_inline__<type> with blocknote_inline as its base hook, so a theme overrides a single type the same way it overrides a single block.
An inline content type declares the theme variables its template needs in variables:, on the attribute, exactly as a block does (4). blocknote_inline and every blocknote_inline__<type> hook are registered with one standard variable set, inline_type, props, attributes and content, so a template written against blocknote_inline works for any type, and the declared variables are added to it. A name the standard set already has is ignored. The type passes the value through the $extra argument of themedInline(), keyed with a leading #.
BlockNoteInlineContentBase::themedInline($item, $context, $extra = []) is the usual way to answer build(): it builds the theme element with the standard variables and the item's classes, and adds $extra beside them, a union rather than a merge, as themedBlock() is. To put classes on the outermost element beyond blocknote-inline and blocknote-inline--<type>, override the protected inlineAttributes($type, $props, $policy) and append to what the parent returns, which is what the badge in tests/modules/blocknote_test does for its tone.
buildContent() reads the item's declared content kind to decide how its content is built: styled content is built through $context->buildInlineContent($content), which renders nested styled text, links and further custom items the way a block's own inline content is rendered, with the format's policy applied; plain content is the stored string as it is; none content builds nothing at all, whatever the item carries.
Declaring referenced entities¶
getReferencedEntities($item) returns the entities one item references, on the same terms Declaring referenced entities describes for a block: it feeds file usage and nothing else, a type that under-reports breaks that accounting silently for its own content, and ReferenceExtractor walks an item's own content the way it walks a block's, into a table cell's content and into a styled item nested inside another.
The client specification¶
Drupal.blocknote.registerInlineContent(spec, {
id: 'badge',
runtime: '1',
version: 1,
provider: 'my_module',
});
registerInlineContent(spec, meta) is registerBlock()'s exact counterpart. spec is a BlockNote inline content specification, normally built with Drupal.blocknote.react.createReactInlineContentSpec(), or a factory taking the attach context and returning one, called once per attach the same way a block specification factory is. The meta object carries id, runtime, version (omitted means 1, the PHP default) and provider, with the same meaning registerBlock()'s carry; an inline content type declares no inlineContent, no formattingToolbarItems, no blockMenuGroups and no blockMenuSubscribe, because none of those describe an item that lives inside a block rather than beside one. getRegisteredInlineContent() returns the registered entries, each { meta, spec }, sorted by type, the same as getRegisteredBlocks().
Registering an inline content type does not authorize it. An item is edited, checked and rendered as its type only when a PHP plugin declares the type and the text format enables it in inline_content; a specification registered for a type the format does not enable is never put in the schema, silently, exactly as an unregistered block type is, and The carrier says what becomes of an item outside that. A registration cannot replace text or link, BlockNote's own two inline content types, and is refused the same way a registration under one of the fourteen default block ids is.
The library that calls registerInlineContent() has to depend on blocknote/editor and has to be named by the PHP plugin's library attribute, which is what makes the editor plugin attach it for the formats that enable the type and for no others.
A type whose render draws an anchor with an href raises the priority of its parse rules above 50, as entityLinkSpec() in js/src/entity-link.tsx does through the spec's TipTap node, implementation.node.extend(). prosemirror-model asks a mark's rules before a node's of the same priority, and upstream gives an inline content type's rules none, so without it the link mark's a[href] rule reads the type's own copied anchor and a paste arrives as a plain link.
The carrier¶
An inline content item the client schema cannot hold, because no plugin registered it, the format does not enable it, or its version disagrees with the format's PHP plugin, is wrapped in blocknote_unsupported_inline, an inline content type of the module's own, on load. The author sees it as a single, read-only unit carrying the item's own text, deleted whole with Backspace or Delete like any atom, never edited into; a notice above the editor names every carried type once, the same way the unsupported-block notice does.
Unlike the block carrier, the inline carrier never reaches the server. The client unwraps every carrier back into the original item on every serialization, so what is stored is always the item as it was written, whatever the current format can open; a carrier that does arrive at the server, which should never happen through the editor's own path, is refused by name as an editor-only shape. Reinstalling the module that claims the type, or re-enabling it in the format, shows the item again with nothing lost, because nothing about it was ever rewritten. A document holding a carried type still saves when no plugin claims the type; a claimed type the format does not enable is refused by name, as a block is, which is why this carrier's rule differs from the block carrier's refusal on save only for the unclaimed case. 0027 is the record for the difference and for why it is safe.
What an inline content type gets no seam for¶
Inline content has no insertion surface of its own here: nothing in this contract gives a type a slash-menu entry, because registerSlashMenuItem()'s meta.blockType gates an item on a block type, not on an inline content type, and where a document gains an item of a given type is the type's own business, not a fact this seam could state generically. 0028 is the worked example: the content link it builds on this seam decides its own insertion route rather than inheriting one from here.
The content link and the search seam¶
The base module's own content link, entityLink, is one worked example of this contract, and the search its client surfaces run on is itself a seam a module may build on. The search the URL field and the [[ menu run is built once per attach, in js/src/entity-link-client.ts's createEntityLinkClient(), from editorSettings.inlineContentSettings.entityLink.suggestionsUrl, the same drupalSettings.editor.formats[<format>].editorSettings core's editor manager builds from \Drupal\blocknote\Plugin\BlockNoteInlineContent\EntityLink::getEditorSettings(). The route that URL names by default, blocknote.entity_link_suggestions, is answered by \Drupal\blocknote\Controller\EntityLinkSuggestionsController::suggestions(), and its request and its answer are the shape a replacement has to keep. The client sends q, the typed text, and adds hostEntityTypeId and hostEntityLangcode when the document's identity carries them; it reads a JSON object with a suggestions list. An entry with string entity_type_id, entity_uuid and path members is a content result, inserted with its label as the link's text where nothing was selected; an entry with a string href instead is the typed text offered as a plain link, and one whose href is empty is not offered at all. Every entry carries label, group and description as strings, the first and the last HTML-escaped on the server and decoded by the client, group sent as plain text, which the client does not decode, and all three rendered as text. A content result may also carry substitution_id, a string naming the address rule the result's own match followed; a route answering it names an id only when the format's own permitted list holds it, so the client never has to tell "no substitution" and "a substitution the format refused" apart. The route's own requirements, the format's permission, a check that the format enables the content link and a CSRF token, are 0028's; a route that replaces it decides its own.
The plugin is final, so a module that wants a different search, a curated linkit profile among content types for example, does not subclass it. It implements hook_editor_js_settings_alter(array &$settings), core's own hook for altering drupalSettings.editor.formats, and rewrites $settings['editor']['formats'][$format_id]['editorSettings']['inlineContentSettings']['entityLink']['suggestionsUrl'] to its own route before core attaches the settings to the page. The client reads whichever URL is there when the editor attaches and does not know the difference: the URL field's search, the [[ menu and the JSON shape they decode stay exactly what 0028 states, so a module that keeps that shape and sends a different URL under that key replaces the backend and nothing else. \Drupal\blocknote_linkit\Hook\BlockNoteLinkitHooks::editorJsSettingsAlter(), the module's own implementation, sets no order: on its #[Hook] attribute; a second module implementing the same hook to point suggestionsUrl at a third route, with no ordering declared either, can overwrite it again, so a module that wants its own URL to be the one an author's browser actually uses declares an order against it, and 0029 records why this module declares none.
The base module offers three seams on top of this contract, all decided by 0029. The first is the suggestionsUrl settings key above. The second is \Drupal\blocknote\EntityLink\EntityLinkResolverInterface, which every consumer of blocknote.entity_link_resolver is typed on: resolve(), describe() and resolveEntity() each take a trailing $substitution parameter the base resolver ignores, and substitutions(): array answers the address rules a decorator offers beyond the base rule, keyed by id with a label as the value. A module wanting a different answer for one entity type, resolving a media item through a linkit substitution instead of this module's own file-versus-page rule, for example, decorates the service, answers its own substitutions(), follows a non-empty id its own plugin can answer for the target, and falls through to the resolver it decorates otherwise. The third is the substitution prop itself: EntityLink::getPropSchema() declares it as an optional string, stored only when set, checked on save against the format's own permitted list, inline_content_settings.entityLink.substitutions, and blanked to '' at render when that list no longer holds it, so a decorator is never asked to follow an id the format does not permit; a module that decorates the resolver is what makes an id in that list mean anything, and the settings pane shows the list to tick only when a decorator offers something.
Checklist for a new inline content type¶
- Write the PHP plugin: the attribute with
id,label,content,versionandlibrary, plustemplateif it ships its own markup, thengetPropSchema(), andbuild()only when the themed render array the base class already builds is not what the type emits; a class of the type's own goes oninlineAttributes(), as the badge's tone does. - Add
validate()if the type has a rule of its own andgetReferencedEntities()if it points at an entity. - If it needs a site builder's decision, implement
BlockNoteInlineContentConfigurableInterface, declareblocknote.inline_content_settings.<type id>in your ownconfig/schema, and handle the removal of anything you declare as a dependency. - If the editor needs to be told something, implement
BlockNoteInlineContentEditorSettingsInterface. - Write the client specification, build it with the preset described in 7, and register it against the runtime from the library the attribute names, with
meta.idandmeta.versionequal to the plugin's. - Declare that library in your module's
*.libraries.ymlwithblocknote/editoras a dependency. - Rebuild the theme registry if you added a template, and declare any extra theme variable the template needs in
variables:on the attribute. - Enable the type in a text format's Inline content tab. Until it is enabled there, it is registered and unusable, and a document that already names it is carried the same as a block the format does not enable.
- Give the type its own insertion surface: nothing here offers one.
14. Conversion rules¶
blocknote.html_converter turns formatted text into a document for a BlockNote text format, and docs/converting.md says what it makes of what. A module teaches it the markup only a site knows, such as a custom element an editor plugin stored, a class that marks a callout or a token an older filter expanded, by adding conversion rules. The conversion API is experimental until the converter has run on a real site's content: any of what this section describes may change in a release before then, and 0038 is the record.
Registering a rule¶
A rule is a service tagged blocknote.conversion_rule, with a priority. The higher the priority, the earlier the rule is asked; the module's own rules have priority 0. There is no plugin type, no alter hook and no event: the rules are the one seam. A rule works through the interfaces below, the walker it is handed and the context; the module's own rules, HtmlWalker and StyleReader are @internal, so a rule neither extends nor calls them.
services:
mymodule.conversion.embed:
class: Drupal\mymodule\Conversion\EmbedRule
tags:
- { name: blocknote.conversion_rule, priority: 100 }
A rule implements one or more of eight interfaces in Drupal\blocknote\Convert\Rule, and is asked only what they ask. Six of them run in this order for each value:
HtmlPreprocessorInterface::preprocessHtml()repairs the stored string before it is parsed, such as markup stored escaped inside an attribute. The source format's filters run next, as docs/converting.md section 3 describes, so a preprocessor sees the markup as stored.DomPreprocessorInterface::preprocessDom()changes the parsed tree before it is walked, such as removing what a browser extension left behind wherever it is.- While the walker builds blocks,
ClassMapProviderInterface,TextPatternRuleInterfaceandElementRuleInterfaceare asked about each element, in that order. DocumentPostprocessorInterface::postprocess()changes the finished top-level blocks, such as removing a note every article ends with.
The other two, FileResolverInterface and LinkResolverInterface, are asked whenever a file or content is looked up, as File and link resolvers describes. The media submodule adds a kind of its own, MediaResolverInterface, as Media describes.
The converter then flattens nesting deeper than the site permits, joins a single-paragraph format's blocks, gives every block an id derived from the source and the block's position, and runs the checks a save runs. A rule never sets an id: ids are given after the postprocessors, so a block any rule builds gets one like every other.
Element rules¶
tags() names the elements a rule is asked about, as lower-case tag names, or ['*'] for every element. convert(\DOMElement $element, ConversionContext $context, ConversionWalkerInterface $walker) returns NULL to let the next rule decide, or an ElementResult:
| Result | What happens |
|---|---|
ElementResult::blocks(array $blocks) |
The element becomes these blocks |
ElementResult::inline(array $items) |
The element becomes this inline content; the formatting of the markup around it is added to its text, a link's text included, a link around it to its runs of text, and custom inline content is kept as it is |
ElementResult::replaceWith(\DOMNode ...$nodes) |
These nodes are converted in the element's place, by every rule again |
ElementResult::replaceWithHtml(string $html) |
This markup is converted in the element's place, by every rule again, after the repair the stored markup gets before it is parsed: bytes that are not UTF-8 are replaced and reported, character references are read as a browser reads them, control characters are removed and counted, and CDATA sections are removed. The HTML preprocessors, the source format's filters and the DOM preprocessors, which run once on the stored markup, do not run on it |
ElementResult::unwrap() |
The element's content is converted in its place, with the formatting, alignment and colors it declares |
ElementResult::drop(string $code, string $detail = '') |
The element and its content are left out and reported as dropped |
ElementResult::strip(string $code) |
The element and its content are left out and counted as stripped |
ElementResult::carry(string $code, string $detail = '') |
The element's markup is kept, as the walker met it, in a block of markup that could not be converted, for an author to rebuild, and reported as carried with the code as its reason; adjacent kept elements share one block. Where the conversion's ConversionOptions::UNCONVERTED option is drop, or the format holds one paragraph, the element is dropped and reported as dropped instead. For markup the old page showed that no rule can convert, such as an embed code; markup a page never showed is stripped |
What the result is decides where it goes, not where the element was met. Blocks returned for an element inside a paragraph split the paragraph around them, which is how an image in the middle of a sentence becomes an image block between two paragraphs. Inline content returned for an element between blocks becomes a paragraph of its own. A custom element holding an embed code therefore decodes it and returns replaceWithHtml(), and the rules for the markup it decodes to take it from there. Replacing more than eight times in a row drops the element and reports replacement_limit, which stops a rule that replaces an element with itself, and so does replacing more than 1000 elements in one document, which stops a rule that replaces an element with several copies of itself.
The walker converts what a rule does not convert itself, with the formatting of the markup around the element:
walkTextBlock($element, $type, $props, $context)makes the element a block of$typeholding its inline content, split around any block met inside it, as the module's own paragraph, heading and quote rules do.walkBlocks($parent, $context)converts the children of a node into blocks.walkElement($element, $context)converts one element inside the one the rule converts, as any element between blocks is converted, its class mappings and the formatting it declares included, as the module's list rule does for a list nested directly in a list; an inline element holding no block becomes a paragraph of its own.walkInline($parent, $context)converts the children of a node into inline content; a block met inside is dropped and reported.blockProps($element)returns the alignment and colors the element and its ancestors declare, and reports nothing, since walking the element reports what it declares.
A rule hands the walker only what is inside the element it converts. Converting that element again, or an element holding it, would ask the rule again without end, so it is a defect: walkElement() throws a \LogicException for either before it starts, and any other walk throws one when it reaches an element a rule is converting. drush blocknote:convert-html exits with 3 for it. An element nested more than 10000 deep is dropped and reported as nesting_limit, which stops a rule that keeps making the markup deeper, such as one that wraps what an element holds in a new element of its own kind.
A rule builds every block and every piece of inline content through $context->blocks, a Drupal\blocknote\Convert\BlockFactory: create($type, $props, $content, $children, $node), text($text, $styles), link($href, $content) and inline($items). The factory applies the target format's policy, so that a rule cannot write what the format refuses: a block type the format does not enable becomes a paragraph or is dropped, a prop value it refuses becomes the nearest permitted one or the default, and every prop the editor writes for the type in that format is filled in with its default, such as a heading's isToggleable where the format permits a collapsible heading, which is what makes the editor open the document unchanged. Each narrowing is reported. A block built without the factory is checked anyway, by the checks a save runs, and a refusal there is reported as refused.
A heading block the walker returns carries a note of the id its element had, which the converter compares, once the document is final, with the id a page will give the heading, and reports as heading_id_not_carried when the two differ (docs/converting.md section 7). A rule that builds a new heading block from one the walker returned, as the module's own disclosure rule makes a collapsible heading of a summary that is a heading, returns Drupal\blocknote\Convert\HeadingAnchors::carry($from, $to), which gives the new block the note the old one carries; a heading built without it is reported as having lost its id.
Class mappings¶
ClassMapProviderInterface::classMappings($context) returns Drupal\blocknote\Convert\ClassMapping objects, each naming a tag, or '*' for any, and a class:
| Mapping | What the element becomes |
|---|---|
ClassMapping::block($tag, $class, $type, $props) |
A block of another type, such as a paragraph that is a quote or an h2 that is a heading of level 3; a type with no content, such as a divider, is one block, and what the element held is reported as content_not_carried |
ClassMapping::props($tag, $class, $props) |
Whatever block it becomes, with these props, such as a background color; a container, or an inline element such as a span around blocks, passes them on to the blocks inside, and text the span holds beside those blocks becomes a paragraph of its own, apart from the text outside the span |
ClassMapping::styles($tag, $class, $styles) |
Its text, with these styles, such as a palette text color |
ClassMapping::style($tag, $class, $classes) |
Whatever block it becomes, with the block style the target format lists for that block's type with these classes, such as 'lead large'; a block whose type lists none is built without one, reported as block_style_not_permitted |
ClassMapping::strip($tag, $class) |
Nothing; it is counted |
ClassMapping::unwrap($tag, $class) |
Its content, as if it were not there; the class is known |
ClassMapping::ignore($tag, $class) |
What it would be without the class; the class is known, for one that means nothing to a document |
Mappings are applied before any other rule is asked about the element. When several match one element, a strip mapping wins over a block mapping and a block mapping over an unwrap mapping, whatever order they come in. Of two block mappings, the one for the class written first on the element wins, and for one class, the one returned first, highest priority provider first. Props and styles are merged in the same order, the first value of a name winning. Of two style mappings, the one for the class written first wins. An element needs no style mapping for a style whose classes it carries itself: when the classes no mapping names are exactly a block style the target format lists, in any order, the block the element becomes takes it, as docs/converting.md section 3 describes, and a rule that builds the block through $context->blocks->create() with the element as its source node gets that for nothing. A rule may also pass blockStyle among a block's props, which is kept only where the format lists it for the block's type. A class no mapping names and no block style took is reported as class_not_mapped with the key tag.class, which is how a site finds the classes its markup relies on. An element a rule of another module converts does not have its classes reported, since that rule may have converted it for its class. The module maps the classes common editors and word processors write, as docs/converting.md section 3 lists them, at priority 0, so a site's own mapping of one of them comes first.
Text pattern rules¶
TextPatternRuleInterface converts a block whose whole text has a shape of its own, such as a JSON token an older filter expanded when the page was built. pattern() returns an anchored PCRE pattern, and convert(array $matches, \DOMElement $element, ConversionContext $context) returns an ElementResult or NULL. The rule is asked about every block-level element that holds no other block, wherever the walker meets it, markup a rule returned included, with the element's text, whitespace collapsed and trimmed, before the element rules. It is not asked about a list item, whose text the list rule makes the item's, though it is asked about a paragraph inside one. A token in the middle of other text is out of its reach. The module's own VideoEmbedTokenRule is one, for video_embed_field's WYSIWYG token: it returns replaceWithHtml() with an iframe loading the token's video URL, so that a rule converting a provider's player takes the token as well.
File and link resolvers¶
A document names a file of the site as file:<uuid> and content by a content link, and docs/converting.md section 3 says when the converter finds them. A resolver finds a file or content the module's own resolvers cannot, such as one the site's old addresses name:
FileResolverInterface::resolveFile(FileReference $reference, ConversionContext $context)returns aDrupal\blocknote\Convert\FileTarget, the file and, when the address named an image style's derivative of it, the style, which the conversion reports; or NULL to let the next resolver decide. AFileReferenceholds the address the markup gives,url, theuuidit names withdata-entity-type="file", exactly as written, and theelement, which may be in a tree of its own, such as the one the secure image check reads.LinkResolverInterface::resolveLink(LinkReference $reference, ConversionContext $context)returns aDrupal\blocknote\Convert\LinkTarget, a content entity of a type with a uuid, thesubstitutionthe link follows, such as linkit'scanonical, and thelangcodethe address named the content in, empty when it named none; or NULL. ALinkReferenceholds the link'shref, theentityType,uuidandsubstitutionitsdata-entity-*attributes name, exactly as written, and theelement.
Resolvers are asked highest priority first, and the first answer wins. The module's own, at priority 0, find a file by its uuid and then by its address in the public files directory or a legacy file path, and content by its attributes and then by its address, through the site's aliases and the canonical addresses of content, so a site's resolver at a higher priority is asked first. The module's own resolvers only find. Once every resolver has answered NULL for a link that names content by no attributes, the walker reports why, as link_content_not_found, link_address_not_answered or link_alias_not_resolved, as docs/converting.md describes, so a site's resolver for the addresses of the site it migrated from may have any priority: a link it finds is not reported. A document naming a file opens it to whoever may view the document, so whatever a resolver finds, a file outside the public files directory is named only where $context->mayNameFile() allows it: where core's editor module recorded the entity the markup was read from as using the file. The media rule and the walker keep the address of any other and report it as private_file_kept_as_url, and the converter refuses a document naming one as private_file_named, so a rule that writes a file's identity, or a content link to a file, itself asks mayNameFile() first. The module's own resolvers find only public files by their address. A rule that meets a file or content itself, such as a custom element naming an image, asks the same chains with $context->resolveFile() and $context->resolveLink(). What a link resolver answers becomes a content link only where the target format permits one, and not where the link has a query or a fragment or names another translation than the document's; the walker decides that, so a resolver only finds.
$context->sites, a Drupal\blocknote\Convert\SiteAddresses, says what an address names on the site: address($url) returns a SiteAddress holding the path under the site's base path, still percent-encoded, its query and its fragment, or NULL for an address elsewhere or relative to a page. It reads the site's root, the host the conversion runs under and the addresses ConversionOptions::SITE_URLS lists, and legacyFilePaths() returns the paths ConversionOptions::LEGACY_FILE_PATHS lists, read, the longest first. A resolver matching the site's own address patterns reads the path it returns, so that it answers the same addresses at every host the site was served at.
final class ArticleLinkResolver implements LinkResolverInterface {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public function resolveLink(LinkReference $reference, ConversionContext $context): ?LinkTarget {
$address = $context->sites->address($reference->href);
if ($address === NULL || preg_match('#^/news/(\d+)-#', $address->path, $matches) !== 1) {
return NULL;
}
$node = $this->entityTypeManager->getStorage('node')->load($matches[1]);
return $node instanceof NodeInterface ? new LinkTarget($node) : NULL;
}
}
Media¶
With BlockNote Media installed, its rules convert <drupal-media>, entity_embed's <drupal-entity> naming a media item, an image whose file a media item holds and a YouTube or Vimeo player into media blocks, at priority 10, and create items where a conversion asks, when the document naming them is written, as docs/converting.md section 3 describes and 0042 records. A site's rule meeting markup that stands for a media item does not look items up itself: it hands the markup to the service blocknote_media.conversion, Drupal\blocknote_media\Convert\MediaConversion, which finds the item, or plans one where the conversion creates items, and builds the block.
| Method | Returns |
|---|---|
blockForMedia(MediaInterface\|string $media, $context, $overrides, $node) |
A media block for an item, or for the uuid of one the site does not hold |
blockForImage(\DOMElement $img, $context, $walker, $overrides) |
A media block for an image whose file an item holds, or for which one is created; its alternative text, data-caption and data-align are read as the module's own image rule reads them |
blockForRemoteUrl(string $url, $context, $overrides, $node) |
A media block for a YouTube or a Vimeo address in any of its forms, or for another provider's address with the provider override, where one of that provider's URL schemes matches it |
refusesRemoteUrl(string $url, string $provider) |
address_not_provider where blockForRemoteUrl() with that provider override would create no item for the address, because none of the provider's URL schemes in the oEmbed provider list matches it, or NULL where the address passes, YouTube's and Vimeo's always; it creates and reports nothing, so a rule asks it before deciding what to hand back |
caption(string $markup, $context, $walker) |
The inline content of a caption held in an attribute |
blocksForGallery(array $items, $context, $overrides, $node) |
A gallery of the items, each a media item, the uuid of one or a media block one of the methods above built, in a list; or, where the target format holds no gallery, a media block for each item |
A rule that takes an image's data-caption into a block's inline content itself removes the attribute from the element, as blockForImage() does, so that the module's figure rule, which gives a figure's figcaption to the image as that attribute, does not also keep it as a paragraph.
Each returns NULL where it cannot, such as where the target format holds no media block or no item is found or created, so the rule falls back on what it would do otherwise, and reports what concerns the item; blocksForGallery() returns an empty list there instead, and reports a target format without the gallery as block_not_permitted. The converter recognizes no gallery markup of its own, since every site writes its galleries its own way, so a rule that recognizes a site's hands the items to blocksForGallery(), with columns and viewMode as its overrides, and returns ElementResult::blocks() of what it gets back (0047). The overrides are viewMode, kept where the format permits it, textAlignment, caption as inline content, alt, an empty string for a decorative image, and, for an item created, name, mediaType and values, a map of field name to value set on the item, such as ['field_credit' => 'Photo: Example Agency'], which an item found keeps its own of. Only a field the site added to the media type that names no file or other entity takes a value, and only one the field validates: the source field, the base fields, such as status, uid and revision_log_message, which the conversion decides, file, image and entity reference fields, which would show what they name to whoever may view the item, a field the type lacks and a value its field refuses are left off and reported as media_value_not_set, the field's reason in the detail, and the item is created without them. A value is validated as the item's owner, OWNER or else the host entity's owner, so a formatted text value is set only in a format that account may use. The options a conversion's caller passes are the class's constants: CREATE, MEDIA_TYPES, OWNER and IMAGES, which both Drush commands set from --create-media, --media-type, --media-owner and --images.
final class EmbedCodeRule implements ElementRuleInterface {
public function __construct(
private readonly MediaConversion $media,
) {}
public function tags(): array {
return ['example-embed'];
}
public function convert(\DOMElement $element, ConversionContext $context, ConversionWalkerInterface $walker): ?ElementResult {
$code = (string) base64_decode($element->getAttribute('data-embed-code'), TRUE);
$context->checkParse($code);
$src = Html::load($code)->getElementsByTagName('iframe')->item(0)?->getAttribute('src') ?? '';
$block = $this->media->blockForRemoteUrl($src, $context, ['viewMode' => 'square'], $element);
return $block === NULL ? ElementResult::replaceWithHtml($code) : ElementResult::blocks([$block]);
}
}
Drupal\blocknote_media\Convert\MediaResolverInterface::resolveMedia(MediaReference $reference, ConversionContext $context) returns the media item markup names, or NULL to let the next resolver decide; resolvers are asked highest priority first, and the module's own, at priority 0, finds an item by its uuid, by the file its image field holds and by the address its remote video field holds, among the types the target format permits, a published item before one that is not. Nothing a resolver finds is checked for access, since the page checks it for each reader; but an unpublished item a resolver finds for a file or an address is named only where the items the conversion creates would be unpublished too, and is otherwise reported as media_not_published, while one found by its uuid is named whatever its status. A MediaReference holds the uuid the markup names, the id entity_embed's element names where it names no uuid, the file an image names, as the file resolvers found it, or a remote url and its provider, with the permitted bundles and the element. A site whose markup names items by the ids of the site it migrated from adds a resolver that reads its migration's map.
A rule that names an entity no one has created yet, as the media rules name a new item, builds it without saving it and hands it to $context->createOnWrite($entity, $detail, $node, $rule, $matches), as The conversion context describes.
Callouts¶
With BlockNote Extras installed, a class mapping or a rule may build its callout, callout, with a tone and a title among its props; the converter recognizes no callout markup itself, and docs/converting.md section 3 shows the one-line mapping. A callout holds no heading, so the submodule's postprocessor, CalloutHeadingsRule, runs the service blocknote_extras.callout_conversion, Drupal\blocknote_extras\Convert\CalloutConversion, on every callout a conversion built: a heading the callout starts with, before any text of its own, becomes its title where it has none, and every other heading inside it a paragraph, each reported as callout_heading_as_title or callout_heading_as_paragraph. A rule that builds a callout itself may run the helper on what it builds, blocks($blocks, $context, $node) or callout($block, $context, $node), with the element it converted, so that the report says where each heading was; running it again changes nothing.
final class AlertRule implements ElementRuleInterface {
public function __construct(
private readonly CalloutConversion $callouts,
) {}
public function tags(): array {
return ['div'];
}
public function convert(\DOMElement $element, ConversionContext $context, ConversionWalkerInterface $walker): ?ElementResult {
if (preg_match('/\balert-(info|success|warning|danger)\b/', $element->getAttribute('class'), $matches) !== 1) {
return NULL;
}
$blocks = $walker->walkTextBlock($element, 'callout', ['tone' => $matches[1]], $context);
return ElementResult::blocks($this->callouts->blocks($blocks, $context, $element));
}
}
The conversion context¶
Drupal\blocknote\Convert\ConversionContext carries:
source: where the markup comes from.entityis the entity it was read from, at the revision and in the translation it was read from, or NULL, as it is for markup converted on its own or by a migration; it is a copy, and nothing a rule changes on it is saved.sourceFormat,sourceField,targetFieldanddeltamay each be empty.currentis FALSE for an older revision, one that is neither the default revision nor a translation's draft.jobis the id of the bulk job converting the markup to write it, which the job keeps from its start to its end over every run resuming it, or NULL, as fordrush blocknote:convert-html; a rule tells an entity the running job created from one another created by it, as the media rules do before they publish an item.target: what the target format permits, as aDrupal\blocknote\Convert\TargetPolicy.sourceFormatEnables(...$filters): whether the source format enables one of some filters, TRUE where the conversion names no source format. A page showed what the source format's filters made of the markup, so a rule reading what only a filter the converter does not run read, such as thedata-entity-*attributeseditor_file_referenceandentity_linksread, asks it first, and reads the markup as stored where the format does not enable the filter.options: the options the conversion runs under, read withget($name, $default), for a rule that needs a setting from whoever starts the conversion. A caller ofblocknote.html_converterpasses them as aDrupal\blocknote\Convert\ConversionOptions. The module's own are its constants, such asBREAKS_AS_PARAGRAPHS,UNCONVERTED, which says whethercarry()keeps markup or drops it,SITE_URLSandLEGACY_FILE_PATHS, lists of the addresses the site was served at and of the paths its public files were,REGISTER_FILES, which registers a public file no file entity names when its document is written, andMAX_SOURCE_BYTESandMAX_SECONDS, which the converter reads itself to stop one value from holding up a run, as docs/converting.md section 2 describes; a module names its own starting with its name. Both Drush commands pass a site's own options to the rules with--rule-option=name=value, as text, and refuse the name of one of the module's own, which have options of their own.report: where a rule reports what it did not carry, withadd($category, $code, $key, $detail, $node, $rule), which returns the entry; a rule that meets several events of one kind together raises the entry'scountrather than adding one entry per event. The categories areDrupal\blocknote\Convert\Report\ReportCategory's, described in docs/converting.md section 7.counters: named counters kept for one conversion. A placeholder that stands for the n-th item of something kept elsewhere, such as the n-th item of a field on the host entity, is converted by counting withnext($name), which starts at 0. Call it only once the rule has decided to convert the element: calling it and then returning NULL moves every later placeholder's number by one.blocks: the block factory described above.sites: what an address names on the site, andresolveFile()andresolveLink(), which ask the resolvers, andmayNameFile(), which says whether the document may name a file they found, as File and link resolvers describes.createOnWrite($entity, $detail, $node, $rule, $matches): for a rule whose document names a content entity that does not exist yet, such as a media item for an image's file. The rule builds the entity without saving it, gives it a uuid derived from what it holds, asNameBasedUuidderives one, so that converting the same markup again names the same entity, names it by that uuid, and hands it here. The bulk converter saves it in the transaction that writes the document, unless an entity has its uuid by then, which the document names only where it is of the same type and bundle and$matches, a closure given that entity, accepts it, as the media rules accept an item holding the same file; where it does not, the write is not done, and the document is skipped as changed during the run; a dry run, a refused document, one that is not written and a caller that only converts, such asdrush blocknote:convert-html, create nothing, and the converter leaves out of its result an entity whose uuid no value of the finished document holds, as when the block naming it was dropped after the rule built it, and every entity of a refused document, whose report then sayswould_createfor none. The report sayswould_create, keyed by the entity type and bundle, until the write has created it, andcreatedonce it has. An older revision's conversion creates nothing, andcreateOnWrite()throws a\LogicExceptionthere, so a rule asks$context->source->currentfirst and falls back on what it does without the entity.publishOnWrite($entity, $detail, $node, $rule, $check): for a rule whose document names an existing entity, in the language given, that the write is to publish, as the media rules publish an item the running job created unpublished for a draft once a page its readers see names it. The bulk converter reads the entity again in the transaction that writes the document and publishes it where it is still unpublished and$check, a closure given the entity as read then, accepts it; where it does not, or the entity is in the trash, it publishes nothing and the write is not done, and the document is skipped as changed during the run. A rule passes the check that let it ask, so that a change made between the conversion and the write is seen. Nothing else publishes it, and the converter leaves it out of its result as it leaves out an entity to create. The report sayswould_publish, keyed by the entity type and bundle, until the write has published it, andpublishedonce it has. It throws an\InvalidArgumentExceptionfor a new entity and a\LogicExceptionin an older revision's conversion.checkParse($html): for a rule that parses markup of its own, such as markup held in an attribute, which calls it with the markup first. Core's parser cannot be stopped once it has started and takes time in the square of how deep elements nest, so the conversion is refused, asmarkup_too_deep, when the parser's estimated work would not fit in the time the conversion has left; the rule lets the exception through. The module's media rule calls it on adata-captionbefore reading its text.store: values the rules keep for one conversion, by key, read and written withget($key, $default),set($key, $value),has($key)anddelete($key), for what one rule learns and another reads later in the same document, such as definitions a DOM preprocessor collects for an element rule to resolve. Every rule of every module shares the one store, so a key starts with the name of the module that owns it, such asmymodule.glossary. Like the rest of this API, it is experimental.
The worked example¶
tests/modules/blocknote_convert_test holds one rule of each kind, of the sort a site writes: a custom element whose embed code is decoded with replaceWithHtml(), a text pattern turning a JSON token into a link, class mappings of all six kinds, three of them to BlockNote Extras' callout, an HTML preprocessor repairing double-encoded punctuation, a DOM preprocessor removing a browser extension's leftovers, a postprocessor removing an appended note, a positional placeholder read with a counter and the host entity, a glossary whose definitions a DOM preprocessor puts in the store for an element rule to read, two rules on one tag at two priorities, a rule that replaces an element with copies of itself, a rule that hands the walker back what is being converted or keeps nesting, a rule that throws or builds a block no document can encode, a link resolver for the addresses of the site it migrated from, /<language>/news/<id>-<slug>, which no route answers, and a file resolver for the address its private files had there, whose files mayNameFile() still decides on. tests/src/Kernel/Convert/ConversionSeamTest.php converts markup through them, CalloutConversionTest.php the callout markup, ContentLinkConversionTest.php through the link resolver and PrivateFileConversionTest.php through the file resolver. tests/modules/blocknote_media_convert_test holds rules built on the media helper: an embed code stored base64-encoded in a custom element, whose player becomes a media block in the view mode its shape stands for, an image marked zoomable, shown in a view mode of its own, the old editor's gallery of images, which becomes a gallery, and a media resolver reading the uuids of the site the items were migrated from; tests/src/Kernel/Convert/MediaConversionTest.php converts markup through them.
Run drush blocknote:convert-html on a site's own markup to see what the rules make of it, as docs/converting.md section 2 describes.