Repository navigation
Round-trip a declared attribute of the link mark through nodeToBlock/blockToNode, or open an InlineContentSpecs seam for link
#3279
NodarDavituri
started this conversation in
Ideas & Enhancements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
The built-in
linkkeeps only itshrefthrough BlockNote's block API, so a host application cannot store an author's choice about one link, such as whether it opens in a new tab, on the link itself. This asks for a way to do that. Every code link below is at v0.57.0, and the behavior was reproduced on@blocknote/core0.57.0.The gap
The
linkmark declares exactly one attribute, inlink.ts, lines 91-100:and the block API's
Linktype hastype,hrefandcontentonly.nodeToBlock.ts, lines 282-292 reads onlyhrefoff the mark when it builds alinkinline content item from a ProseMirror node, and so do lines 318-328 and lines 355-365:and
blockToNode.ts, lines 110-112 recreates the mark fromhrefalone:as does
editLink(), which the link toolbar's edit form calls, inStyleManager.ts, line 248.target="_blank"andrel="noopener noreferrer nofollow"exist only as render-timeHTMLAttributesdefaults on the mark, inaddOptions(), lines 77-89, andrenderHTML()merges those options over the mark's own attributes. So every link an editor draws, and every link upstream's owntoExternalHTMLputs on the clipboard, already carriestarget="_blank", and nothing about that choice is ever stored or read back. A host application that wants an author-controlled per-link attribute, such as whether a link opens in a new tab, has no declared way to add one tolink: unlike a custom type registered throughInlineContentSpecs, the built-inlinkhas no prop schema, and an attribute added to the mark from outside, through a TipTap extension's global attributes, reaches the ProseMirror schema but is dropped by both conversions, as step 3 below shows.Steps to reproduce the gap
Seen on 2026-10-30 with
@blocknote/core0.57.0,@tiptap/core3.32.2 andprosemirror-model1.25.12, in a plain Vite page without React, in headless Chrome 156.0.8078.76. The comments under eachconsole.logare what it printed.Create an editor whose document has a link carrying two keys beside
href, mount it, and read the document and the link mark back:Load the same document into the running editor with
editor.replaceBlocks(editor.document, doc()), and parse<p><a href="https://example.org/" target="_self" data-kind="internal">x</a></p>witheditor.tryParseHTMLToBlocks().Expected, if the link round-tripped an author-controlled attribute: the extra keys survive, and so do the HTML's
targetanddata-kind. Actual, on 0.57.0: each time the link comes back as{"type":"link","href":"https://example.org/","content":[{"type":"text","text":"x","styles":{}}]}, the mark in the editor has the attributes{"href":"https://example.org/"}only, and nothing is logged. The keys are silently dropped the first time the document passes through the editor, whatever the input contained, and the rendered link, like the output ofeditor.blocksToHTMLLossy(), carriestarget="_blank"andrel="noopener noreferrer nofollow"regardless.links.HTMLAttributesdoes not change this: an editor created withlinks: { HTMLAttributes: { target: "_self" } }renders every link withtarget="_self", stores nothing, and returns the same link.Declare
targeton the mark with a TipTap extension, and set it on the link in a transaction:The mark's spec now has
target, but thetargetininitialContentstill arrives asnull. After the transaction, the mark in the ProseMirror state hastarget: "_self", yetdeclared.documentreturns the link without it, and the editor renders the link withtarget="_blank", becauselinks.HTMLAttributesis merged over the mark's own attribute. Loading that document back withreplaceBlocks(), as a host does with a document it has saved, sets the mark'stargettonullagain.What this project does meanwhile
The BlockNote Editor module for Drupal (https://www.drupal.org/project/blocknote) stores the choice as a boolean style,
newTab, on the text runs inside the link's own content, because a style round-trips where a mark attribute does not. A style mark is inclusive by default, so it surviveseditLink()'sinsertText(), which keeps the marks across the text it replaces (decision record 0032 has the measurement). The module's own link form does not calleditLink(), though: it repeats that function'sinsertText()andaddMark()and then sets or clears the style over the same text in the same transaction, from an "Open in a new tab" checkbox in the form, and a test pins that the style survives an edit of both the URL and the text. The same style serves the module's own custom inline content type for links to site content, so both kinds of link carry the choice the same way.The link's first run decides, and the rest has to be kept in line by hand. A plugin appended to every transaction copies the first run's flag onto the link's other runs, so text pasted into the middle of a new-tab link gains it, and takes the flag off any run that ends up outside a link, which typing at a link's end produces, since the style mark is inclusive and the link mark is not. The editor's own sign that a link opens in a new tab is a widget decoration drawn once at the end of the link, because the style's element for each run nests inside whatever other style marks the run carries and a sign drawn per run would repeat; it is hidden by default, and a theme may show it with one rule. The rendered page reads the first run only.
This works, but the choice lives beside the link's text rather than beside its
href, where an integrator reading the stored document would not look for it, and a carrier that can differ from run to run has to be held uniform for a choice that belongs to the link as a whole.What this asks for
One of, in order of preference:
linkbeyondhref, the same way a custom inline content type declares its props, and carry them throughnodeToBlockandblockToNode, so a stored link keeps them. That also means comparing them wherenodeToBlockdecides whether adjacent text belongs to the same link, which today compareshrefonly (line 299), keeping them ineditLink(), and letting a stored attribute win over the same attribute inlinks.HTMLAttributeswhen the link renders.links.HTMLAttributesfrom feat: Link customization options (BLO-913) #2666 already covers rendering, for every link at once; this is the storage half, and the "default link with an additional prop" a maintainer asked about in Distinguish link types (e.g. External vs Internal) #2479.linkis deliberately not extensible this way, and instead expose an official seam, comparable toInlineContentSpecs, for replacing or wrapping the built-inlinktype with one that declares extra attributes while keeping the toolbar, paste handling and node conversions that already assume alink-shaped mark exists. The answer on Override rendering of built-in blocks (Link) #1118 says customizing a link through the custom inline content API would be a major breaking change, so this may have to be a seam of its own.Related
links.HTMLAttributesandlinks.onClick, and fix: Link HTML attributes (BLO-915) #2687 fixedlinks.HTMLAttributes; both apply to every link alike.AI use: I maintain the BlockNote module for Drupal. Its code, tests and documentation, this request and the reproduction behind it were made with AI coding agents that I direct.
All reactions