Skip to content

(Blocknote): Add external link capture with screen reader hints for blank link targets#22696

Open
akabiru wants to merge 7 commits intodevfrom
feature/blocknote-native-extensions
Open

(Blocknote): Add external link capture with screen reader hints for blank link targets#22696
akabiru wants to merge 7 commits intodevfrom
feature/blocknote-native-extensions

Conversation

@akabiru
Copy link
Copy Markdown
Member

@akabiru akabiru commented Apr 9, 2026

Ticket

What are you trying to accomplish?

Adds external link handling inside the BlockNote editor — both phishing capture (routing clicks through /external_redirect) and screen reader accessibility (aria-describedby hints on external links). Previously, the body-level ExternalLinksController handled this by rewriting DOM attributes, but that causes infinite re-render loops inside ProseMirror's contenteditable.

Supersedes #22689 and #22694.

ext-links-capture-docs.mp4

What approach did you choose and why?

We use BlockNote native extensions (createExtension() with prosemirrorPlugins) instead of DOM manipulation:

  1. ExternalLinkCaptureExtension — intercepts mousedown before ProseMirror's internal click chain, so only our redirect window opens (no double-window from TipTap's Link extension)
  2. ExternalLinkA11yExtension — adds aria-describedby via ProseMirror Decorations, which modify the rendered DOM without touching the document model

Note

These are application-level concerns (not generic enough for op-blocknote-extensions yet).

Both avoid the DOMObserver mutation loop that caused the browser freeze. Shared link utilities are extracted into external-link-helpers.ts for reuse with the body-level controller.

We initially used TipTap's _tiptapOptions.extensions, but the BlockNote team confirmed it was an internal API - so we migrated to BlockNote's native createExtension() framework before merging.

Screenshot 2026-04-08 at 9 29 26 PM

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

akabiru and others added 6 commits April 8, 2026 11:55
ProseMirror's internal DOMObserver re-parses and re-renders any node
whose attributes change, creating infinite loops when the body-level
ExternalLinksController writes target, rel, aria-describedby, or
rewrites href on links inside the editor.

Instead of modifying the DOM, a standalone ProseMirrorExternalLinksController
intercepts clicks on external links and routes them through
/external_redirect via window.open. The document model retains original
URLs, Yjs collaboration is unaffected, and no re-render loops occur.

TipTap's Link extension already renders target="_blank" and
rel="noopener noreferrer nofollow" from its mark schema defaults,
so those attributes are handled natively by ProseMirror.

Shared link utilities (isLinkExternal, shouldProcessLink,
buildRedirectUrl) are extracted into link-handling helpers so both
controllers use a single source of truth without inheritance coupling.
TipTap's Link extension handles clicks via ProseMirror's mouseup-based
handleClick, which fires before any DOM click event. The Stimulus
controller's click interception couldn't prevent TipTap from also
opening a window, resulting in two windows on every external link click.

Replace the Stimulus click interception with a TipTap extension that
uses handleDOMEvents.mousedown. Returning true from mousedown prevents
ProseMirror from creating its internal MouseDown tracker, so the entire
handleClick chain never fires. Only our redirect window opens.

The extension is conditionally registered via _tiptapOptions only when
external link capture is enabled. When disabled, TipTap's default
openOnClick behavior handles link clicks natively.
Handle Text node event targets by normalizing to parentElement
before calling closest('a'). Guard against clicks on anchors
outside the editor content with view.dom.contains(). Replace
brittle sleep with rspec-wait polling for window count.
Narrow closest('a') result with instanceof HTMLAnchorElement for
type safety. Skip non-web protocols (mailto:, tel:, etc.) in
isExternalLinkCandidate to match body-level controller behavior.
Pass element reference to paste_links JS instead of global querySelector.
Previously, aria-describedby was skipped inside contenteditable because
directly mutating link DOM attributes outside ProseMirror's knowledge
triggered infinite re-render loops (PM strips unknown attributes on
re-render, which fires the mutation observer again).

This adds a new ExternalLinkA11yExtension that uses ProseMirror
Decorations to apply aria-describedby to external link text. Decorations
are ProseMirror's sanctioned mechanism for adding DOM attributes without
modifying the document model, so no re-render loop occurs.

The extension is registered unconditionally (not gated behind
capture_external_links), since the screen reader hint is an accessibility
concern independent of phishing capture.

Implementation details:
- Extract isHrefExternal(string) from isLinkExternal(HTMLAnchorElement)
  so the decoration plugin can check externality from ProseMirror mark
  attrs (plain href strings) without needing a DOM element
- Decoration.inline wraps text nodes in a <span> with the attribute
  rather than adding it to the <a> element (standard PM behavior)
