Skip to content

Commit 211ed27

Browse files
akabiruclaude
andcommitted
Add aria-describedby screen reader hint for external links in BlockNote
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>
1 parent b11f2a6 commit 211ed27

4 files changed

Lines changed: 128 additions & 16 deletions

File tree

frontend/src/react/components/OpBlockNoteEditor.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
*/
3030

3131
import { BlockNoteEditorOptions, BlockNoteSchema } from '@blocknote/core';
32+
import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y';
3233
import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture';
3334
import { User } from '@blocknote/core/comments';
3435
import { filterSuggestionItems } from '@blocknote/core/extensions';
@@ -104,13 +105,12 @@ export function OpBlockNoteEditor({
104105
},
105106
dictionary: localeDictionary,
106107
...(attachmentsEnabled && { uploadFile }),
107-
// When external link capture is enabled, intercept clicks on external
108-
// links via a ProseMirror plugin and route through /external_redirect.
109-
...(captureExternalLinks && {
110-
_tiptapOptions: {
111-
extensions: [ExternalLinkCaptureExtension],
112-
},
113-
}),
108+
_tiptapOptions: {
109+
extensions: [
110+
ExternalLinkA11yExtension,
111+
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
112+
],
113+
},
114114
};
115115
}, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile, captureExternalLinks]);
116116

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//-- copyright
2+
// OpenProject is an open source project management software.
3+
// Copyright (C) the OpenProject GmbH
4+
//
5+
// This program is free software; you can redistribute it and/or
6+
// modify it under the terms of the GNU General Public License version 3.
7+
//
8+
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
9+
// Copyright (C) 2006-2013 Jean-Philippe Lang
10+
// Copyright (C) 2010-2013 the ChiliProject Team
11+
//
12+
// This program is free software; you can redistribute it and/or
13+
// modify it under the terms of the GNU General Public License
14+
// as published by the Free Software Foundation; either version 2
15+
// of the License, or (at your option) any later version.
16+
//
17+
// This program is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with this program; if not, write to the Free Software
24+
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25+
//
26+
// See COPYRIGHT and LICENSE files for more details.
27+
//++
28+
29+
import { Extension } from '@tiptap/core';
30+
import { Plugin, PluginKey } from '@tiptap/pm/state';
31+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
32+
import type { Node as PmNode } from '@tiptap/pm/model';
33+
import { isHrefExternal } from 'core-stimulus/helpers/external-link-helpers';
34+
35+
const pluginKey = new PluginKey('externalLinkA11y');
36+
37+
function buildDecorations(doc:PmNode):DecorationSet {
38+
const decorations:Decoration[] = [];
39+
doc.descendants((node, pos) => {
40+
for (const mark of node.marks) {
41+
if (mark.type.name === 'link' && isHrefExternal(String(mark.attrs.href ?? ''))) {
42+
decorations.push(
43+
Decoration.inline(pos, pos + node.nodeSize, {
44+
'aria-describedby': 'open-blank-target-link-description',
45+
}),
46+
);
47+
break;
48+
}
49+
}
50+
});
51+
return DecorationSet.create(doc, decorations);
52+
}
53+
54+
/**
55+
* TipTap extension that adds `aria-describedby` to external links inside the
56+
* editor via ProseMirror Decorations.
57+
*
58+
* Decorations add DOM attributes without modifying the document model, so
59+
* ProseMirror does not re-render and there is no risk of infinite loops (the
60+
* reason direct DOM mutation was previously avoided for this attribute).
61+
*
62+
* Uses the `state.init/apply` pattern: decorations are rebuilt only when the
63+
* document changes, and cheaply remapped via `DecorationSet.map()` otherwise
64+
* (e.g. on selection changes or non-doc transactions).
65+
*
66+
* The referenced description element (`open-blank-target-link-description`) is
67+
* a screen-reader-only `<span>` that tells assistive-technology users the link
68+
* opens in a new tab. It lives in the main layout (`base.html.erb`) and is
69+
* cloned into the BlockNote shadow DOM by `block-note-element.ts`.
70+
*/
71+
export const ExternalLinkA11yExtension = Extension.create({
72+
name: 'externalLinkA11y',
73+
74+
addProseMirrorPlugins() {
75+
return [
76+
new Plugin({
77+
key: pluginKey,
78+
state: {
79+
init(_, { doc }) {
80+
return buildDecorations(doc);
81+
},
82+
apply(tr, oldDecos) {
83+
if (tr.docChanged) {
84+
return buildDecorations(tr.doc);
85+
}
86+
return oldDecos.map(tr.mapping, tr.doc);
87+
},
88+
},
89+
props: {
90+
decorations(state) {
91+
return pluginKey.getState(state) as DecorationSet;
92+
},
93+
},
94+
}),
95+
];
96+
},
97+
});

frontend/src/stimulus/helpers/external-link-helpers.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,29 @@ export function isLinkBlank(link:HTMLAnchorElement) {
4242
}
4343

4444
/**
45-
* Returns true when the link points to a different origin than the current page.
46-
* External links receive special treatment for security (noopener/noreferrer)
47-
* and, when capture is enabled, are routed through `/external_redirect` for
48-
* phishing prevention.
45+
* Returns true when the given href string points to a different origin than
46+
* the current page. Works with plain URL strings (e.g. from ProseMirror mark
47+
* attrs) where no HTMLAnchorElement is available.
4948
*/
50-
export function isLinkExternal(link:HTMLAnchorElement) {
49+
export function isHrefExternal(href:string):boolean {
5150
try {
52-
const linkUrl = new URL(link.href, window.location.origin);
51+
const linkUrl = new URL(href, window.location.origin);
5352
return linkUrl.origin !== window.location.origin;
5453
} catch {
5554
return false;
5655
}
5756
}
5857

58+
/**
59+
* Returns true when the link points to a different origin than the current page.
60+
* External links receive special treatment for security (noopener/noreferrer)
61+
* and, when capture is enabled, are routed through `/external_redirect` for
62+
* phishing prevention.
63+
*/
64+
export function isLinkExternal(link:HTMLAnchorElement) {
65+
return isHrefExternal(link.href);
66+
}
67+
5968
/**
6069
* Returns true when the link is eligible for external-link processing.
6170
* Links with empty hrefs (e.g. anchor-only), download links, and non-web

modules/documents/spec/features/external_links_in_block_note_spec.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,17 @@
7878
expect(link[:rel]).to include("noreferrer")
7979
end
8080

81-
it "does not set aria-describedby inside contenteditable to avoid ProseMirror re-render loop" do
81+
it "sets aria-describedby on external links for screen reader accessibility" do
8282
editor.paste_links(text: "Accessible Link", url: "https://example.com")
8383

84-
link = editor.shadow_root.find("a[target='_blank']", text: "Accessible Link", wait: 5)
85-
expect(link[:"aria-describedby"]).to be_nil.or eq("")
84+
# ProseMirror inline decorations wrap the text node in a <span> with the
85+
# attribute, rather than adding it to the <a> element (which is rendered
86+
# by the link mark). The screen reader hint is on the link's text content.
87+
editor.shadow_root.find(
88+
"a[target='_blank'] [aria-describedby='open-blank-target-link-description']",
89+
text: "Accessible Link",
90+
wait: 5
91+
)
8692
end
8793

8894
it_behaves_like "does not freeze when pasting multiple external links"

0 commit comments

Comments
 (0)