Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions frontend/src/react/components/OpBlockNoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
*/

import { BlockNoteEditorOptions, BlockNoteSchema } from '@blocknote/core';
import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y';
import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture';
import { User } from '@blocknote/core/comments';
import { filterSuggestionItems } from '@blocknote/core/extensions';
Expand Down Expand Up @@ -104,13 +105,12 @@ export function OpBlockNoteEditor({
},
dictionary: localeDictionary,
...(attachmentsEnabled && { uploadFile }),
// When external link capture is enabled, intercept clicks on external
// links via a ProseMirror plugin and route through /external_redirect.
...(captureExternalLinks && {
_tiptapOptions: {
extensions: [ExternalLinkCaptureExtension],
},
}),
_tiptapOptions: {
extensions: [
ExternalLinkA11yExtension,
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
],
},
};
}, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile, captureExternalLinks]);

Expand Down
97 changes: 97 additions & 0 deletions frontend/src/react/extensions/external-link-a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { Node as PmNode } from '@tiptap/pm/model';
import { isHrefExternal } from 'core-stimulus/helpers/external-link-helpers';

const pluginKey = new PluginKey('externalLinkA11y');

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',
}),
);
Comment thread
akabiru marked this conversation as resolved.
break;
}
}
});
return DecorationSet.create(doc, decorations);
}

/**
* TipTap extension that adds `aria-describedby` to external links inside the
* editor via ProseMirror Decorations.
*
* Decorations add DOM attributes without modifying the document model, so
* ProseMirror does not re-render and there is no risk of infinite loops (the
* reason direct DOM mutation was previously avoided for this attribute).
*
* Uses the `state.init/apply` pattern: decorations are rebuilt only when the
* document changes, and cheaply remapped via `DecorationSet.map()` otherwise
* (e.g. on selection changes or non-doc transactions).
*
* The referenced description element (`open-blank-target-link-description`) is
* a screen-reader-only `<span>` that tells assistive-technology users the link
* 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 = Extension.create({
name: 'externalLinkA11y',

addProseMirrorPlugins() {
return [
new Plugin({
key: pluginKey,
state: {
init(_, { doc }) {
return buildDecorations(doc);
},
apply(tr, oldDecos) {
if (tr.docChanged) {
return buildDecorations(tr.doc);
}
return oldDecos.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return pluginKey.getState(state) as DecorationSet;
},
},
}),
];
},
});
21 changes: 15 additions & 6 deletions frontend/src/stimulus/helpers/external-link-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,29 @@ export function isLinkBlank(link:HTMLAnchorElement) {
}

/**
* Returns true when the link points to a different origin than the current page.
* External links receive special treatment for security (noopener/noreferrer)
* and, when capture is enabled, are routed through `/external_redirect` for
* phishing prevention.
* Returns true when the given href string points to a different origin than
* the current page. Works with plain URL strings (e.g. from ProseMirror mark
* attrs) where no HTMLAnchorElement is available.
*/
export function isLinkExternal(link:HTMLAnchorElement) {
export function isHrefExternal(href:string):boolean {
try {
const linkUrl = new URL(link.href, window.location.origin);
const linkUrl = new URL(href, window.location.origin);
return linkUrl.origin !== window.location.origin;
} catch {
return false;
}
}
Comment thread
akabiru marked this conversation as resolved.

/**
* Returns true when the link points to a different origin than the current page.
* External links receive special treatment for security (noopener/noreferrer)
* and, when capture is enabled, are routed through `/external_redirect` for
* phishing prevention.
*/
export function isLinkExternal(link:HTMLAnchorElement) {
return isHrefExternal(link.href);
}

/**
* Returns true when the link is eligible for external-link processing.
* Links with empty hrefs (e.g. anchor-only), download links, and non-web
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,17 @@
expect(link[:rel]).to include("noreferrer")
end

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

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

it_behaves_like "does not freeze when pasting multiple external links"
Expand Down
Loading