- The referenced description element (open-blank-target-link-description)
  is already cloned into the BlockNote shadow DOM by block-note-element.ts

Co-authored-by: Claude <noreply@anthropic.com>
isHrefExternal treated mailto:, tel:, and javascript: URLs as external
because new URL() yields origin "null" for non-web protocols, which
never matches window.location.origin. This meant the a11y decoration
plugin would incorrectly add the "opens in new tab" hint to non-web
links.

Add an explicit protocol check so only http/https URLs are considered
external. This mirrors the existing guard in isExternalLinkCandidate
but applies at the lower level so all callers are safe.

Co-authored-by: Claude <noreply@anthropic.com>
@akabiru akabiru added this to the 17.4.x milestone Apr 9, 2026
@akabiru akabiru self-assigned this Apr 9, 2026
@akabiru akabiru changed the title fix(Blocknote): Add external link capture with screen reader hints for blank link targets (Blocknote): Add external link capture with screen reader hints for blank link targets Apr 9, 2026
@akabiru akabiru marked this pull request as ready for review April 9, 2026 06:09
@akabiru akabiru requested review from a team, Copilot and oliverguenther April 9, 2026 06:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR moves external link handling for the BlockNote (ProseMirror) editor into BlockNote-native extensions to avoid DOM mutation loops, while keeping phishing-mitigation (/external_redirect) and improving accessibility for screen reader users.

Changes:

  • Add BlockNote extensions to (a) intercept external-link clicks for /external_redirect capture and (b) add aria-describedby via ProseMirror decorations.
  • Extract shared external-link utilities into a reusable frontend helper and reuse it in the existing external-links Stimulus controller.
  • Add/extend Selenium feature specs and a helper for simulating link pastes into the editor.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
spec/support/form_fields/primerized/block_note_editor_input.rb Adds paste_links helper to simulate clipboard paste of one/multiple links into BlockNote in feature specs.
modules/documents/spec/features/external_links_in_block_note_spec.rb New feature spec coverage for “no freeze”, target/rel behavior, a11y hint, and captured-click redirect behavior.
frontend/src/stimulus/helpers/external-link-helpers.ts New shared helper functions for external link detection/candidate checks and building /external_redirect URLs.
frontend/src/stimulus/controllers/external-links.controller.ts Refactors controller to use the shared helper utilities.
frontend/src/react/OpBlockNoteContainer.tsx Plumbs new captureExternalLinks prop down to the editor.
frontend/src/react/components/OpBlockNoteEditor.tsx Registers the new external link extensions, feature-gating capture by prop.
frontend/src/react/extensions/external-link-capture.ts Adds a ProseMirror mousedown interception plugin to route external clicks through /external_redirect.
frontend/src/react/extensions/external-link-a11y.ts Adds ProseMirror decorations to apply aria-describedby hints for external links in the editor DOM.
frontend/src/elements/block-note-element.ts Passes capture setting into React via document.body.dataset.externalLinksEnabledValue.

