Integrates the BlockNote block editor with Drupal as a structured-document field type: an editing widget, a public formatter, text-format integration, and extension points for custom blocks, media and document workflows.

A BlockNote field stores a document as structured JSON, not as markup. The editor writes blocks, and Drupal renders those blocks into its own semantic HTML on the public page, so what is stored is the content and what a visitor sees is the module's markup.
A text format is the editor profile and the permission set. Which blocks an author may use, which inline styles and colours are permitted, and whether links are allowed are all settings of the BlockNote filter inside one text format, and who may write in that format is the format's own permission. This is the arrangement CKEditor 5 uses, applied to a field type this module owns.
Public pages load no editor JavaScript. The bundle is attached to forms that carry the widget and nowhere else, because the public output is rendered in PHP.
editor, field and filter modules, which the module declares as dependencies.No JavaScript toolchain is needed to run the module. The editor bundle is committed to the repository, already built, and the module ships it as it stands. The toolchain in package.json is needed only to change the editor's TypeScript sources.
The editor supports the browsers in Drupal core's browser support matrix.
Install as you would any contributed Drupal module:
composer require drupal/blocknote
drush pm:install blocknote
Installing the module offers one text format, BlockNote, as optional configuration. It names its fourteen blocks explicitly and is created only if a format with the id blocknote does not already exist. No role is granted permission to use it: deciding who may write in a format is the site's decision. Installing the media submodule later does not change that format either; enabling its block in a format is always a deliberate step.
Four steps take a site from "module installed" to "an author can type".
Go to Administration › Configuration › Content authoring › Text formats and editors (/admin/config/content/formats). Use the shipped BlockNote format, or add a format and set its Text editor to BlockNote.
A BlockNote format has exactly one enabled filter, BlockNote document, and no other. The renderer is the sanitizer: it never echoes the stored text, it builds markup from the parsed document. An HTML filter added after it would be filtering markup the module already decided on, so the format form refuses any other filter in a BlockNote format, and refuses a BlockNote filter in a format whose editor is something else. The same rule is a validation constraint on the text format itself, but core validates a configuration entity only when a caller asks it to: the format form asks, and configuration import, recipes, config actions and programmatic saves do not. A format written by one of those can therefore fail the definition, and an item using it renders nothing and logs the refusal rather than putting the stored JSON on the page.
The filter's settings are the whole content policy:
http, https, mailto and tel.Anything the policy forbids is refused when the document is saved, naming what was refused, rather than being stripped on the way out. A protocol-relative URL in a link, which carries no scheme of its own, is permitted only when both http and https are. The URL of an image, video, audio or file block is checked on save as well, against the narrower allowlist the public output uses: http, https, a root-relative path or a scheme-less relative path, and never mailto, tel or a protocol-relative URL, because a media source is fetched by the browser without the visitor choosing to follow it.
Go to Administration › People › Permissions (/admin/people/permissions) and grant Use the BlockNote text format to the roles that should write in it. Until some role has it, the format exists but nobody is offered it, and a BlockNote field shows an author nothing to write in.
On any fieldable bundle, add a field of type BlockNote document (blocknote_document). Its settings are:
| Setting | Where | What it does |
|---|---|---|
| Allowed text formats | Field settings | Restricts which BlockNote formats this field offers. Leave every box clear to offer every BlockNote format the author may use. Only BlockNote formats are ever offered, and a format outside this list is refused on a programmatic or JSON:API write as well as in the form. |
| Rows | Widget settings | The height of the fallback textarea, which is what an author sees before the editor loads or with JavaScript off. |
| Read-only | Widget settings | Shows the document and lets nobody edit it. The form ignores whatever is submitted for the field and keeps the stored document, so a crafted request cannot write through it. |
| Attach the default styles | Formatter settings | Attaches the module's optional public stylesheet. On by default. See "What the public page contains" below. |
| File uploads | Field settings | Whether an author may upload files from inside a document, where they go and what is accepted. See "File uploads" below. |
The editor's own settings sit under the format, beside the editor selection, and are six toggles, all on by default: Formatting toolbar, Block side menu, Slash menu, Link toolbar, Table handles and File panel. They decide which surfaces the editor renders, and nothing else. Which blocks exist and what may be written is the filter's policy, not this.
The image, video, audio and file blocks can hold a file uploaded from inside the document, as well as a URL an author types. An upload creates an ordinary managed file on the site, and the document stores the file's identity rather than its address, so the document survives the file being moved, renamed, or served from a different scheme.
Three things have to be true before an author is offered an upload: the field permits it, the account has the permission, and the text format enables at least one of the four file blocks. If any of them is not, the file panel offers Embed and no Upload tab, which is the honest answer rather than a button that refuses.
They are under File uploads in the field's settings, at Manage fields for the bundle, and they are core's own controls for a file field.
| Setting | Default | What it does |
|---|---|---|
| Allow uploads | On | With uploads off, an author may still reference a file by URL. |
| Upload destination | The site's default file scheme | Where uploaded files are stored. This is a setting of the field, not of the field storage, so one storage can serve a public field beside a private one. |
| File directory | blocknote/[date:custom:Y]-[date:custom:m] | A subdirectory within the destination. Tokens are replaced. |
| Allowed file extensions | png gif jpg jpeg webp mp4 webm mp3 ogg pdf | Required, and separated by commas or spaces. |
| Maximum upload size | Empty | A value like 512, 80 KB or 50 MB. Empty means PHP's own limits decide. |
| Maximum image dimensions | Empty | Width by height. A larger image is scaled down to fit on upload, which is what core's own image field does and costs the image its EXIF data. The limit applies to uploaded images only. |
One extension list serves all four blocks. There is no separate list for images, and no check that a file going into an image block is an image. Deciding that on the server would mean trusting the browser to say which block it was uploading into, which is not a claim a server can act on, so a list that permits png and mp4 permits both into either block. A site that wants images only sets an image-only list. This is a stated boundary of this release.
Grant Upload files into BlockNote documents at Administration › People › Permissions (/admin/people/permissions) to the roles that should be able to place files. It is granted to nobody on installation. It is a genuine grant of the right to put files on the site, so give it the same thought you would give the file upload permissions on a file field.
Set the field's Upload destination to private and uploads from that field go to the private scheme, where Drupal serves them through a download callback rather than off the web server. The module answers for the files its own documents reference: a file is served to an account that may view an entity referencing it, and the field of that entity whose document names it, and refused otherwise. A site that withholds the field, with Field Permissions or a rule of its own, withholds the files its documents name along with it. A temporary file an author has just uploaded is served to that author, before the entity has been saved.
The rendered page asks the same question, so the two answers cannot disagree. A private file a document names is shown to every account the download callback would serve it to, and renders as unavailable for the rest. The page never links to a file the callback would refuse, and never withholds one it would serve.
An upload is stored as a temporary file. It becomes permanent, with a usage record, when the entity holding the document is saved and the saved document is found to reference it. A file uploaded into a form that was then abandoned stays temporary and is deleted by the file module's cron, which is what keeps an editing session from littering the site.
A file larger than PHP's post_max_size is discarded by PHP before Drupal sees the request, so the field's Maximum upload size cannot report it and the editor shows a generic failure. If authors are hitting that, raise post_max_size and upload_max_filesize in the site's PHP configuration; the field setting only narrows what PHP already allows.
BlockNote Media (blocknote_media) is a submodule that adds one block: a media item chosen from the Media Library and rendered in a media view mode, with a caption written in the document.
It ships nothing enabled. Installing it changes nothing an author sees. It requires core's media and media_library modules.
drush pm:install blocknote_media./admin/config/content/formats, open the BlockNote document filter's settings, and tick Media in the block list.Permitting a view mode makes the format depend on that view mode's configuration. Deleting the view mode afterwards removes it from the format's settings and leaves the format alone, which is deliberate: a format that kept the name would be deleted along with the view mode, and the fields pointing at that format would go with it.
The editor offers the media item where the format permits at least one media type; a format that permits none offers nothing rather than a dialog that refuses. Choosing an item opens core's Media Library dialog, and the item is inserted with the uuid and the view mode as its stored properties. The preview in the editor is rendered by the site's own view builder, through the same code that renders the public page, so what the author sees is what a visitor gets. The caption is the block's own text, written and styled under the same policy as the rest of the document.
Alt text is not here. It belongs to the media item, which is where Drupal keeps it and where changing it fixes every document using it. Alignment and width are not here either; a site builder answers those with a view mode.
view media), which is what core's Media Library requires of anyone opening it.A visitor who may not view an item sees Inaccessible media where it would have been, with the caption intact; a document naming an item that has been deleted shows Missing media the same way.
The module renders its own semantic HTML from the stored document: headings, paragraphs, grouped and nested lists, blockquotes, pre > code, dividers, figures for images, video, audio and files, details/summary for toggles, and tables with header rows and cell spans. Every element carries a class in the blocknote- namespace. No editor JavaScript and no editor CSS reach the page.
docs/rendering.md is the contract: the element and class for each block, what a theme may rely on, and what changes only with a major release. Each block has its own template, blocknote-block--<type>.html.twig with the block type in kebab case, so bulletListItem is blocknote-block--bullet-list-item.html.twig. A theme overrides any of them the usual way.
A few things need CSS that markup alone cannot carry: the nine palette colours, checklists, toggles and table cell colours. The module ships those, and only those, as the blocknote/content library, from css/blocknote.content.css. Its colours are custom properties, --blocknote-color-<name>-text and --blocknote-color-<name>-background, so a theme can restate the palette without replacing the stylesheet.
To turn the stylesheet off entirely, clear Attach the default styles on the formatter, at Manage display for the bundle. A theme that wants to own the palette can instead keep the library and redefine the custom properties.
The field offers no text format, or the widget is disabled. Either no BlockNote format exists, or the author has no format they may use, or the field's Allowed text formats names formats none of which the author may use. Check step 2 above. An author with no usable format sees the stored document and cannot edit it, which is deliberate: core's behaviour for a format the user may not use, and the document is preserved on save.
A save is refused, naming block types. The document holds blocks the chosen format does not enable, usually after a format switch or after an administrator turned a block off. The editor keeps those blocks as they were, in a read-only carrier block with a notice, and lists them above the editor. Nothing is dropped and the stored value is never rewritten. Remove them, or switch back to a format that allows them, and save again.
A save is refused, naming a limit. Documents have size limits, which exist so that a single field cannot take a site down: 1,048,576 bytes of stored JSON, 5,000 blocks, 10 levels of nesting, 1,000 cells or 100 columns in one table, and 10,000 table cells across the whole document. They are a container parameter, not configuration, so a site that needs different numbers overrides blocknote.limits in sites/default/services.yml:
parameters:
blocknote.limits:
max_bytes: 4194304
max_blocks: 20000
max_depth: 10
max_table_cells: 1000
max_table_columns: 100
max_document_table_cells: 10000
A key left out keeps its default. Raising a limit does not make an oversized document renderable, only storable, so raise them with a reason.
A public page renders nothing where the field is. The item's text format is not a BlockNote format, which happens when a format was deleted, or when a document was written programmatically with the wrong format. The module refuses to render rather than fall through to the raw JSON, and logs to the blocknote channel: Refused to render %entity_type %id, field %field, delta @delta: the text format %format is not a BlockNote format. Look for it at /admin/reports/dblog. Correcting the format invalidates the refusal, so the page fills in without a cache rebuild.
Claro is supported and tested. The editor is proven against Claro in automated browser tests: the theme's global element rules do not restyle the editor, the menus are not clipped inside modal dialogs or off-canvas trays, and nothing in the editor's stylesheet reaches the rest of the page.
Gin is best effort. Gin publishes no release that declares support for Drupal 11.4: its newest tag, 8.x-3.1, is capped below 11.2, and 4.0.x, 5.0.x and 6.x are untagged branches. The Gin coverage here is therefore checked against the 5.0.x branch at commit 356c4b55 and cannot be a claim about a release. On that commit the editor renders correctly in both light and dark mode, with one known exception: Gin's table header rule outranks BlockNote's own cell background colours, so a coloured header cell loses its colour inside an unlimited-cardinality field.
Any other administration theme is untested. The editor's own stylesheet is scoped to its container and restores what admin themes take over, so a theme close to Claro is likely to work, but only Claro is tested.
The editor mounts in whatever theme renders the Layout Builder tray, which is the site's front-end theme: Layout Builder's routes render in the front-end theme even when the content type is set to use the administration theme. The widget therefore appears there inside a theme this module has never been tested against.
Layout Builder support is not claimed for 1.0. Only Claro and Gin are checked. The editor may well work in a given front-end theme, and reports are welcome in the issue queue, but no test covers it and no fix for a front-end theme is promised in this release.
docs/extending.md documents the seams a module can build on: the BlockNoteBlock plugin that declares a block's property schema, version, validation, referenced entities and rendering on the server; the two optional interfaces that give a block per-format settings and something to tell the editor; its own Twig template; the Drupal.blocknote client runtime, registering a block specification or a slash-menu item against it, and altering the editor's options; and the Vite preset that builds an extension bundle resolving React and BlockNote off the runtime rather than shipping its own. A block added that way needs no patch to this module and no rebuild of its bundle.
docs/workflows.md documents the other half: what a module that observes or changes a document it did not author builds on, which is the repository that reads a document at a chosen revision, the updater that proposes a change against the revision and the bytes the caller last saw, and the saved, deleted and client lifecycle events it can subscribe to.
The module is licensed GPL-2.0-or-later, like everything on drupal.org.
dist/blocknote.js and dist/blocknote.css are a compiled bundle of third-party code: the @blocknote/* packages under the Mozilla Public License 2.0, and their dependencies under MIT, ISC, Apache-2.0 and 0BSD. MPL-2.0 section 3.3 permits distributing those files inside a larger work under the GPL, and the covered files remain available under the MPL upstream and in this repository. Every package, its version, its licence and where its source is are listed in THIRD_PARTY_NOTICES.md, which is generated from the lockfile.
Issues: <https://www.drupal.org/project/issues/blocknote>Document workflows
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.
Everything below is a service or an event another module may depend on. Names, payload members and orderings are part of what the module promises within a major version; where a limit is claimed rather than a guarantee, it is stated as one.
Three things, and only three. Reading a stored document at a revision the caller chooses. Proposing a change to one against the revision and the bytes the caller last saw, and being told in one word what became of it. Observing the documents a save or a delete changed, after the change has been committed.
It is not a comments system, not an approval workflow, not collaborative editing, and not an atomic bridge between Drupal and anything outside it. A module that wants any of those builds them on this seam and owns their policies: which proposals are accepted, what an annotation anchors to, what happens to an external record when a Drupal transaction rolls back. The base module supplies the identity, the read, the guarded write and the notification, and nothing above them.
The seam does not enumerate documents, and the events report only changes committed after a subscriber is installed. A mirror seeds itself: it takes the entity types and fields entity_field.manager answers for getFieldMapByFieldType('blocknote_document'), queries each type for its hosts, reads every document on each host with loadAll(), and correlates later events on the uuid and eventId.
blocknote.document_repository, Drupal\blocknote\Document\DocumentRepository, has four methods and checks no access at all.
load(DocumentIdentity $identity, DocumentRevision $revision = DocumentRevision::Active) reads the document an identity names.loadFrom(FieldableEntityInterface $entity, string $field_name, int $delta = 0) reads from an entity already in hand and loads nothing, which is what a hook implementation uses.loadAll(FieldableEntityInterface $entity) reads every document on one entity, keyed "$field_name.$delta".loadByUuid(string $uuid, DocumentRevision $revision = DocumentRevision::Active, bool $include_pending_revisions = FALSE) finds a document wherever it now sits.Each of them answers a StoredDocument or NULL. A StoredDocument carries the resolved identity, the exact revision and translation entity the item came from, the stored value, the parsed result, the hash, the formatId and whether that format is one this module renders, the three revision flags isDefaultRevision, isLatestRevision and isLatestTranslationAffectedRevision, and the selection that says how the revision was chosen. hasDocument(), getDocument(), getError() and getSchemaVersion() read the parse result without repeating it.
Drupal\blocknote\Document\DocumentRevision is the word a caller asks with. Each case maps to core as follows.
Default is $storage->load($id).Latest is getLatestRevisionId() then loadRevision(), falling back to load() on an entity type that keeps no revisions.LatestTranslationAffected is getLatestTranslationAffectedRevisionId($id, $langcode) then loadRevision(), with the EntityRepository::getLatestTranslationAffectedRevision() guard: a missing revision, or one that was the default revision when it was saved and is not the default revision now, falls back to Latest. An entity type that is translatable and revisionable but keeps no revision_translation_affected field cannot be asked the question at all, and is answered as Latest. The selection stays LatestTranslationAffected when the guard falls back, because it names the rule the revision was chosen by; the revision read is the one on the identity.Active is entityRepository->getActive($type, $id, ['langcode' => $langcode]).Named is not passed in: load() and loadByUuid() throw \InvalidArgumentException for it. It is what the repository reports back when it read one exact revision, either because the identity carried a revision id or because the caller handed over a revision object.A revision id on the identity wins over the word passed in: it is read at exactly that revision and reported as Named. A caller that wants the word to win passes withRevisionId(NULL) first. This is the precedence a caller holding an old identity most often gets wrong, and it is the one that makes a revision revert visible rather than silently followed.
An active workspace changes what Default, Latest and Active resolve to, because it changes what the storage and the entity repository return. This release claims nothing about workspaces beyond that.
The repository takes no current_user and runs no access check. A caller putting a document in front of a request owns view access for it, the same way a caller loading an entity directly does. Writes are checked, and section 4 says where.
loadByUuid() queries the indexed uuid column once per entity type and field that carries a document field. Its default path queries the field data table, which holds the default revision only, so a document living in a pending revision alone is not found: that is what $include_pending_revisions is for, and it is how a moderated draft is reached. On the default path the revision word is applied in the language of the translation that carries the document in the default revision, as load() applies it in the identity's langcode; the source language stands in only when no translation carries it there. The flag replaces the revision word rather than joining it, because every revision is searched and the newest revision carrying the document is the one read, reported as Named. When more than one entity carries the uuid, the first is answered and a warning naming both is logged on the blocknote channel, on that path as much as on the default one: the revisions of one entity would fill the window that catches a second entity, so the search across revisions looks for a second host in a query of its own.
Translations resolve strictly: a translation that is not there is NULL rather than the default language. NULL is also the answer for an entity that is gone, a revision that is gone, a revision id that belongs to another entity, a delta out of range, an empty item, a field the bundle does not have and an identity whose host has never been saved. A PluginNotFoundException propagates for an entity type nothing defines, and an \InvalidArgumentException is thrown for a field that is not a blocknote_document field.
Drupal\blocknote\Document\DocumentIdentity is the one way a document is named. It is a final readonly class carrying uuid, entityTypeId, bundle, entityId, revisionId, langcode, fieldName, delta and schemaVersion, with equals() over every member, sameDocument() over the uuid alone, key() giving document:<uuid>, isStored() answering whether the host has been saved, and the three withers withRevisionId(), withDelta() and withUuid() answering copies.
The original block, verbatim, as JSON in a prop.
toArray() writes the members in one fixed order, uuid, entity_type, bundle, entity_id, revision_id, langcode, field, delta, schema_version, and fromArray() is its inverse. That array is the payload of all three layers at once: the data-blocknote-document attribute the widget writes, the detail.identity of the client events, and the identity members of the server events. A member added to it is added to three public surfaces and to whatever an external store has already persisted.
What is durable is the uuid. It survives a new revision, a revision revert, a reorder within the field, and a translation being created, which gets a uuid of its own. The delta is not durable: a reorder moves it, and a store correlating on a delta correlates on where a document sat once. entity_id and revision_id are NULL on an add form, where the host has not been saved yet, so a client that saw an identity on an add form reconciles it after the first save by asking loadByUuid() for the uuid it was shown.
One rule governs a uuid and a delta that disagree. The uuid at the delta is verified, a mismatch is healed by finding the item that carries the uuid in the same field and translation, the neighbour sitting at the requested delta is never returned, and no item carrying the uuid answers NULL. The updater applies the same rule when it writes, so a reorder between a read and a write is a reorder and the write lands on the document rather than on the position.
ADR 0001 holds the collision rules the field item applies in preSave(): an item with no uuid is given one, one that collides with another document of the same entity is regenerated, and an identity its host did not already store is checked against every revision of every blocknote_document field of every entity type, and regenerated when another entity holds it, which covers duplicating an entity, posting a copy through JSON:API or PATCH and a uuid living only in a pending revision. The query is paid only when an identity is new to its host, and it leaves out the host's own revisions, so a restored revision keeps its identity. The widget never takes a stored document's identity from the submission; only a document that has none reads the posted one. Two saves minting the same posted identity at the same moment can both pass the query, because no unique index spans the field tables and their revisions: the field's storage is not a registry of identities, and a site correlating on them owns that registry.
The hash is sha256: followed by hash('sha256', $raw_stored_value), taken by blocknote.document_hasher over the stored bytes and never over a re-encode of the decoded document. A re-encode would compare two encodings of one document as equal, which is exactly how a concurrent write disappears without a trace. The hash lives on StoredDocument, on the widget attribute and on the events, and is never a field property.
blocknote.document_updater, Drupal\blocknote\Document\DocumentUpdater, is the write path for anything that is not an entity form.
update(DocumentIdentity $base, string $expected_hash, string $value, UpdateOptions $options) writes $value into the document $base names, provided the store still holds the revision and the bytes the caller read. createTranslation(DocumentIdentity $source, string $langcode, string $value, UpdateOptions $options) adds a translation carrying the document and hands it to prepare, which fills the translation's required fields; the new document carries no uuid of its own, so preSave() mints one and the translation is a document in its own right. A translation that already exists is reported as stale, and so is a host whose entity type is not translatable, a document field that is not translatable, and a source identity whose revision is not the active one. A langcode the site does not have, a locked one or a language-neutral host is the \InvalidArgumentException core's addTranslation() throws: it is a programming error, like an unknown entity type, not a result.
UpdateOptions is a final readonly class: actor is required, because a write through this seam is made on behalf of somebody; newRevision asks a revisionable host for a new revision and is ignored by a host that keeps none; revisionLogMessage is recorded by a host that keeps a log; lockWait is how many seconds to wait for another writer before being answered, 0.0 meaning answer at once; and prepare is a closure handed the translation the document is written in, or the translation being created, inside the lock and the transaction, for a caller that has to touch the host as well as the document. What prepare changes is validated with the document; it must not save the host, and a call to DocumentUpdater from it is refused. A closure cannot be serialised, so an UpdateOptions carrying one cannot be put in a queue.
Both methods throw \LogicException when a transaction is already open on the connection. The updater writes in a transaction of its own, so it is called from outside any transaction: from a controller, a queue worker or a blocknote.document_saved subscriber, and never from prepare, an entity hook or a caller's own transaction.
UpdateResult answers one of six kinds and carries what a caller acting on that kind needs.
| Kind | What the result carries | What the caller does |
|---|---|---|
Saved | document, the new StoredDocument | Take it, and correlate on the identity and hash it carries |
Stale | document, the document now in the store | Re-read, reapply the change to what is there, and submit again. Never retry the same bytes blind. Only an actor allowed to write the document is told it; access is answered first |
Invalid | violations, a ConstraintViolationListInterface | Show them. Nothing was written |
Locked | retryAfter, the longest the other writer can hold the lock (DocumentUpdater::LOCK_TIMEOUT), not the time it has left | Try again sooner with a backoff, or pass lockWait. Nothing was read and nothing was written |
Forbidden | access, an AccessResultInterface | Stop. The actor may not write this document |
Gone | Nothing | Drop the correlation. No item carries the uuid any more |
The updater answers in a fixed order: the entity, the translation and the item (Gone), then access (Forbidden), then the revision and the hash (Stale), then the value and what prepare changed (Invalid). Inside the lock and under the host row it reads the document past the static cache, as DocumentRevision::Active names it for the identity's langcode: EntityRepository::getActive(), which is the revision an entity form edits and the revision the repository's default read returns, trusting nothing in the identity but the uuid and where to look for it. A revision id read that differs from the one on the base identity is Stale before the hash is looked at; equal revision ids with bytes whose hash does not match the expected one are the same-revision edit and are Stale too. Checking the revision first is why a revision revert makes a byte-identical token stale: the revert writes a new revision whose bytes may be exactly the ones the caller read, and the caller is nonetheless holding a document from before the revert. Gone is answered ahead of access, because there is nothing to ask access of, so an actor without access learns that an identity names nothing; that is recorded as a limit, since Gone is what stops a workflow retrying a deleted document forever.
Access is answered for the actor and not for the request: $translation->access('update', $actor, TRUE) on the translation the document is written in and the document field's edit access, either refusal being Forbidden. createTranslation() asks the same of the source translation and then, as the actor, when content_translation translates the entity type and the actor lacks translate any entity, the translation handler's getTranslationAccess($entity, 'create'), whose refusal is Forbidden as well. The account is switched to before validation and stays switched through the save and the entity hooks, so that core's AllowedValues constraint on the format property and a moderation transition are evaluated against the actor rather than against whoever the request is running as, and the events carry the actor as actorId. Validation is $translation->validate() filtered by the actor's field access, the chain JSON:API runs, so what prepare changed and every entity-level constraint is answered for the actor, and a host that is already invalid is refused as its entity form would refuse it.
One asymmetry in the two write paths is worth knowing. The updater calls DocumentNormalizer::stampVersion() on the parsed document before it validates, so a programmatic write always carries the version this release writes; in 1.0 the call is a no-op, because DocumentParser::parse() refuses every other version before the stamp is reached. The widget path does not stamp at all: the client preserves the envelope it received and replaces only blocks. The stamp is the seam a later schema version uses on the programmatic path, not a promise that a stored document is ever rewritten.
A subscriber author can rely on this order for a save made through DocumentUpdater.
\LogicException), acquires the lock named blocknote_document:<uuid>, opens its own transaction and takes the host's base-table row with SELECT ... FOR UPDATE.Active, answers access, checks staleness, switches to the actor, writes the item, runs prepare and validates the translation.preSave() runs, minting a document uuid for an item that has none and regenerating one that collides.hook_ENTITY_TYPE_presave() and hook_entity_presave() implementations run. BlockNoteDocumentHostLockHooks takes the host row for any save of an existing revisionable host carrying a document field, and BlockNoteDocumentEventHooks runs after the others and reads the default revision that a save making another revision the default one replaces. A document a presave hook adds is stored without a uuid and reported on the entity's next save; a hook that adds one sets its uuid, and a caller of the updater adds it in prepare.SqlContentEntityStorage::save() opens for itself.hook_entity_insert() or hook_entity_update() runs, still inside that transaction. This is where BlockNoteEntityHooks writes the file_usage rows and where BlockNoteDocumentEventHooks diffs the documents the entity holds against the documents it held, and hands each changed one, with a check that finds it in storage, to blocknote.post_transaction_dispatcher, which queues it.ChangeOriginTracker::forget(), evicts from the static cache a host it did not save, and releases the lock.Transaction object. Destroying it is what runs the post-transaction callbacks, and that is what dispatches blocknote.document_saved.Steps 3 to 6 are the same for a save made through an entity form, where there is no lock and no account switch; the queue then flushes wherever core's own root transaction object goes out of scope. A delete follows the same shape: hook_entity_predelete() gathers the documents of every revision, hook_entity_delete() runs inside the storage's transaction, one blocknote.document_deleted is queued for every document of every translation of every revision the entity held, a document living only in a pending revision included, and the queue flushes when the root transaction object is destroyed. The union runs both ways: a delete through the default revision reports what only a draft holds, and a delete through the latest revision, which is the revision content moderation's delete form hands over, reports what only the published revision holds. A document a draft removed was reported as ItemRemoved when the draft was saved and is reported again with EntityDeleted, so a mirror applies a delete idempotently.
SqlContentEntityStorage::save() always opens a transaction, so a subscriber hearing about a save made through normal entity storage always takes the deferred path. The immediate path, where PostTransactionDispatcher::dispatch() dispatches where it is called, belongs to a caller dispatching outside entity storage.
The outcome of the root transaction is the outcome of an event queued directly in it, or queued by the save DocumentUpdater makes inside the transaction it opened. An event queued inside a savepoint, raised while events are being dispatched, or flushed by a callback that is told the root rolled back, is dispatched only if what it reports is found in storage at the flush: a saved document at the revision, position and hash it names, a deleted entity that no longer loads, a removed item or translation that the latest revision no longer carries. A check that throws is logged and its event is dropped. A transaction manager that does not extend TransactionManagerBase reports no depth, so every event raised on it is checked.
The delete event's shape is fixed. identity is the document as it was last stored, previousIdentity is NULL because a deleted document did not move, previousRevisionId and previousHash carry the last known revision id and hash from the snapshot taken before the change, and newRevisionId and newHash are NULL because there is nothing left to read.
A save that stays a pending revision is compared with the revision it was loaded from, which is $entity->getOriginal() as ContentEntityStorageBase::doPreSave() sets it; a save that becomes the default revision is compared with the default revision it replaces. Under content moderation a draft edited through the form or DocumentUpdater is compared with the draft it was loaded from, and publishing a draft, or a revert that publishes a former revision, fires when the published bytes change; a revert that only writes a new draft is a draft save. The event does not say whether its revision is published: load($event->identity) reads exactly that revision and isDefaultRevision says, and a mirror of published content reads DocumentRevision::Default.
A subscriber runs after the root transaction has committed, and never for a write a rolled-back savepoint took back. The order among subscribers of one event is Symfony's own priority order.
An invalid document never reaches storage. Both write paths validate before they save, and a violation is an Invalid result or a form error rather than a row.
A hook_entity_presave(), hook_entity_insert() or hook_entity_update() implementation that throws, \Error included, rolls a save made through DocumentUpdater back, the queue is discarded, and nothing is dispatched. A save made through entity storage alone has SqlContentEntityStorage::save() roll back on an \Exception, which is core's own behaviour.
An EntityStorageException or any other throwable from the save rolls the transaction back, releases the lock and propagates. There is no UpdateResult for it and no event: a throwing hook is a defect in some module, not an outcome a caller can act on. The module adds no pre-save event of its own, which would be a second contract with a second failure story.
A subscriber that throws after the commit is logged with Error::logException() on the blocknote channel, and the save stands, because the data is committed and there is nothing left to undo. The limit is that the catch is per event and not per listener: a subscriber that throws costs its event the subscribers that would have run after it, and the next event is still dispatched.
Three defects in core's post-transaction callbacks shape the dispatcher, and no released or development version of core fixes any of them. A callback registered inside the transaction of a running post-transaction callback waits for a later root transaction, because TransactionManagerBase::purge() forgets the root that is ending only after its callbacks have returned (#3447097). A callback registered inside a savepoint that is rolled back still runs, told that the root transaction committed. A callback that runs after another callback which ended a transaction of its own is told how that transaction ended rather than how the root transaction did, because processPostTransactionCallbacks() reads the outcome from the connection again for each callback. No core issue reports either of the last two yet. The storage check at the flush, the check of every event when the dispatcher is told that the root transaction rolled back, and the flush of what a subscriber's own transaction left queued are therefore a compatibility path, kept for every core version the module supports. The order events are dispatched in does not depend on whether core has the fix for #3447097: a callback that runs while the queue is being flushed hands its events to the flush that is running.
The lock is advisory. It is named blocknote_document:<uuid>, held for DocumentUpdater::LOCK_TIMEOUT seconds, and only DocumentUpdater takes it, so what it does is answer a second writer of the same document Locked quickly. What excludes a lost update is the host row: from before its read to its commit the updater holds the host's base-table row, and every save of an existing revisionable host carrying a document takes the same row in presave, so no save commits inside the updater's window and a write that read before another committed comes back Stale. A writer that outlives the lock timeout loses its lock but not the row, so the next writer still waits for it. A form that loaded its entity before the updater committed and saves after it overwrites the updater's write: that is core's form concurrency, which core's changed-time constraint answers for an entity that records a changed time, not this seam's. SQLite ignores FOR UPDATE and serialises writers instead.
The queue flushes when the root Transaction object is destroyed, which in the updater happens after the lock has been released. A subscriber therefore runs on committed state with no lock held, and may call DocumentUpdater for the same document. A write a subscriber makes, through DocumentUpdater or a plain save, is announced in the same flush, after the events already queued. A subscriber that leaves a transaction open when it returns has that transaction's events wait for the next root transaction. For a form save the flush happens wherever core's own root transaction object goes out of scope, which is core's business and not this module's.
An event checked at the flush reads storage after the commit, so a concurrent write to the same document landing between the commit and the flush can make it look as though it never landed, and it is dropped; that write fires its own event.
The outcome core passes the dispatcher can be wrong either way, and only one way is caught. Told that the root transaction rolled back, the dispatcher checks every event against storage, so the events of a root transaction that committed are still dispatched. Told that the root transaction committed when it rolled back, which a post-transaction callback registered before the dispatcher's causes by committing a transaction of its own, the dispatcher cannot tell from inside its callback, and dispatches the events queued directly in the root transaction or by the save DocumentUpdater makes, for writes that were rolled back. That is the defect in processPostTransactionCallbacks() named in section 6, and the limit lasts until core passes every callback the outcome of the root transaction.
No atomic persistence across Drupal and an external service is claimed, and none is possible here. A post-commit subscriber's own writes are outside Drupal's transaction: if they fail, the Drupal change stands. A subscriber that must not lose work queues it rather than doing it inline.
Every event carries an eventId, a v4 uuid minted once per changed document at detection time. The same document changing twice carries two ids, and work retried from a queue carries the id it carried before, so a subscriber writing into another store can recognise what it has already applied and make its own retries safe.
Three DOM events are dispatched on the textarea the editor stands in front of, never on the editor root and never on document. They bubble and are not cancelable, so one listener bound on document hears every editor on the page, provided it is bound before the editor attaches: at script evaluation rather than inside a Drupal.behaviors attach, or in a library that loads ahead of editor/drupal.editor.
blocknote:ready, after the editor has mounted and rendered, with its editable element on the page.blocknote:change, after the editor has written a changed document back.blocknote:destroy, after the editor has been torn down and the textarea given back.A change a blocknote:ready listener makes to the document is kept: it is reported to core as soon as core registers its change callback, so the form submits it.
The detail of all three is { identity, hash, editorId, format, dirty }. identity is the array the widget wrote into data-blocknote-document, in the server's own snake case, with the hash among its members. hash is that same hash, echoed and never recomputed in the browser: it describes the bytes the document was loaded from, not whatever the editor currently holds. editorId is the data-blocknote-id value, which keys Drupal.blocknote.instances. format is the text format the editor was attached for. dirty says whether the serialised document differs from the document as this attach first serialised it, so it is false at ready except for a document the editor transformed on load; it is per attach, and after a teardown and re-attach it compares with what the previous editor wrote back and says nothing about the bytes the hash describes.
A missing or malformed attribute is one console.warn and nothing else. The events then carry identity: null and hash: null, and the editor works as it did before.
blocknote:change fires from the 400 ms trailing-edge write, after the document has been written back to the textarea, and only when the written value differs from the last value announced, the value the editor opened on counting as announced. It does not fire on the keystroke, and a change that leaves the serialised document alone, a caret move among them, announces nothing.
A detach whose trigger is serialize dispatches nothing: the editor is still there and the textarea is being read, not given back.
Three computed properties on the field item are non-internal, so JSON:API exposes them and a mapping layer can name them.
plain_text is a computed string: the text of the document, for a search index, a token or a meta tag.processed is the document rendered through its text format, and carries the cacheability of that render.document is the decoded envelope, typed any. It is there for a client reading the field as structured data and must not be handed to a search index, which wants a string.Neither token nor search_api is a dependency of this module. What the module does instead is assert the property shape those modules require, in TokenSanityTest, ComputedPropertiesTest and SearchIndexingTest, so that a site installing them finds the properties where their mapping layers look. entity_usage is a named non-goal of 1.0: file_usage rows are written for uploaded files, and a document referencing a media item registers nothing.
hook_entity_revision_delete() fires nothing. Deleting an old revision does not delete the document, and telling an annotation store to drop its anchors for a live document would be worse than telling it nothing. A DocumentRevisionDeleted event would go there, in a later release.
Drupal\blocknote\Document\ChangeOrigin says who wrote a document, as far as the module was told. It has three cases: Widget for the editor widget writing through an entity form, Updater for DocumentUpdater, and Unmarked for everything else.
Nothing is inferred. A migration, a Drush script, JSON:API, REST, cron and another module calling save() all arrive as Unmarked, and a subscriber that reads Unmarked as "some other writer" rather than as "not the widget" reads it the way it is meant. The marks are kept by blocknote.change_origin, ChangeOriginTracker, in a \WeakMap keyed by the entity object and lasting one request, and the hook forgets a mark once it has read it.
docs/extending.md section 12 for the event classes and a subscriber example.Text can be bold, italic, underlined, struck through or code, and coloured red or highlighted. Links go to the project page, to an address or to a telephone number.
Then, once content exists:
A document is read, validated and rendered as one value, so a revision always holds a document that was valid when it was saved.
Blocks are never stored as entities of their own. What a block references is named by identity instead.
A carrier, a link target outside the permitted schemes, and a colour outside the palette.
| Surface | Keyboard | Pointer | Notes |
|---|---|---|---|
| Side menu | Alt+F10, then the arrow keys | Block colours come from the palette | |
| Table handles | Alt+F10 inside a cell | Drag a row or column handle | Merged across two rows |
| Link toolbar | Tab from inside a link | Hover the link | |
Supported releases:
| Drupal core | PHP | Status |
|---|---|---|
| 11.4 | 8.3 | Supported |
| 11.5 | 8.4 | Planned |
| 12.0 | 8.5 | Not yet |
| Bold | drush cr |
| Documentation |


Recordings of the same steps:
Downloads:
$document = $repository->loadByUuid($uuid);
if ($document !== NULL) {
$blocks = $document->blocks;
}
Drupal.blocknote.alterEditorOptions((options) => {
options.trailingBlock = false;
});
.blocknote-check-list-item__label {
gap: 0.75em;
}
A document is the value of one field, and everything it references is named by identity.
Read the contributed module documentation before filing an issue.
A paragraph on a coloured background.
Centred text in purple.