Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions apps/web/src/navigation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
};
Expand Down
46 changes: 46 additions & 0 deletions e2e/research-child-surface.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
42 changes: 42 additions & 0 deletions packages/editor/src/widgets-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<HTMLAnchorElement>("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<WidgetOptions>({
init(realm, params) {
realm.pub(widgets$, params ?? {});
Expand All @@ -56,6 +95,9 @@ export const widgetsPlugin = realmPlugin<WidgetOptions>({
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);
Expand Down
Loading