Skip to content

0032. A link's new-tab choice is a boolean style on the link's own runs, permitted per format

Status: Accepted

Date: 2026-09-26

Context

Upstream's link mark declares one attribute, href, and nothing else survives a round trip: node_modules/@blocknote/core/src/extensions/tiptap-extensions/Link/link.ts:91-100 declares only href in addAttributes(); nodeToBlock.ts:248-258 builds {type: "link", href, content} from a link node, reading nothing else off its mark; blockToNode.ts:106-108 recreates the mark from href alone, schema.marks.link.create({href: link.href}). target="_blank" and rel="noopener noreferrer nofollow" exist only as render-time HTMLAttributes defaults on the mark (link.ts:79-84), so every link in the editor's own DOM, and in upstream's clipboard HTML, already carries target="_blank", and nothing about that choice is stored. A custom link mark with a second, persisted attribute is not an option BlockNote's InlineContentSpecs exposes: link is not one of the extensible inline content types the way a custom node is.

TypeCellOS/BlockNote's issue tracker has closed requests for a link mark that carries more than href more than once: #1861, #1118, #2240 and #2666 each ask for a title, a target, rel, or a custom attribute on a link and are declined or left open with no movement, on the grounds that the mark's shape is intentionally minimal and any richer link is expected to be a custom inline content type instead. Amended 2026-09-26. Read on GitHub on this date, the four say something narrower. #1861, an issue closed on 2026-02-19, asked for a flag marking a link as internal; a contributor advised a custom inline content type and wrote that the project intends to improve the inline content API, given time and funding. #2240, an issue closed the same day, asked for a custom link type to be parsed ahead of the built-in link; the pull request that would lower the link extension's parse priority, #2249, is still open. #1118, an issue still open, asks to customize how a link renders, and a maintainer answered on 2026-04-23 that doing so through the custom inline content API would be a major breaking change. #2666 is a pull request, merged on 2026-04-23, whose links.HTMLAttributes and links.onClick editor options 0.54.2 ships; both apply at render time, and its author calls them a stopgap. None of the four refuses a persisted second attribute on link in so many words, and none provides one; the project's answers point a richer link at custom inline content.

