Skip to content

Commit bc3a6be

Browse files
Harden authored link popups
1 parent 2245b49 commit bc3a6be

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

‎e2e/research-child-surface.e2e.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ test("authored links open from parent and child documents", async ({ baseURL, jo
4646
await parentLink.click();
4747
let parentPopup = await parentPopupPromise;
4848
await expect(parentPopup).toHaveURL("https://example.com/parent-source");
49+
expect(await parentPopup.evaluate(() => opener === null)).toBe(true);
4950
await parentPopup.close();
5051

5152
let sidebar = page.getByRole("complementary", { name: "Projects" });

‎packages/editor/src/widgets-plugin.tsx‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
* only way a later value arrives.
1212
*/
1313

14+
import { useEffect } from "react";
15+
import { $getSelection, $isRangeSelection } from "lexical";
16+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
1417
import { addComposerChild$, realmPlugin } from "@mdxeditor/editor";
15-
import { ClickableLinkPlugin } from "@lexical/react/LexicalClickableLinkPlugin";
1618

1719
import { ChangeObserver } from "./changes-observer";
1820
import { CommentLayer } from "./comment-layer";
@@ -34,6 +36,42 @@ import type { WidgetOptions } from "./widget-options";
3436
export { widgets$ } from "./widget-options";
3537
export type { WidgetOptions } from "./widget-options";
3638

39+
function SafeClickableLinkPlugin() {
40+
let [editor] = useLexicalComposerContext();
41+
42+
useEffect(() => {
43+
let follow = (event: MouseEvent) => {
44+
if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) return;
45+
let target = event.target;
46+
if (!(target instanceof Element)) return;
47+
let link = target.closest<HTMLAnchorElement>("a[href]");
48+
if (!link || link.hasAttribute("download")) return;
49+
let selecting = editor.read(() => {
50+
let selection = $getSelection();
51+
return $isRangeSelection(selection) && !selection.isCollapsed();
52+
});
53+
if (selecting) {
54+
event.preventDefault();
55+
return;
56+
}
57+
event.preventDefault();
58+
event.stopPropagation();
59+
window.open(link.href, "_blank", "noopener,noreferrer");
60+
};
61+
let middle = (event: MouseEvent) => {
62+
if (event.button === 1) follow(event);
63+
};
64+
return editor.registerRootListener((current, previous) => {
65+
previous?.removeEventListener("click", follow);
66+
previous?.removeEventListener("mouseup", middle);
67+
current?.addEventListener("click", follow);
68+
current?.addEventListener("mouseup", middle);
69+
});
70+
}, [editor]);
71+
72+
return null;
73+
}
74+
3775
export const widgetsPlugin = realmPlugin<WidgetOptions>({
3876
init(realm, params) {
3977
realm.pub(widgets$, params ?? {});
@@ -59,7 +97,7 @@ export const widgetsPlugin = realmPlugin<WidgetOptions>({
5997
realm.pub(addComposerChild$, ResearchDeletionPlugin);
6098
// Link nodes live inside Lexical's contenteditable root, where a normal
6199
// browser click changes the selection instead of following the anchor.
62-
realm.pub(addComposerChild$, () => <ClickableLinkPlugin newTab />);
100+
realm.pub(addComposerChild$, SafeClickableLinkPlugin);
63101
// Also where `@lexical/table`'s own plugins are registered, which the
64102
// editor otherwise runs without.
65103
realm.pub(addComposerChild$, TableChrome);

0 commit comments

Comments
 (0)