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
12 changes: 12 additions & 0 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -1498,6 +1498,18 @@ button.question-add:disabled {
outline: none;
}

/* A rendered block clips its preview's ring, so show the same ring on the outer block. */
.plan-content .planCode :is(.plan-code-view, .plan-diagram):focus-visible {
outline: none;
}

:root:not([data-focus-input="pointer"])
.plan-content
.planCode:has(> [data-plan-preview] :is(.plan-code-view, .plan-diagram):focus-visible) {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}

/*
* The ring answers "where am I?" for keyboard users, so it hides after a pointer
* press. Text fields keep theirs: a caret alone is easy to lose.
Expand Down
165 changes: 165 additions & 0 deletions e2e/code.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,171 @@ test("hiding the source leaves what was drawn from it", async ({ join, seed }) =
await expect(content(page).getByRole("button", { name: "Show source" })).toBeVisible();
});

/*
* Getting into a rendered block.
*
* The preview is derived and cannot take a caret, so every way of arriving at
* one has to end with the caret in the source, or with focus on the preview
* and a key that leads into the source from there. Typing is how each test
* proves where the caret went: the file is the only witness that cannot be
* fooled by a caret drawn somewhere it is not.
*/

test("clicking drawn code opens its source at the clicked line", async ({ join, room, seed }) => {
await seed("```ts\nlet a = 1;\nlet b = 2;\n```\n");
let page = await join("ana");
let block = content(page).locator(".planCode");

await block.locator("[data-line='2']").click();

// One copy: the source replaces the coloured lines rather than joining them.
await expect(block.locator("[data-plan-source]")).toBeVisible();
await expect(block.locator("[data-file]")).toBeHidden();
await expect(content(page)).toBeFocused();
await page.keyboard.type("X");
await written(page, room, /^let a = 1;\n[^\n]*X[^\n]*\n```/m);

await page.keyboard.press("Escape");
await expect(block.locator("[data-plan-source]")).toBeHidden();
await expect(block.getByRole("group", { name: "Code preview" })).toBeFocused();
});

test("clicking a drawn diff line opens the patch at that line", async ({ join, room, seed }) => {
await seed(PATCH);
let page = await join("ana");

await content(page).locator("[data-line][data-line-type='change-addition']").first().click();
await page.keyboard.type("X");

await written(page, room, /^\+(?=[^\n]*rotate)[^\n]*X[^\n]*$/m);
});

test("a focused preview opens on Enter, Space or typing, and never scrolls", async ({ join, room, seed }) => {
await seed("```ts\nlet a = 1;\n```\n");
let page = await join("ana");
let block = content(page).locator(".planCode");
let preview = block.getByRole("group", { name: "Code preview" });
let scroller = page.locator("[data-plan-scroll]");

await preview.focus();
let before = await scroller.evaluate(node => node.scrollTop);
await page.keyboard.press("Space");
await expect(block.locator("[data-plan-source]")).toBeVisible();
expect(await scroller.evaluate(node => node.scrollTop)).toBe(before);

await page.keyboard.press("Escape");
await expect(preview).toBeFocused();
await page.keyboard.press("Enter");
await expect(content(page)).toBeFocused();

await page.keyboard.press("Escape");
await expect(preview).toBeFocused();
await page.keyboard.type("Z");
await written(page, room, /^let a = 1;Z$/m);
});

test("arrows step into code, onto a diagram, and out again", async ({ join, room, seed }) => {
await seed("Before\n\n```ts\nlet a = 1;\n```\n\n```mermaid\ngraph TD;\nA-->B;\n```\n\nAfter\n");
let page = await join("ana");
let [code, diagram] = [
content(page).locator(".planCode").nth(0),
content(page).locator(".planCode").nth(1),
];
let region = diagram.getByRole("region", { name: "Diagram preview" });
await expect(region).toBeVisible();
await expect(code.locator("[data-file]")).toBeVisible();

// A hidden source has no layout, so the browser alone would skip both.
await content(page).getByText("Before").click();
await page.keyboard.press("ArrowDown");
await expect(code.locator("[data-plan-source]")).toBeVisible();
await page.keyboard.type("Q");
await written(page, room, /^Qlet a = 1;$/m);

// A drawing is held as a block, not opened: arrowing past it is reading.
await page.keyboard.press("ArrowDown");
await expect(region).toBeFocused();
await expect(code.locator("[data-plan-source]")).toBeHidden();
await expect(diagram.locator("[data-plan-source]")).toBeHidden();

// Enter edits it, with the drawing kept above; Escape comes back.
await page.keyboard.press("Enter");
await expect(diagram.locator("[data-plan-source]")).toBeVisible();
await expect(region).toBeVisible();
await page.keyboard.press("Escape");
await expect(region).toBeFocused();
await expect(diagram.locator("[data-plan-source]")).toBeHidden();

await page.keyboard.press("ArrowDown");
await page.keyboard.type("!");
await written(page, room, /^!After$/m);

await page.keyboard.press("ArrowUp");
await expect(region).toBeFocused();
await page.keyboard.press("ArrowUp");
await page.keyboard.type("R");
await written(page, room, /^RQlet a = 1;$/m);
});

test("dragging across drawn code selects it to copy, without opening", async ({ join, seed }) => {
await seed("```ts\nlet a = 1;\nexport function open(room: string) {}\n```\n");
let page = await join("ana");
let block = content(page).locator(".planCode");
let line = block.locator("[data-line='2']");
await expect(line).toBeVisible();
await page.evaluate(() =>
window.addEventListener("copy", event => {
(window as unknown as { copied: unknown }).copied = {
prevented: event.defaultPrevented,
text: document.getSelection()?.toString(),
};
})
);

let box = (await line.boundingBox())!;
await page.mouse.move(box.x + 16, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 120, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
let dragged = await page.evaluate(() => document.getSelection()?.toString() ?? "");

// Shift extends it from where the drag began, as it would in text.
await page.keyboard.down("Shift");
await page.mouse.click(box.x + 200, box.y + box.height / 2);
await page.keyboard.up("Shift");

await expect(block.locator("[data-plan-source]")).toBeHidden();
await expect(block.locator("[data-file]")).toBeVisible();
let selected = await page.evaluate(() => document.getSelection()?.toString() ?? "");
expect(selected.length).toBeGreaterThan(dragged.length);
expect(selected.startsWith(dragged)).toBe(true);
expect("export function open(room: string) {}").toContain(selected);

// The browser copies what is selected; Lexical must not swap in its own.
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => (window as unknown as { copied: unknown }).copied))
.toEqual({ prevented: false, text: selected });
});

test("arrows reach code inside a callout and a list item", async ({ join, room, seed }) => {
await seed(
'<Callout id="01K0N4Y9VG9DHBFZB6HC89E2AC" type="note" title="Nested">\n\nInside.\n\n```ts\nlet a = 1;\n```\n\n</Callout>\n\n- Item\n\n ```ts\n let b = 2;\n ```\n\n- Next\n',
);
let page = await join("ana");
await expect(content(page).locator("[data-file]")).toHaveCount(2);

await content(page).getByText("Inside.").click();
await page.keyboard.press("ArrowDown");
await page.keyboard.type("Q");
await written(page, room, /^\s*Qlet a = 1;$/m);

await content(page).getByText("Next").click();
await page.keyboard.press("ArrowUp");
await page.keyboard.type("R");
await written(page, room, /^\s*Rlet b = 2;$/m);
});

/*
* Two people in one fence.
*
Expand Down
8 changes: 6 additions & 2 deletions e2e/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { visibleOutlineColor } from "./focus-color";

import type { Locator } from "@playwright/test";

export async function expectFocusIndicator(target: Locator): Promise<void> {
/**
* `ring` is the element that draws the indicator, when that is not the focused
* element itself: a preview whose block clips it, say.
*/
export async function expectFocusIndicator(target: Locator, ring = target): Promise<void> {
await expect(target).toBeFocused();
let result = await target.evaluate(element => {
let result = await ring.evaluate(element => {
let style = getComputedStyle(element);
let width = parseFloat(style.outlineWidth);
let offset = parseFloat(style.outlineOffset);
Expand Down
4 changes: 3 additions & 1 deletion e2e/syntax-contrast.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ test("a narrow code preview can be scrolled with the keyboard", async ({ join, s
expect(await preview.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true);
await expect(preview).toHaveAttribute("tabindex", "0");
await preview.focus();
await expectFocusIndicator(preview);
// The block clips its corners and the renderer paints over the preview's
// own box, so the whole block carries the ring.
await expectFocusIndicator(preview, content(page).locator(".planCode"));
await page.keyboard.press("ArrowRight");
await expect.poll(() => preview.evaluate(node => node.scrollLeft)).toBeGreaterThan(0);
});
Expand Down
27 changes: 27 additions & 0 deletions packages/editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2448,6 +2448,33 @@
background: var(--color-inset);
}

/*
* Editing code or a diff swaps the coloured copy for its source in the same
* box, so a line is never on screen twice. The source is the text now, not a
* footnote to it.
*/
.plan-content .planCode[data-plan-swapped] [data-plan-preview] {
display: none;
}

.plan-content .planCode[data-plan-swapped] [data-plan-preview]:not(:empty) ~ [data-plan-source] {
opacity: 1;
border-block-start: 0;
background: var(--color-page);
}

@media (prefers-reduced-motion: no-preference) {
.plan-content .planCode[data-plan-swapped] [data-plan-source] {
animation: plan-source-in var(--duration-fast) var(--ease-out);
}
}

@keyframes plan-source-in {
from {
opacity: 0;
}
}

.plan-content .planCode [data-plan-preview] :is(.plan-diagram, [data-plan-error]) {
padding-inline: 0.75rem;
}
Expand Down
124 changes: 124 additions & 0 deletions packages/editor/src/widgets/preview-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Selecting text in a rendered preview with the pointer.
*
* A preview sits inside the editable root, and Chromium answers a press there
* by putting the caret at the nearest editable position: the block's source,
* hidden while the preview shows. A drag then selects nothing visible, and
* copying code out of a preview is impossible. Coloured code is also drawn in
* shadow roots, which a selection begun in the light tree never enters.
*
* So the press is taken over: the browser's default is cancelled, and the
* selection is set from the points under the pointer, inside the shadow roots
* the renderer draws in. Double and triple presses select a word and a line,
* as they would anywhere else; for a writer the first click of those has
* already opened the source, so in practice they serve readers. The selection
* never touches the editor's text, so Lexical leaves it alone.
*/

type CaretPositionAt = (
x: number,
y: number,
options?: { shadowRoots?: ShadowRoot[] },
) => { offsetNode: Node; offset: number } | null;

type Point = { node: Node; offset: number };

function shadowRoots(preview: HTMLElement): ShadowRoot[] {
return [preview, ...preview.querySelectorAll("*")].flatMap(element =>
element.shadowRoot ? [element.shadowRoot] : []
);
}

function inside(preview: HTMLElement, node: Node): boolean {
for (let at: Node | null = node; at; at = (at.getRootNode() as ShadowRoot).host ?? null) {
if (preview.contains(at)) return true;
if (!(at.getRootNode() instanceof ShadowRoot)) return false;
}
return false;
}

function pointAt(preview: HTMLElement, x: number, y: number): Point | undefined {
let at = (preview.ownerDocument as unknown as { caretPositionFromPoint?: CaretPositionAt })
.caretPositionFromPoint?.(x, y, { shadowRoots: shadowRoots(preview) });
if (!at || !inside(preview, at.offsetNode)) return undefined;
return { node: at.offsetNode, offset: at.offset };
}

/** The drawn line, or failing that the text node, holding a point. */
function lineOf(point: Point): Node {
let element = point.node instanceof Element ? point.node : point.node.parentElement;
return element?.closest("[data-line]") ?? point.node;
}

/**
* Listens on an editor root for presses that start on a preview.
* `preview` names the preview an event target is in, if any.
*/
export function registerPreviewSelection(
root: HTMLElement,
preview: (target: Element) => HTMLElement | null,
): () => void {
let document = root.ownerDocument;
let drag: { preview: HTMLElement; anchor: Point } | undefined;

let down = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
let target = event.target instanceof Element ? preview(event.target) : null;
if (!target) return;
let point = pointAt(target, event.clientX, event.clientY);
if (!point) return;
event.preventDefault();
target.focus({ preventScroll: true });
let selection = document.getSelection();
if (!selection) return;

if (event.detail >= 3) {
let range = document.createRange();
range.selectNodeContents(lineOf(point));
selection.removeAllRanges();
selection.addRange(range);
return;
}
// Shift extends a selection already in this preview, as it would in text.
let anchor = event.shiftKey && selection.anchorNode && inside(target, selection.anchorNode)
? { node: selection.anchorNode, offset: selection.anchorOffset }
: undefined;
if (anchor) {
selection.setBaseAndExtent(anchor.node, anchor.offset, point.node, point.offset);
drag = { preview: target, anchor };
return;
}
selection.collapse(point.node, point.offset);
if (event.detail === 2) {
selection.modify("move", "backward", "word");
selection.modify("extend", "forward", "word");
return;
}
drag = { preview: target, anchor: point };
};

let move = (event: MouseEvent) => {
if (!drag) return;
let point = pointAt(drag.preview, event.clientX, event.clientY);
if (!point) return;
document.getSelection()?.setBaseAndExtent(
drag.anchor.node,
drag.anchor.offset,
point.node,
point.offset,
);
};

let up = () => {
drag = undefined;
};

root.addEventListener("mousedown", down);
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
return () => {
root.removeEventListener("mousedown", down);
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
}
Loading
Loading