diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index a0c7edeb..b66c77b8 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -747,6 +747,9 @@ export function NavigationShell( && !/^\/(?:channels|documents|repositories)(?:\/|$)/.test(destination.pathname) ) return; event.preventDefault(); + // Lexical's clickable-link listener is attached at the editor root. Stop + // the event before it reaches that root after this shell has claimed an app route. + event.stopPropagation(); setDrawerOpen(false); navigate(`${destination.pathname}${destination.search}${destination.hash}`); }; diff --git a/e2e/research-child-surface.e2e.ts b/e2e/research-child-surface.e2e.ts index 8089d225..a000e701 100644 --- a/e2e/research-child-surface.e2e.ts +++ b/e2e/research-child-surface.e2e.ts @@ -15,10 +15,56 @@ const CHILD_SOURCE = `# Source review This ordinary child has its own editable document, Decisions, and inline comments. `; +const LINKED_PARENT_SOURCE = `# Parent document + +[Parent source](https://example.com/parent-source) +`; + +const LINKED_CHILD_SOURCE = `# Source review + +[Child source](https://example.com/child-source) +`; + function port(baseURL: string): number { return Number(new URL(baseURL).port); } +test("authored links open from parent and child documents", async ({ baseURL, join, page, room, seed }) => { + await seed(LINKED_PARENT_SOURCE); + let child = await seedChildChannel( + port(baseURL!), + room, + crypto.randomUUID(), + `Linked child ${room.slice(0, 8)}`, + LINKED_CHILD_SOURCE, + ); + await join("ana"); + + let parent = page.locator(`[data-workspace-room="${room}"]`); + let parentLink = parent.getByRole("link", { name: "Parent source", exact: true }); + let parentPopupPromise = page.waitForEvent("popup"); + await parentLink.click(); + let parentPopup = await parentPopupPromise; + await expect(parentPopup).toHaveURL("https://example.com/parent-source"); + expect(await parentPopup.evaluate(() => opener === null)).toBe(true); + await parentPopup.close(); + + let sidebar = page.getByRole("complementary", { name: "Projects" }); + await sidebar.getByRole("link", { name: `Linked child ${room.slice(0, 8)}`, exact: true }) + .click(); + let surface = page.getByRole("region", { + name: `Child document: Linked child ${room.slice(0, 8)}`, + }); + await expect(surface.locator(`[data-workspace-room="${child.id}"]`)).toBeVisible(); + + let childLink = surface.getByRole("link", { name: "Child source", exact: true }); + let childPopupPromise = page.waitForEvent("popup"); + await childLink.click(); + let childPopup = await childPopupPromise; + await expect(childPopup).toHaveURL("https://example.com/child-source"); + await childPopup.close(); +}); + async function captureChatSends(page: Page) { let sends: { channelId: string; text: string; to: Chat.Destination }[] = []; await page.route("**/api/session", async route => { diff --git a/packages/editor/src/widgets-plugin.tsx b/packages/editor/src/widgets-plugin.tsx index 5fa5f7f4..0a829106 100644 --- a/packages/editor/src/widgets-plugin.tsx +++ b/packages/editor/src/widgets-plugin.tsx @@ -11,6 +11,9 @@ * only way a later value arrives. */ +import { useEffect } from "react"; +import { $getSelection, $isRangeSelection } from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { addComposerChild$, realmPlugin } from "@mdxeditor/editor"; import { ChangeObserver } from "./changes-observer"; @@ -33,6 +36,42 @@ import type { WidgetOptions } from "./widget-options"; export { widgets$ } from "./widget-options"; export type { WidgetOptions } from "./widget-options"; +function SafeClickableLinkPlugin() { + let [editor] = useLexicalComposerContext(); + + useEffect(() => { + let follow = (event: MouseEvent) => { + if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) return; + let target = event.target; + if (!(target instanceof Element)) return; + let link = target.closest("a[href]"); + if (!link || link.hasAttribute("download")) return; + let selecting = editor.read(() => { + let selection = $getSelection(); + return $isRangeSelection(selection) && !selection.isCollapsed(); + }); + if (selecting) { + event.preventDefault(); + return; + } + event.preventDefault(); + event.stopPropagation(); + window.open(link.href, "_blank", "noopener,noreferrer"); + }; + let middle = (event: MouseEvent) => { + if (event.button === 1) follow(event); + }; + return editor.registerRootListener((current, previous) => { + previous?.removeEventListener("click", follow); + previous?.removeEventListener("mouseup", middle); + current?.addEventListener("click", follow); + current?.addEventListener("mouseup", middle); + }); + }, [editor]); + + return null; +} + export const widgetsPlugin = realmPlugin({ init(realm, params) { realm.pub(widgets$, params ?? {}); @@ -56,6 +95,9 @@ export const widgetsPlugin = realmPlugin({ realm.pub(addComposerChild$, CalloutPlugin); realm.pub(addComposerChild$, EnterPlugin); realm.pub(addComposerChild$, ResearchDeletionPlugin); + // Link nodes live inside Lexical's contenteditable root, where a normal + // browser click changes the selection instead of following the anchor. + realm.pub(addComposerChild$, SafeClickableLinkPlugin); // Also where `@lexical/table`'s own plugins are registered, which the // editor otherwise runs without. realm.pub(addComposerChild$, TableChrome);