Measured against the same headless BlockNote 0.54.2 editor: a boolean style inside a link's content round-trips, in both link kinds, and survives an edit through editLink(). A schema holding a custom boolean style named newTab, and a custom inline content type entityLink with content: "styled", load a document holding {"type":"link","href":…,"content":[{"type":"text","text":"plain link","styles":{"newTab":true}}]} and get back the identical bytes from editor.document; an entityLink whose content run carries {"bold":true,"newTab":true} round-trips the same way. Calling upstream's editLink("https://example.org/new", "renamed", pos) on the plain link, both with changed text and with the same text, returns {"type":"link","href":"https://example.org/new","content":[{"type":"text","text":"renamed","styles":{"newTab":true}}]}: the style survives because insertText(text, from, to) inherits $from.marksAcross($to) (prosemirror-model/src/resolvedpos.ts:160-169), which drops only a mark declared inclusive: false that is absent after to. The link mark is inclusive: false (link.ts:76) and is re-added by editLink() itself; a style mark is inclusive by default and is simply kept. So the link edit form needs no re-apply step for the flag; a vitest pins the round trip so a future upstream change that stops re-adding the link mark this way is caught. Amended 2026-09-26. The link form no longer calls editLink() (0010's amendment of the same date); the vitest pins the same inheritance through the module's own commitLink(), which repeats editLink()'s insertText() and addMark().

Loading the flag into a schema without its spec throws: the same document loaded into an editor built from defaultStyleSpecs alone fails with "Error creating document from blocks passed as initialContent" (blockToNode.ts:37-40), so the client's load strip has to remove newTab whenever the spec is absent, the same way it removes any other forbidden style.

HTML import drops both <sub>/<sup> and target today: tryParseHTMLToBlocks('<p>x <a href="https://e.com/" target="_blank">t</a> <sub>s</sub></p>') gives a plain link with no flag and unstyled text. Reading target="_blank" back into the flag on paste is possible in principle, since getStyleParseRules() (node_modules/@blocknote/core/src/schema/styles/createSpec.ts:29-66) accepts a non-consuming parse(element) rule that prosemirror-model continues past to the link rule, but it is not done in this wave: upstream's own toExternalHTML puts target="_blank" on every link in the clipboard (link.ts:79-84), so an author copying a plain link from one BlockNote editor into another would silently gain the new-tab flag unless the module also stripped target from its own links' HTMLAttributes, a second, coupled change to an option this module does not otherwise touch. Left for a later wave.

RenderPolicy and DocumentValidator already carry a links boolean and a link_schemes allowlist; a third, narrower permission for what a permitted link may additionally do is a natural sibling rather than a new axis. EntityLink::build() reads a resolved href and builds its render array through themedInline(), which does not currently expose a way to add a sibling child to #content or to attach a library; buildContent() is already protected, so a subclass can override it without a base-class change. Amended 2026-09-26. themedInline() does take an $extra array, added to the build it returns, and EntityLink::build() attaches system/base through it; what it cannot do is add a child to #content, which is what the buildContent() override is for.

WCAG guidance is explicit that a link opening a new context needs to say so: G201 and H83 both call for a visible or programmatically available indication. Core's own text formats offer no equivalent, so there is no existing convention to match; this module writes its own.

Decision

The carrier is a boolean style, newTab, on the text runs inside a link's own content (a plain link's content or a content link's content), the same shape and the same enforcement path as subscript and superscript in 0031, rather than a prop on entityLink or a patch to upstream's conversion. One carrier for both link kinds gives one validator rule, one renderer rule, one client helper, one guard rule and one strip rule, and Wave F's converter writes target="_blank" the same way whichever element the anchor becomes. The cost is that a content link's stored JSON carries the flag in the odd position a link's content occupies, rather than the natural position a node's own props would be, for a plugin that has props to put it in; accepted, because a document consumer that already reads a link's newTab reads a content link's the identical way.

The link's first text run decides. The server reads content[0].styles.newTab, and the client keeps the flag uniform across the whole link, using the first run as its reference for the toggle's own state. A hand-written document with the flag set only on a later run opens in the same tab; nothing is refused for position, because the validator checks style names, not their position in a run. A run outside any link that carries newTab: true is accepted on save when the format permits new-tab links at all, since the validator enforces names and not positions there either, has no effect on render, since only a link's first run is ever read, and is removed by the editor's own guard the next time an author edits it. Amended 2026-09-26. With the toggle gone, the first run is the reference for the link form's checkbox, which opens showing the flag of the first run of the link the form edits: from Edit link, the link's own; from the formatting toolbar's form, the link the selection starts in, or the selection's own first run when it starts in none. That form commits over the selected text only (0010's amendment of the same date), so over part of a link the checkbox does one of two things. Saved with a different address, the selected text becomes a link of its own carrying the checkbox's choice, and the rest keeps its own. Saved with the same address, the selected text stays part of the one link: adjacent link marks with equal attributes merge, and commitLink() applies the checkbox's choice to that whole resulting link, in the same transaction, so what the author ticked is what the link does, whichever character the selection started at. Edit link from the link toolbar always sets the whole link.

Stored as {"type":"link","href":…,"content":[{"type":"text","text":"read","styles":{"newTab":true}}]}, and the identical shape inside a content link's own content. No version bump: DocumentParser::SCHEMA_VERSION stays 1, because an absent key means what it always meant, no new tab, and every existing document and golden fixture is unchanged.

A new filter setting, link_new_tab, gates it, defaulting on everywhere. DocumentValidator gains LINK_STYLES = ['newTab'], checked in validateTextItem() beside INLINE_STYLES and COLOR_STYLES: a non-boolean value is InvalidInlineStyle; true while the format's link_new_tab is off is a new error, ForbiddenLinkNewTab, with the message "The text format %format does not permit links that open in a new tab.", following 0023's "This text format does not permit X" family. Amended 2026-10-31. 0023's second pass adds the action: "Clear Open in a new tab on the link, or switch to a format that permits it." readPolicy() reads link_new_tab as permitted when the key is absent, the same posture links already takes. RenderPolicy gains allowsNewTabLinks(): bool, true when the policy carries no such key and in permissive(). BlockNoteFilter's plugin definition default is link_new_tab: TRUE; every shipped format and both recipe formats store it TRUE, the snippet format included, because the module has no consumers yet and there is no reason to make the shipped permit-everything profile the one exception a reader has to be told about. A site turns the choice off per format the same way it turns off any other permission, and the toggle then disappears from that format's editor. blocknote_post_update_inline_style_vocabulary() (the same post-update 0031 describes) fills the key in on an existing format the same way, through a plain re-save with no per-format rule. Amended 2026-09-26. The server's reader is DocumentValidator::normalisePolicy(), not readPolicy(), which is the client's reader in js/src/policy.ts and reads links.newTab the same way, absent meaning permitted. The setting takes effect only while the format permits links: BlockNoteFilter::getPolicy() answers link_new_tab as TRUE only when links is TRUE too, and the validator, the renderer and the editor all read that answer, so a format with links off neither offers nor renders a new-tab choice on any link, a content link included, and refuses one on save. The stored setting keeps what the site saved, and the format form, which hides the checkbox while links are off, shows it as saved.

The rendered markup, emitted only when the flag is set on a link the format permits at all: <a href="…" target="_blank" rel="noopener" class="blocknote-link--new-tab">…text…<span class="visually-hidden"> (opens in a new tab)</span></a>. rel is noopener alone, never upstream's nofollow, because this module has no editorial opinion about whether a link should be followed by a crawler. The hidden text is a second child of #content, after the link's own words, so the anchor's visible text is unaffected and a screen reader announces the indication after the link's own label, per G201/H83. InlineContentRenderer::buildLink() builds it directly; EntityLink::build() reads the same first-run flag before calling themedInline(), and its override of buildContent() appends the identical hidden child so the two anchors cannot drift; a shared helper, InlineContentRenderer::newTabDecoration(), builds the attribute set and the hidden child once for both callers. #attached => ['library' => ['system/base']] is set on the link's render array whenever the decoration is emitted, because nothing on a public Olivero page otherwise loads the .visually-hidden rule (web/core/modules/system/css/components/hidden.module.css, in the system/base library): the only core attach point outside tests is BareHtmlPageRenderer.php, and Olivero declares no dependency on system/base and defines no rule of its own. Amended 2026-09-26. The shared helper builds the hidden child and the library only: a content link's three attributes are added by EntityLink::inlineAttributes() itself, after href and with the class merged into the content link's own classes, so its attributes read href, class, target, rel. The hidden text is translated into the language the document is rendered in, which the filter passes down with the text, not the interface language; it is the anchor's last child, after every run of the link's own words; and its leading space sits inside the hidden span, in the same inline template as the words, so nothing visible follows the link.

On a format that forbids it, the save is refused with ForbiddenLinkNewTab; a document already stored with the flag renders the plain anchor, no target, the moment the format stops permitting it, the same posture 0017 already gives every stored value a format has since narrowed; the editor strips the key on load, naming "links that open in a new tab" in the stripped notice when links themselves are still permitted, and silently when links are forbidden altogether, since the notice already says "links" in that case. Amended 2026-09-26. That silence is a plain link's own: a content link is not unwrapped the way a plain link is, so nothing else in the strip names its removal, and its flag is removed and named on load even while links are forbidden altogether.

The marker stays, hidden by default. Amended 2026-09-26. newTabWidgetPlugin() and newTabGlyph() are unchanged in what they draw and when, but links.css now sets .blocknote-link-new-tab-glyph { display: none; }, so a sighted author no longer sees the arrow while editing unless the admin theme opts back in with one rule against the same stable class; README's Administration themes section documents it. The stored document, the rendered page and the hidden "(opens in a new tab)" text this record's Decision section already describes are untouched: only the editor's own, optional hint moves from shown to hidden.

The buttons are "Open link" and "Open in a new tab", in both the plain-link and the content-link toolbars, ordered Edit link, Open link, Open in a new tab, Remove link. OpenLinkAction replaces upstream's own OpenLinkButton at both call sites (js/src/links.tsx, the plain link toolbar, and js/src/entity-link-toolbar.tsx, built since the memo that first proposed this feature was written), because upstream's dictionary string for it, Drupal.t('Open in new tab'), is the exact wording this feature needs for the toggle instead, and reusing it for the action would leave two adjacent buttons named "Open in new tab" and "Open in a new tab" one word apart. NewTabToggle is a new button reading and writing the flag over the link's own range in one transaction, so one undo reverts it; it renders only when the format permits link_new_tab. Amended 2026-09-26. The toggle's name is the module's own string, Drupal.t('Open in a new tab'), not upstream's 'Open in new tab'; what renaming the action avoids is those two names standing side by side a word apart. Upstream's string stays in the generated js/src/dictionary.ts, and so in the bundle, unused, because the generator copies upstream's whole English dictionary. While the author edits, the same blocknoteLinkPolicy extension that holds the guard marks each new-tab link, plain or content, with one arrow at the end of its content, drawn as a ProseMirror widget decoration: once per link rather than once per run, because the style's own element for each run nests inside whatever other style marks the run carries, so an indicator drawn per run would repeat. The arrow is hidden from assistive technology and is not part of the document; the page's indication is the hidden text above. Amended 2026-09-26. The toolbar's fourth button is gone. The new-tab choice moves into LinkTargetForm as a checkbox, <input type="checkbox" name="newTab">, labeled "Open in a new tab", applied together with the URL and the text in one transaction on Save, a button the form now always shows beside the checkbox; Enter anywhere in the form still commits. The toolbar returns to three actions, in order Edit link, Open link, Remove link, and the content-link toolbar to its own three, title, Edit link, Open link, Unlink; NewTabToggle and its two Drupal.announce() calls are removed. OpenLinkAction is unchanged by this: "Open link", upstream's RiExternalLinkFill, now held as a documented Remix asset rather than hand-drawn (0033). The widget decoration this paragraph describes stays and is drawn the same way, but moves from shown to hidden by default; the "The marker stays, hidden by default" amendment above has the detail. The form now sets the flag itself, in the same transaction as the URL and the text, rather than depending on editLink()'s own inherited marks-across behavior to carry a flag set earlier by a separate toolbar action.

Pasted target="_blank" is not read into the new-tab flag in this wave. An author pasting a link from a page that opens all its own links in a new tab has to re-tick each one; the alternative needs a second, coupled change to what this module's own links render as clipboard HTML, and is left for later. Amended 2026-11-01. Decided by the maintainer, both halves. A pasted link whose target, trimmed and in any case, is _blank arrives with the flag on where the format permits new-tab links, as the HTML converter reads it; where the format does not, newTabSpec is not in the schema and the target is ignored as before. newTabSpec reads it through a non-consuming parse() rule on an <a> with an address, and the link mark's own rule still reads the anchor after it. An anchor carrying BlockNote's data-inline-content-type is not read: upstream's link mark renders target="_blank" on every link, and BlockNote renders a pasted HTML document again before it reads it, so every pasted link would gain the flag; markup BlockNote drew carries the flag on the style's own element. The coupled change is the text/html a copy or a cut out of the editor writes: a plain or content link that opens in a new tab carries target="_blank" rel="noopener noreferrer", and any other link loses upstream's target and rel, so a link copied into another editor, this module's or one such as CKEditor 5, keeps the author's choice and no other link gains it. The editor's own rendering of a link, the blocknote/html another BlockNote editor reads first and the data of a drag out of the editor keep upstream's attributes. The vitest specs in js/src/__tests__/paste.test.tsx and entity-link.test.tsx cover both directions. Amended 2026-11-02. Three corrections to the amendment above. The copy writes rel="noopener", not rel="noopener noreferrer", so that the clipboard carries the pair the page renders. Other editors built on tiptap, which put target="_blank" on every link they write and mark none of it with data-inline-content-type, hand over links that all arrive with the flag on; that is the decided behavior, since the clipboard cannot tell a link the author meant to open in a new tab from one the editor opened that way, and the author unticks the ones that should not. A link copied from a page this module rendered carries the hidden "(opens in a new tab)" span inside its anchor, which would paste as the link's own words and make the saved page announce it twice, so the paste drops a .visually-hidden element inside an a.blocknote-link--new-tab, the module's own class: the paste handler in js/src/editor.tsx does so for the clipboard's text/html, and transformPastedHTML in js/src/links.tsx for HTML ProseMirror reads itself, such as a drop.

Alternatives considered

A custom link mark, replacing upstream's. Rejected. link is not one of BlockNote's extensible inline content seams; replacing the mark upstream's own toolbar, paste handling and node conversions all assume would mean re-implementing all three rather than adding one flag to the existing carrier.

Patching upstream's conversion to carry a second attribute. Rejected, on the evidence of #1861, #1118, #2240 and #2666: the maintainers have declined this shape more than once, and a module-local patch would have to be re-applied on every BlockNote upgrade. Amended 2026-09-26. The four do not show the maintainers declining this shape; see the amendment to Context. The rejection rests on the second reason, a patch re-applied on every upgrade, and on upstream offering no seam that persists a second attribute on link.

Reading target="_blank" at render time from an external rule instead of storing anything. Rejected. There is nothing to read it from: this module's own storage never puts target there, only the client's editor DOM and clipboard HTML do, so a render-time rule would have no signal to key off inside a saved document.

A newTab prop on entityLink instead of the shared style carrier. Rejected for this wave. A prop is the natural home for a node that has props, but it means two carriers in every consumer that reads the flag: the validator, the renderer, the client schema builder, the guard and the strip rule would each need a plain-link branch and a content-link branch, and the format's policy would have to reach EntityLink::validate() through a wider InlineValidationContext than it does today. One carrier for both kinds, at the cost of an odd storage position for a plugin that has a props bag, is judged the smaller cost while the module has no released consumers to keep this decision stable for.

Parsing target="_blank" out of pasted HTML now, with a coupled change to strip it from this module's own clipboard output. Not taken; see Context and the "Decisions made by Nodar" section. Recorded as the recommended next step, not rejected outright. Amended 2026-11-01. Taken; the Decision's paragraph on pasted target="_blank" has its amendment.

No second, entityLink-shaped golden format for the fixture corpus. Not needed: with link_new_tab on in the shipped blocknote format by default, the existing golden fixture through the shipped format already exercises the new-tab markup; a second built-in-test format would only have been needed if the shipped format kept the toggle off, which it does not.

Consequences

Search indexes the hidden "(opens in a new tab)" text, because the index is built from the policy-aware rendered output (0030's "the index follows the public output"); plainText() and every token stay unaffected because they read the stored JSON directly, not the render array.

isSelected cannot be dropped from OpenLinkAction: the kit types every link and formatting toolbar button's prop as required (ComponentsContext.tsx:143, 170), so OpenLinkAction, like upstream's own OpenLinkButton and DeleteLinkButton, passes isSelected={false} and the Mantine kit renders aria-pressed="false" on it regardless.

Converting a plain link into a content link loses the flag, because insertInlineContent() replaces the selection's content outright (0028:133 already records the identical loss for a character style); an author who wants the new tab on the new content link re-ticks it. Amended 2026-09-26. No longer so on the one route that converts a selection, the formatting toolbar's form: its checkbox opens showing the plain link's flag, read as the first-run rule's amendment of the same date describes, and a content result picked there is inserted with the flag on its text while the box is ticked. The loss 0028 records for a character style is in its Consequences section, not at line 133.

A theme wanting a different visible indication for a new-tab link overrides blocknote-inline-link.html.twig or blocknote-inline--entity-link.html.twig; the class blocknote-link--new-tab and the hidden text stay, since both are part of the accessibility contract this record makes, not presentation.

The translation extraction test's pin gains "Open link", "Open in a new tab", "The link opens in a new tab.", "The link opens in the same tab." and "links that open in a new tab", moved in the commit that rebuilds dist/. Amended 2026-09-26. "The link opens in a new tab." and "The link opens in the same tab.", the toggle's own two announcements, drop back out of the pin, because NewTabToggle and its Drupal.announce() calls are gone; "Save", the form's new button, joins it instead. "Open link" and "links that open in a new tab" stay. Amended 2026-09-26. "Open in a new tab" stays as well, as the checkbox's label.

An upstream draft, docs/drafts/upstream-blocknote-link-mark-attributes.md, asks TypeCellOS/BlockNote to round-trip a declared attribute of the built-in link mark, or to open an implementation seam for link inside InlineContentSpecs; it is not filed, per this repository's rule that only the maintainer posts upstream.

References

  • BlockNote 0.54.2: @blocknote/core src/extensions/tiptap-extensions/Link/link.ts, src/api/nodeConversions/nodeToBlock.ts:248-258, src/api/nodeConversions/blockToNode.ts:37-40, 106-108, src/schema/styles/createSpec.ts; @blocknote/react src/components/LinkToolbar/LinkToolbarProps.ts, src/components/ComponentsContext.tsx:143, 170.
  • prosemirror-model src/resolvedpos.ts:160-169, the marks-across rule that keeps an inclusive style mark through editLink().
  • WCAG: G201 (Giving users advanced warning when opening a new window), H83 (Using the target attribute to open a new window on user request), 3.2.5 Change on Request.
  • TypeCellOS/BlockNote issues (declined or unresolved requests for a richer link mark): #1861, #1118, #2240, #2666. Amended 2026-09-26. #1861, #1118 and #2240 are issues and #2666 a merged pull request, and none is a declined request for this shape; see the amendment to Context.
  • src/Document/DocumentValidator.php, src/Render/InlineContentRenderer.php, src/Render/RenderPolicy.php, src/Plugin/BlockNoteInlineContent/EntityLink.php, src/Plugin/Filter/BlockNoteFilter.php, blocknote.post_update.php.
  • js/src/links.tsx, js/src/entity-link-toolbar.tsx, js/src/portable/styles.ts, js/src/schema.tsx, js/src/policy.ts, js/src/stripped.tsx.
  • 0010, the policy guard the two new rules join.
  • 0016, the rule that a setting lives on the surface that enforces it.
  • 0017, a stored value a format has since stopped permitting still renders as stored until the author's own edit reaches it. Amended 2026-09-26. Not the posture this record takes from 0017: a new-tab choice the format has since stopped permitting is dropped at render, as a forbidden alignment's class is, while the stored document is left as it is. What 0017 renders as stored is a heading level or a collapsible heading the format has since narrowed, because changing either would change the document's structure.
  • 0023, the refusal message family this record's message follows.
  • 0026, which named the per-link choice as a separate design not yet decided; this record is that design.
  • 0028, the loss insertInlineContent() already causes for a character style, which this record's carrier inherits for the same reason.
  • 0030, "the index follows the public output".
  • 0031, the style carrier and the arrival rule this record reuses.