Comment thread frontend/src/stimulus/helpers/external-link-helpers.ts
Comment thread frontend/src/stimulus/helpers/external-link-helpers.ts Outdated
Comment thread modules/documents/spec/features/external_links_in_block_note_spec.rb Outdated
Comment thread frontend/src/react/extensions/external-link-capture.ts Outdated
Comment thread frontend/src/react/extensions/external-link-a11y.ts
BlockNote core team confirmed _tiptapOptions will be removed in a
future version. Convert both editor extensions (ExternalLinkCapture,
ExternalLinkA11y) from TipTap Extension.create() to BlockNote
createExtension(), and register via the extensions option instead
of _tiptapOptions.extensions. ProseMirror plugin code is unchanged.
@akabiru akabiru force-pushed the feature/blocknote-native-extensions branch from bdd7481 to 9a7243c Compare April 9, 2026 06:25
@akabiru akabiru requested a review from bsatarnejad April 10, 2026 11:59
Comment on lines +77 to +87
state: {
init(_, { doc }) {
return buildDecorations(doc);
},
apply(tr, oldDecos) {
if (tr.docChanged) {
return buildDecorations(tr.doc);
}
return oldDecos.map(tr.mapping, tr.doc);
},
},
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
state: {
init(_, { doc }) {
return buildDecorations(doc);
},
apply(tr, oldDecos) {
if (tr.docChanged) {
return buildDecorations(tr.doc);
}
return oldDecos.map(tr.mapping, tr.doc);
},
},
state: {
init(_, { doc }) {
return buildDecorations(doc); // full build once on editor mount
},
apply(tr, oldDecos) {
if (tr.docChanged) {
return buildDecorations(tr.doc); // rebuild if doc changed
}
return oldDecos.map(tr.mapping, tr.doc); // cheap remap otherwise
},
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 explainer ✨

  • init runs once when the editor boots — does the first full scan
  • apply runs on every transaction:
    • tr.docChanged is true only when actual content changed (not cursor moves, not selection changes)
    • On doc change: full rebuild — because a link could have been added, removed, or its href changed
    • Otherwise: .map() shifts existing decoration positions through the transaction's mapping — O(decorations) not O(doc)

One potential improvement here: rebuilding on every docChanged is slightly conservative. If only a non-link node changed (e.g. someone typed in a paragraph with no links), you're still doing a full scan. For most documents this is fine. For very large documents you could add a heuristic:

apply(tr, oldDecos) {
  if (!tr.docChanged) return oldDecos.map(tr.mapping, tr.doc);
  // Only rebuild if a link mark was actually affected
  const affectsLinks = tr.steps.some(step => /* check step ranges touch link marks */);
  return affectsLinks ? buildDecorations(tr.doc) : oldDecos.map(tr.mapping, tr.doc);
}

But that's an optimisation you'd only need at scale — the current approach is correct and plenty fast for normal document sizes.

Comment on lines +37 to +53
function buildDecorations(doc:PmNode):DecorationSet {
const decorations:Decoration[] = [];
doc.descendants((node, pos) => {
for (const mark of node.marks) {
if (mark.type.name === 'link' && isHrefExternal(String(mark.attrs.href ?? ''))) {
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, {
'aria-describedby': 'open-blank-target-link-description',
}),
);
break;
}
}
});
return DecorationSet.create(doc, decorations);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 explainer ✨

  • doc.descendants() walks every text node in the document
  • For each node it checks its marks (inline formatting) for one named 'link'
  • If found, it calls isHrefExternal() to decide if the href points outside the app
  • If external: pushes a Decoration.inline() spanning exactly that text node (pos to pos + node.nodeSize)
  • The decoration adds a single DOM attribute — aria-describedby — to the rendered element
  • break after the first matching link mark is a nice micro-optimisation — a node won't have two link marks simultaneously, so no need to keep iterating

One subtlety worth noting: Decoration.inline() here spans the text node, not the <a> tag boundary. In ProseMirror, a link mark can span multiple adjacent text nodes (e.g. bold + linked text). This means if a single <a> spans multiple text nodes, each gets its own decoration. That's fine for aria-describedby since the attribute ends up on each rendered span within the link — but worth being aware of.

* opens in a new tab. It lives in the main layout (`base.html.erb`) and is
* cloned into the BlockNote shadow DOM by `block-note-element.ts`.
*/
export const ExternalLinkA11yExtension = createExtension({
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 explainer ✨

createExtension from @blocknote/core is BlockNote's thin wrapper around Tiptap extensions. Using prosemirrorPlugins here is the correct integration point — it maps directly to Tiptap's addProseMirrorPlugins() under the hood. The plugin is registered once and participates in ProseMirror's normal plugin lifecycle.

@bsatarnejad
Copy link
Copy Markdown
Contributor

Hi @akabiru
I tested the branch locally only with VO. The external link on a document is not mentioned to be opened in a new tab. I inspected the rendered DOM, and the aria-describedby is applied to a nested inside the link, not to the element itself. Since the anchor is the focused element announced by VoiceOver, the “opens in new tab” description is not read.

@akabiru
Copy link
Copy Markdown
Member Author

akabiru commented Apr 15, 2026

Thanks @bsatarnejad - I'll take a look at that; a bit surprised as the chrome accessibility tree shows the correct result. But perhaps there are nuances with shadow dom and screenreaders.

@bsatarnejad
Copy link
Copy Markdown
Contributor

Thanks @bsatarnejad - I'll take a look at that; a bit surprised as the chrome accessibility tree shows the correct result. But perhaps there are nuances with shadow dom and screenreaders.

Hi @akabiru
I tested the current implementation again with VoiceOver and NVDA, and noticed that the “opens in new tab” hint is not announced.

Initially, I assumed this might be due to aria-describedby being applied to a nested span rather than the anchor. However, even when manually applying aria-describedby directly on the <a> element, VoiceOver still does not announce the description.

This suggests that the issue is not just where the attribute is applied, but that the aria-describedby approach may not be reliable in this ProseMirror/contenteditable context.

Given that “opens in new tab” is important interaction information, it would ideally be conveyed directly on the link in a way that is reliably announced by assistive technologies. It might be worth considering an alternative approach (e.g. a more robust semantic solution or a programmatic announcement) rather than relying solely on aria-describedby.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants