Skip to content

0019. The media block draws no box, and its functions live in the formatting toolbar and the block menu

Status: Accepted; the toolbar paragraph's "for a selection that is exactly one media block" in the Decision section is superseded by 0025 (2026-09-20)

Date: 2026-09-20

Context

Before this change the block drew a permanent controls row inside itself, modules/blocknote_media/js/block.tsx's MediaBlockView: a Select media/Change media button and a view mode <select>, both contentEditable={false}, above the rendered item, and a hand-drawn border around the whole block from modules/blocknote_media/js/drupal-media.css. That row was the one thing in the editor with chrome an author could not turn off, and every other block's own functions, alignment, styles, links, reach an author through the formatting toolbar or the Alt+F10 block menu, per docs/decisions/0014-block-editing-surfaces-declared-at-registration.md's contract for a block that contributes its own items there.

Upstream's own pattern for a block with rich content is exactly that: getFormattingToolbarItems() (node_modules/@blocknote/react/src/components/FormattingToolbar/FormattingToolbar.tsx:28-55) is one list, and each item, such as FilePreviewButton, renders null for a selection that is not its own block type. js/src/toolbar.tsx's registeredToolbarItems() and formattingToolbarItems() splice a registered block's own components into that list at the same position upstream draws a block's own buttons, and js/src/blockmenu.tsx's blockMenuGroups() does the equivalent for the Alt+F10 menu, both reached through the formattingToolbarItems and blockMenuGroups members of BlockMeta (js/src/runtime.ts:140-171).

A plain click does not select a block with inline content of its own. prosemirror-view node-selects only atom nodes on a mousedown, and an image block is an atom (content: 'none'), which is why upstream's own image selects on click; the media block declares content: 'inline' so its caption can be written under the document's own policy, so it is not an atom, and Tiptap's NodeView.stopEvent() (node_modules/@tiptap/core/src/NodeView.ts) lets such a click through to ProseMirror, which then does nothing for a non-atom node. Moving the block's functions onto the shared toolbar and block menu therefore only works once something puts the selection there in the first place; a click on the item, the empty state or any other non-editable part of the block has to become a node selection for the toolbar's contributed item to see it selected and for upstream's own ring (node_modules/@blocknote/core/src/editor/Block.css:23-40, an ::after overlay on .ProseMirror-selectednode) to draw.

An empty mediaUuid is refused on save (DrupalMedia::validate(), modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php:140-143, pinned by tests/src/Kernel/MediaBlockSettingsTest.php::testValidationRefusesOneBlockWithNoMediaItem()), so a control that cleared a chosen block's reference back to empty would produce a block the document could not be saved with.

Decision

The block draws no box of its own. modules/blocknote_media/js/drupal-media.css no longer sets a border or padding on .blocknote-media-block; a chosen item is shown with whatever border its own view mode's markup gives it, and alignment is drawn by upstream's own [data-text-alignment="center"|"right"] rules on .bn-block-content, so no alignment rule is added here either.

