0010. The editor enforces the format's link and color policy as the author edits¶
Status: Accepted; the untaken alternative on pasted block and cell color props, and the consequence that such a prop is refused on save, are superseded by 0024 (2026-09-20)
Date: 2026-09-20
Context¶
A BlockNote format decides which link targets and which colors a document may hold. Its policy turns each of four link schemes, http, https, mailto and tel, on or off, permits a protocol-relative target only when both http and https are on, and lists the text and background colors of the nine-color palette it permits. The server is the authority: DocumentValidator refuses a forbidden target or color on every save, and the renderer sanitizes again, so a document that reaches a page has passed both (0002, 0004). A refusal on save is still the worst moment to tell an author, because the document looked right while it was being written.
Before this record the editor applied the policy in a few places only. links.isValidLink was built from the format's schemes, and BlockNote consults it in the paste rule, the paste handler, autolink and the HTML import. Each of those checks the text it matched and then stores the href BlockNote's link detector derives from that text, so pasting someone@example.org into a format that permits only https stored a mailto: link that the save then refused. The formatting toolbar's color button offered only the permitted palette, and no other surface did.
Upstream's link surfaces cannot be configured into compliance. The link toolbar's edit form and the formatting toolbar's create-link button share EditLinkMenuItems, whose validateUrl() prefixes https:// to any value that does not start with one of ten hard-coded protocols and commits the result without asking anything. The side menu's Colors item and the table handles' color pickers offer all nine colors. What upstream does export is the seams around them: LinkToolbarController takes a linkToolbar component, SideMenuController a sideMenu built from DragHandleMenu and its exported items, and TableHandlesController a tableCellHandle built from TableCellMenu. The row and column handle menu is the exception, because its header and color buttons are not exported.
Core's CKEditor 5 link plugin reads a typed target by a rule Drupal authors already meet. Core configures it with defaultProtocol: 'https://', and CKEditor 5 then prefixes mailto: to a bare email address and the default protocol to a value that names no scheme and does not start with a non-word character, and leaves everything else as typed.
BlockNote handles Tab in KeyboardShortcutsExtension, at priority 50. It lets Tab through while the formatting toolbar or the file panel is shown and nests the block otherwise, with a TODO for the link toolbar. A ProseMirror plugin that an extension adds runs ahead of it.
The editor and the server also have to read an entity-encoded scheme the same way. The server read one with Html::decodeEntities(), which knows the HTML 4.01 table and needs the trailing semicolon, while the editor decodes the way a browser does, so javascript:alert(1) was a relative link to the server and a javascript link to the editor.
Decision¶
The module owns the link toolbar and the create-link button. BlockNoteView is given linkToolbar={false}, and LinkToolbarSurface in js/src/links.tsx renders LinkToolbarController with a toolbar of the module's own: an edit button whose popover holds LinkTargetForm, beside upstream's OpenLinkButton and DeleteLinkButton, which only open or remove a link. The formatting toolbar's create-link button is replaced by PolicyCreateLinkButton for every format. It keeps upstream's editor-state selector and Ctrl+K or Cmd+K, embeds the same form, and renders nothing when the format permits no link. Amended 2026-09-26. OpenLinkButton is the module's own OpenLinkAction now, named "Open link", with an "Open in a new tab" toggle beside it where the format permits it; see 0032. Amended 2026-09-26. The toggle beside Open link is gone. The new-tab choice is a checkbox inside LinkTargetForm itself now, applied together with the URL and the text on Save, and both toolbars, the plain-link one and the content-link one, are back to three actions each: Edit link, Open link, Remove link, and title, Edit link, Open link, Unlink; 0032's amendment of the same date has the detail. Amended 2026-09-26. LinkTargetForm no longer commits through upstream's editLink(): it writes a plain link through the module's own commitLink() (js/src/links.tsx), which does what editLink() does, insertText() only when the text changed and addMark() over it in one transact(), but over exactly the range its caller gives. Edit link in the link toolbar gives the link's own range, so it edits the whole link, as upstream's does. PolicyCreateLinkButton, the formatting toolbar's button, whose form Ctrl+K and Cmd+K open, gives the selection, so its form links the selected text only: a selection inside an existing link, saved with a different address, becomes a link of its own, and the rest of the link keeps its text and its address. Here the module departs from upstream on purpose. Upstream's CreateLinkButton renders the link toolbar's EditLinkMenuItems with the selected text, and its submit calls editLink() at the selection's start, which widens that position to the whole link around it and, because the selected text differs from the link's, replaces the whole link's text with the selection (@blocknote/core src/editor/managers/StyleManager.ts:219-239 in 0.54.2): selecting "amp" in a linked "Example" and pressing Enter, with the address unchanged, leaves "amp" and deletes the rest of the link's text. Following upstream would keep a way to lose an author's text without warning, so this is a defect fix, which 0026's rule does not make an editor toggle. BlockNote 0.55.0 still does it, and for more selections; docs/drafts/upstream-blocknote-partial-link-selection.md reports it upstream and is not filed, per this repository's rule that only the maintainer posts upstream. Amended 2026-10-30. It is filed now, as BlockNote issue #3277, after the steps were reproduced in a browser on 0.57.0, 0.56.0, 0.55.0 and 0.54.2. It is a request awaiting an answer, and commitLink() stays as this paragraph describes.
A typed target is read by core's CKEditor 5 rule, then asked of the format. commitLinkTarget() in js/src/policy.ts trims the value, gives a bare email address mailto:, gives a value that names no scheme and does not start with a non-word character https://, and rewrites nothing else. The result is checked with linkIsPermitted(). A refusal carries its reason: the scheme the target names, protocol-relative, or unknown.
A refused target is refused in place. The form stays open and shows a translated message naming the scheme, tied to the URL field through aria-describedby and aria-invalid and announced assertively through Drupal.announce(). LinkToolbarExtension.editLink() is never called with the target, so the link as it was is untouched. Escape closes the form and returns focus to the caret.
A policy extension takes back, in the same transaction, any mark the format forbids. linkPolicyExtensions() returns one BlockNote extension, keyed blocknoteLinkPolicy, whose ProseMirror plugin implements appendTransaction. For every transaction that changes the document it collects the changed ranges from the step maps, plus the from and to of mark steps, whose maps are empty. Inside those ranges it removes every link mark whose href linkIsPermitted() refuses and every textColor or backgroundColor mark whose value the palette lacks; default is always permitted. The removal is appended to the transaction that made the mark, so one undo takes both back. Because the guard reads the stored href, it catches every path that makes a mark: typing, the toolbar, autolink, the paste rule, the paste handler, the HTML import, and whatever an options alter adds. createEditor() appends the extension after every registered alter has run, so an alter can neither remove nor reorder it. Amended 2026-09-15. BlockNote leaves out every extension options.disableExtensions names, whatever built it, so createEditor() takes blocknoteLinkPolicy back out of that list after the alters and warns once in the console. Every other key an alter put there stands, so an alter may still disable anything BlockNote built. Block-level and table-cell color props are node attributes, not marks, and one that arrives by paste is refused on save. Amended 2026-09-20. No longer so: the same extension now resets a block or cell color attribute the palette lacks to default in the same ranges; see 0024.
The block and cell color menus follow the palette. When the format trims either palette, SideMenuSurface hands SideMenuController a side menu whose drag handle menu holds upstream's exported items in upstream's order, with the Colors item swapped for PolicyBlockColorsItem. TableHandlesSurface hands TableHandlesController a cell handle whose menu holds upstream's SplitButton and PolicyCellColorItem. Both draw from js/src/colors.tsx, which the formatting toolbar's color button uses too. A color section is offered only when the block or cell has the prop and the format permits at least one color for it. A format that permits the whole palette keeps upstream's menus unchanged. The row and column handle menu keeps upstream's list, and a color it sets that the format forbids is refused on save. Amended 2026-09-20. A color that menu sets and the format forbids is now taken back in the same transaction rather than refused on save; see 0024. Amended 2026-11-03. The drag handle menu is the module's own for every format now, since it opens with the block's registered groups (0011's amendment of the same date); its Colors item is still upstream's own where the format permits the whole palette.
The URL field is internal. UrlField is a component of js/src/links.tsx and not a member of Drupal.blocknote, and RUNTIME_VERSION does not change. The entity-link search that 0008 plans for the same field is its first consumer, and the public seam lands with it.
Tab reaches the link toolbar. The same extension adds a handleKeyDown plugin: Tab without a modifier, with a collapsed selection inside a link while the link toolbar is shown, moves focus to the toolbar's first enabled button. It runs before BlockNote's Tab handler without declaring an order, because extension plugins sort ahead of KeyboardShortcutsExtension. 0011 records the keyboard routes as a whole.
The editor and the server read an entity-encoded scheme alike. UrlSanitizer::probe() serves both server call sites, the renderer and DocumentValidator::linkScheme(), and decodes the way decodeEntities() in js/src/policy.ts does: HTML5 named references, the legacy names a browser accepts without a semicolon, and decimal and hexadecimal references with or without one, in one pass, before whitespace and control characters are stripped. javascript:alert(1), java	script:alert(1), javascript:alert(1) and javascript:alert(1) are refused and named javascript on both sides.
Alternatives considered¶
Never rewriting a typed target. Rejected. example.com would be stored as a relative link to a path on the site, which is almost never what an author typing a host name means.
BlockNote's own reading. Rejected. Prefixing https:// to anything that does not start with one of ten protocols rewrites /node/1 and #section, and committing without validation is the defect this record removes.
Closing the form on a refused target, with or without removing the link. Rejected. Closing and doing nothing leaves the author guessing why nothing happened, and removing the link removes content without saying so.
Normalizing the display text inside isValidLink. Rejected. It mirrors the link detector's unexported heuristics, it reads page.html as a host name, and it still decides from the text instead of from what is stored.
Filtering the pasted slice in transformPasted. Rejected. Autolink and the paste rule run after the content is inserted, so a filter on the way in misses both.
Refusing on save only. Rejected. The author learns about the link after writing the rest of the document around it.
Rewriting pasted slices to strip block and cell color props. Not taken. It means walking and rebuilding node attributes in every pasted slice, and the save already refuses what gets through. Amended 2026-09-20. Superseded by 0024: the reason given here no longer holds, because a refusal naming a color the author cannot see or locate is not a usable outcome.
Copying upstream's row and column handle buttons into the module. Rejected. About 150 lines of unexported code from a pre-1.0 dependency would have to be kept in step by hand.
Registering the URL field as a provider on Drupal.blocknote now. Rejected. A runtime member is a versioned contract, and one with no consumer would be designed before anyone needs it.
Keeping the HTML 4.01 decoder on the server and documenting the difference. Rejected, because the author would get two answers for one target. Decoding until nothing changes was rejected too: it refuses targets the browser, and so the editor, accepts, such as javascript:. Fixing only UrlSanitizer would have left linkScheme() naming the refusal unknown.
Consequences¶
The module depends on more of @blocknote/react than it did: LinkToolbarController, SideMenuController, TableHandlesController, the drag handle and cell menu items, and the kit's form components. A BlockNote upgrade has to check each of them again, and js/src/__tests__/links.test.tsx, paste.test.tsx, menus.test.tsx and toolbar.test.ts and the LinkToolbarTest browser test are where a change shows first.
A target typed without a scheme becomes an https:// link, so page.html is stored as https://page.html. An author who wants a relative link starts it with /, ., # or ?, as in CKEditor 5.
An address pasted over a selection whose scheme the format forbids leaves the selection as it was, with no link and no message: upstream's paste handler sets a mark, and the guard takes it back off.
The row and column handle menu, and a block or cell color prop that arrives by paste, still let an author set a color the format forbids, and the save refuses it with a message naming the color. Amended 2026-09-20. No longer so for the color props; see 0024. The row and column handle menu still offers all nine colors, and a forbidden pick now does nothing.
The guard runs on every transaction that changes the document and scans only the changed ranges, so its cost follows the size of the edit and not the size of the document.
KeyboardShortcutsExtension carries a TODO about Tab and the link toolbar. If upstream resolves it, the module's Tab plugin can go.
Amended 2026-09-15. The two probes removed different whitespace code points, because PHP's \s matches U+0085 and U+180E and JavaScript's matches U+FEFF instead. Each side now removes the union of both classes, written out rather than taken from \s, and neither calls trim(), so the two read the same scheme out of the same target. Removing more than a browser ignores can only refuse a target, never serve one.
References¶
- BlockNote 0.54.2:
@blocknote/reactsrc/components/LinkToolbar/LinkToolbarController.tsx,src/components/LinkToolbar/EditLinkMenuItems.tsx,src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx,src/components/SideMenu/SideMenuController.tsx,src/components/TableHandles/TableHandlesController.tsx;@blocknote/coresrc/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts,src/extensions/tiptap-extensions/Link/link.ts. - Drupal core 11.4.6:
core/modules/ckeditor5/ckeditor5.ckeditor5.yml(link.defaultProtocol),core/lib/Drupal/Component/Utility/Html.php. js/src/links.tsx,js/src/policy.ts,js/src/colors.tsx,js/src/menus.tsx,js/src/toolbar.tsx,js/src/editor.tsx,src/Render/UrlSanitizer.php,src/Document/DocumentValidator.php.- Tests:
js/src/__tests__/links.test.tsx,paste.test.tsx,menus.test.tsx,colors.test.tsx,toolbar.test.tsandpolicy.test.ts;tests/src/FunctionalJavascript/LinkToolbarTest.phpandEditorPolicyTest.php;tests/src/Unit/UrlSanitizerTest.php;tests/src/Kernel/BlockContentValidationTest.php. Amended 2026-10-31.EditorPolicyTestno longer types links:paste.test.tsxtypes an address in a format that permits its scheme, in one that forbids it and in one that forbids links, andpolicy.test.tsandstripped.test.tsxunwrap and name a stored link the format forbids. The browser class keeps the payload reaching the running editor, the real color picker, the refused save, and the first keystroke reported to core's editor.js before the document is written. Amended 2026-10-31.LinkToolbarTestno longer refuses targets or pastes addresses:links.test.tsxopens the create-link form with Ctrl+K and the Edit link form from the link toolbar and refuses a forbidden target in place in each, andpaste.test.tsxpastes an address into a format that forbids its scheme. The browser class keeps the real Tab and Escape into the link toolbar and what it measures, a refusal shown beside the field among them. - 0002, 0004, 0008, 0011.