The block's functions move to the formatting toolbar, through BlockMeta.formattingToolbarItems, and to the Alt+F10 block menu, through BlockMeta.blockMenuGroups, both contributed by modules/blocknote_media/js/index.tsx's registration. modules/blocknote_media/js/toolbar.tsx's MediaToolbar renders, for a selection that is exactly one media block and only then, in order: the view mode control, Replace media (or Select media before anything is chosen), Toggle preview, one button per alignment the format's policy permits, and Toggle caption. modules/blocknote_media/js/blockmenu.ts's mediaBlockMenu() offers the same functions, in the same order, under one group labeled Media, as the keyboard route ADR 0011 requires for every one of them: the toolbar is shown only for a selection, and a keyboard author most often reaches the block with a collapsed caret in its caption, where nothing is selected and the toolbar shows nothing. Amended 2026-09-20. Superseded in part by 0025: the toolbar renders the block's own items only while the block is node-selected, and a text selection inside the caption renders upstream's text tools alone; the block menu is unchanged. Amended 2026-10-31. For an item that takes an alternative text override, Override media image alternative text sits between the alignment buttons and Toggle caption, and Alternative text in the same place in the block menu's Media group; both open one form in a core dialog (0036). Amended 2026-10-31. BlockNote 0.57.0 closes the formatting toolbar as soon as focus leaves the editor and the elements registered as its interface, so the Media Library dialog that Replace media opens took the button out of the page, and after Escape focus was left on the page's body. modules/blocknote_media/js/library.ts now registers the dialog's content with registerPortalElement() when core dispatches dialog:beforecreate for a pending opener, before jQuery UI focuses into it, and the dialog's wrapper on dialog:aftercreate, stops an Escape at the wrapper as the alternative text form does (0036), and releases both on dialog:afterclose, so the toolbar stays open behind the dialog and Escape gives focus back to the button. Amended 2026-10-31. Opened from the block menu, the dialog's opener was the editable itself, and jQuery UI focusing it by its own element after Escape put the caret at the start of the document, the way it did for the alternative text form (0036). The bridge now leaves an editable that has focus when core dispatches dialog:beforecreate, and gives focus to the editor itself once the dialog has closed, unless something has taken it by then, so the caret stays in the block the menu was opened for. Choosing an item is another matter, and is not changed here: after the insertion, core's AJAX framework focuses the element whose data-drupal-selector matches the dialog's submit button, which on a node form is the form's own Save button, whichever route opened the dialog. Amended 2026-11-02. Choosing an item now returns focus to the media block. The insertion command sets core's own data-disable-refocus on the element that sent the request, so core's AJAX framework leaves focus where the dialog's close put it, and once the response is done the bridge's focusChosenBlock() gives focus to the editor while nothing has it, with the selection in the chosen block: the block stays selected after a choice from the toolbar, whose button the choice draws again, and the caret goes to the end of the caption after a choice from the slash menu, the block menu or the empty block's own Select media button, which the chosen item replaces, unless the caret is in the block already. A button the dialog was opened from that is still in the page keeps the focus jQuery UI gave back to it, as after Escape. tests/src/FunctionalJavascript/MediaLibraryInsertionTest.php::testChoosingAnItemReturnsFocusToTheBlock() holds the slash menu and toolbar routes; without the attribute it finds focus on the Save button.

The view mode control is a named menu button, not the kit's Components.FormattingToolbar.Select. That component (node_modules/@blocknote/mantine/src/toolbar/ToolbarSelect.tsx) refuses every prop beyond className, items and isDisabled, so it cannot carry the accessible name ADR 0011 gives every media control; a trigger whose only name is the mode's own label ("Default") would fail that naming rule. The control is built from Components.Generic.Menu.Root/Trigger/Dropdown/Item instead, the same primitives js/src/colors.tsx's color menus use, with an explicit mainTooltip/label naming it "Media view mode". Amended 2026-09-19. The trigger's label now also states the current mode, for example "Media view mode: Wide", and each Menu.Item carries aria-checked for the selected mode.

A mousedown on a caption block's own content, outside its caption and outside any interactive control, node-selects the block. js/src/portable/blockselect.ts's blockSelectMousedown() is a ProseMirror plugin appended in createEditor() (js/src/editor.tsx) beside the link and code block policy extensions, once per editor and only when the editor's schema holds at least one block registered with inlineContent: 'caption' (BlockMeta.inlineContent, ADR 0014). It is scoped to .bn-block-content, checked against the registered caption block types read from the runtime rather than named in the bundle, and excludes a click inside .bn-inline-content or on a button, select, input, textarea, a, so a click that starts editing the caption or that operates a control the block draws is left alone. The ring it produces is entirely upstream's own CSS; nothing here draws one. Amended 2026-09-20. A mousedown inside an <iframe> never reaches the parent document, and a <video> with controls or a link inside the rendered item takes the click for itself, so a block whose view mode renders an embedded player could not be selected by pointer at all. The media block now renders the item inside an inner wrapper carrying inert (modules/blocknote_media/js/block.tsx), with pointer-events: none on iframe, video, audio, embed and object in drupal-media.css as the fallback for a browser without inert: a click anywhere on the preview lands on the group and this rule selects the block. Playing a video inside the editor is not a goal; Toggle preview stays the route for an author who wants the space back. tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php::testAnEmbeddedPlayerInsideThePreviewDoesNotSwallowTheClick() holds it with a test view mode rendering an <iframe srcdoc>.

Toggle caption clears the caption; there is no showCaption prop. Its onClick runs editor.updateBlock(block, { content: [] }) when the block carries content, which one undo reverses because it is an ordinary document edit, and otherwise places the caret at the start of the (already empty) caption with editor.setTextCursorPosition() so its placeholder opens for typing. The page keeps rendering a <figcaption> only when there is content, exactly as it always has (blocknote-block--drupal-media.html.twig's {%- if content -%} guard); nothing is hidden while still stored.

Toggle preview is editor-local and is never stored, a deliberate departure from upstream's own FilePreviewButton, which stores showPreview on the block (node_modules/@blocknote/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx:63-67). modules/blocknote_media/js/preview-visibility.ts keeps a WeakMap<editor, Set<blockId>> of hidden previews, read by MediaBlockView's container through useSyncExternalStore() and flipped by the toolbar button and the block menu's checkbox item alike, so the three surfaces agree without any of them writing to the document. Collapsing a preview to work faster while editing is not something a visitor to the published page has any reason to see, and storing it would mean a third stored prop, another version bump, and a decision about what an old document's absence of it means.

The empty state is the block's own control, because nothing can click-select an empty block into having a formatting toolbar before it holds an item. With a library, it is a Select media button in upstream's own add-file shape; with no library at all, it is a message naming the reason: "No media item has been chosen, and this text format permits no media type. Delete this block, or ask an administrator to permit a media type." Amended 2026-11-02. An empty block the slash menu inserted does not outlive a dialog closed without a choice. Closing the Media Library is a cancel, as it is in CKEditor 5: on dialog:afterclose, when no item was written into the block and it is still the empty block that same insert made, with no item and no caption, modules/blocknote_media/js/library.ts's takeBackInsertion() turns it back into the empty block it replaced, with that block's type and properties, or removes it when it was inserted after the block holding the caret, in one undo step, and the caret is where it was before the insert. Undo brings the empty media block back. An empty block the dialog was opened for in any other way, from its own Select media button, the toolbar or the block menu, keeps its empty state when the dialog closes. Creating the block only once an item is chosen was rejected: the dialog needs the block as the place the selection lands in and focus returns to.

Alternatives considered

Keeping the in-block controls beside the new toolbar items. Rejected. Two surfaces for one function is worse than either alone, and it is exactly the permanent chrome this change removes.

A showCaption prop, toggled instead of cleared. Rejected. It would be stored state a visitor to the published page never sees reflected in anything, since the page already omits an empty <figcaption> on its own; the prop would only duplicate what emptiness already means.

A stored showPreview, matching upstream. Rejected for the same reason: the preview is an editing convenience, not an editorial decision, and storing it would need a version bump for something no visitor can tell.

Using the kit's Components.FormattingToolbar.Select for the view mode control. Rejected; it refuses the accessible name this control needs (see Decision above).

display: none on the empty caption instead of a height-collapsing rule. Rejected. A hidden contentEditable element cannot take the selection, so Toggle caption's "place the caret and let the placeholder show" branch would have nothing to place the caret into.

Consequences

The Tab route into a chosen block's controls is gone; the block menu and the toolbar are the routes now, and tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php::testMediaBlockControlsHaveNamesAndKeyboardAccess() pins both: Tab still reaches the empty state's own button, and Alt+F10 and a text selection in the caption both reach the same five functions. Amended 2026-09-19. That browser test now also asserts the toolbar route's five data-test values in order and their accessible names and pressed or checked states; modules/blocknote_media/js/__tests__/toolbar.test.tsx pins the same five in order at the vitest layer, against the project's mock component kit rather than the real Mantine markup. Amended 2026-09-20. Since 0025, a text selection in the caption no longer reaches the five functions from the toolbar; the toolbar route is the node selection, and that browser test asserts the node route's seven data-test values as the whole toolbar and the text route's text tools without them. Amended 2026-10-31. The test's item is an image, so since 0036 the node route carries eight values and the block menu six functions, the alternative text override among them, and the test also reaches the override's form from both routes. Amended 2026-10-31. The browser test no longer reads the block menu's media group, sets the view mode or the alignment from it, or reads every name and state on the toolbar: modules/blocknote_media/js/__tests__/blockmenu.test.ts holds the group, its order and what each entry does, and the browser test reads the node route's eight data-test values in order with one name and one state for each kind of control on the real Mantine markup, beside toolbar.test.tsx's mock kit. It still reaches the empty state's button with Tab and the override's form from both routes, by keyboard from the block menu. Amended 2026-11-02. The block gained Break text, its default, beside the three alignments (0044), so the node route carries nine values, and the toolbar offers an alignment button for it only beside at least one alignment the format permits.

tests/src/FunctionalJavascript/MediaReadOnlyTest.php checks that a read-only editor shows neither the formatting toolbar nor the block menu for a chosen item, and that the empty state's button is disabled the way the textarea is, which is the one control the block still draws itself.

A format that stops permitting every media type leaves an existing chosen block frozen: Replace media is gated on the same settings.libraryUrl !== null the old in-block button was gated on (modules/blocknote_media/js/toolbar.tsx, blockmenu.ts), so such a block cannot be repointed. The same empty bundle set that withholds libraryUrl also empties offeredViewModeLabels() to the default alone, so the view mode control is hidden along with Replace media; what remains for such a block is Toggle preview, the alignments the format permits, and Toggle caption, because none of those read libraryUrl or the bundle list. A Remove media that emptied the block would only trade one dead end for another, since an empty mediaUuid cannot be saved either. The honest way out, not built here, is a control that converts the block to a paragraph holding the caption's own inline content (editor.updateBlock(block, { type: 'paragraph' }) keeps inline content), which needs no library and no validation change; it is left as a follow-up. Amended 2026-10-30. The follow-up is built: for a frozen block, Convert to paragraph takes Replace media's place in the formatting toolbar and in the Alt+F10 block menu, and turns the block into a paragraph holding the caption's inline content and the block's alignment, in one undo step (modules/blocknote_media/js/toolbar.tsx, blockmenu.ts). Amended 2026-11-02. A block with no alignment, none since 0044, becomes a paragraph aligned left, a paragraph's default, since BlockNote keeps the alignment across the type change and a paragraph's schema refuses none; both routes call convertToParagraph() in modules/blocknote_media/js/library.ts.

Amended 2026-09-20. blockSelectMousedown()'s node selection is a ProseMirror NodeSelection, the same kind a click on one of upstream's own atom blocks, such as its file or image block, produces, and ProseMirror's own default for a printable character typed while one is active is to replace the whole selected node with a paragraph holding the character. A caption block node-selected this way is not exempt: typing over it loses the block the same way, one undo away. This is recorded here as a consequence of node-selecting the block at all, not fixed, because it is not this record's own click that is at fault but ProseMirror's ordinary handling of any node selection; see 0021's Consequences.

References

@blocknote/core (vendored under node_modules/): src/editor/Block.css.

@blocknote/react: src/components/FormattingToolbar/FormattingToolbar.tsx, src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx.

@blocknote/mantine: src/toolbar/ToolbarSelect.tsx.

@tiptap/core: src/NodeView.ts.

This module: js/src/runtime.ts, js/src/toolbar.tsx, js/src/blockmenu.tsx, js/src/editor.tsx, js/src/portable/blockselect.ts, js/src/colors.tsx, modules/blocknote_media/js/block.tsx, modules/blocknote_media/js/toolbar.tsx, modules/blocknote_media/js/blockmenu.ts, modules/blocknote_media/js/preview-visibility.ts, modules/blocknote_media/js/drupal-media.css, modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php, tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php, tests/src/FunctionalJavascript/MediaReadOnlyTest.php.

0011, 0014.