diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css
index 99ed901e6..c2a138735 100644
--- a/apps/web/src/theme.css
+++ b/apps/web/src/theme.css
@@ -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.
diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts
index 36cbea9cc..3911a1e5d 100644
--- a/e2e/code.e2e.ts
+++ b/e2e/code.e2e.ts
@@ -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(
+ '\n\nInside.\n\n```ts\nlet a = 1;\n```\n\n\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.
*
diff --git a/e2e/focus.ts b/e2e/focus.ts
index 4f40039ae..c40d1782e 100644
--- a/e2e/focus.ts
+++ b/e2e/focus.ts
@@ -4,9 +4,13 @@ import { visibleOutlineColor } from "./focus-color";
import type { Locator } from "@playwright/test";
-export async function expectFocusIndicator(target: Locator): Promise {
+/**
+ * `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 {
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);
diff --git a/e2e/syntax-contrast.e2e.ts b/e2e/syntax-contrast.e2e.ts
index f55d24982..8a907d039 100644
--- a/e2e/syntax-contrast.e2e.ts
+++ b/e2e/syntax-contrast.e2e.ts
@@ -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);
});
diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css
index c6b5790d0..0d3072c8e 100644
--- a/packages/editor/src/styles.css
+++ b/packages/editor/src/styles.css
@@ -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;
}
diff --git a/packages/editor/src/widgets/preview-selection.ts b/packages/editor/src/widgets/preview-selection.ts
new file mode 100644
index 000000000..18e32bf4f
--- /dev/null
+++ b/packages/editor/src/widgets/preview-selection.ts
@@ -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);
+ };
+}
diff --git a/packages/editor/src/widgets/render-blocks.tsx b/packages/editor/src/widgets/render-blocks.tsx
index 33de9531f..19300aabd 100644
--- a/packages/editor/src/widgets/render-blocks.tsx
+++ b/packages/editor/src/widgets/render-blocks.tsx
@@ -19,33 +19,48 @@
* race.
*/
-import { useCallback, useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { createPortal, flushSync } from "react-dom";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { readOnly$ } from "@mdxeditor/editor";
import { useCellValue } from "@mdxeditor/gurx";
import {
+ $getNearestNodeFromDOMNode,
$getNodeByKey,
$getRoot,
$getSelection,
+ $isDecoratorNode,
$isElementNode,
$isRangeSelection,
+ $isRootNode,
+ $isTextNode,
+ $setSelection,
BLUR_COMMAND,
COLLABORATION_TAG,
+ COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_LOW,
+ COMMAND_PRIORITY_NORMAL,
+ COPY_COMMAND,
FOCUS_COMMAND,
HISTORIC_TAG,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_DOWN_COMMAND,
+ KEY_ESCAPE_COMMAND,
mergeRegister,
} from "lexical";
+import { $isTableCellNode, $isTableRowNode } from "@lexical/table";
import { $isCodeBlockNode, $isMathNode } from "@chopin/dialect";
import { enclosing, remember } from "../collapse";
import { describeDiagramError, kindOf, languageOptions, titleOf } from "./code";
import { CodeView } from "./code-view";
import { LanguageMenu } from "./language-menu";
+import { registerPreviewSelection } from "./preview-selection";
+import { diffSourceLine, lastLineStart, offsetOfLine } from "./source-offset";
import { CodeIcon, WarningIcon } from "@chopin/icons";
-import type { ElementNode, LexicalEditor, LexicalNode } from "lexical";
+import type { ElementNode, LexicalCommand, LexicalEditor, LexicalNode } from "lexical";
import type { Kind } from "./code";
/** How long the source must sit still before a failed drawing is reported. */
@@ -296,13 +311,24 @@ function Preview(
* replaces the element whenever `updateDOM` returns true — a code block
* changing language, math flipping inline — which would drop the attribute
* while this still believed it had been set.
+ *
+ * A layout effect, because opening a block and putting the caret in its
+ * source happen in one gesture, and a caret cannot land in a hidden box.
+ *
+ * Code and diffs swap: their preview is the same text, coloured, so
+ * showing both would put every line on screen twice. A drawing is a
+ * different reading of its source, and stays above it while it is edited.
*/
let hide = collapsed && renders(block, html);
- useEffect(() => {
+ let swap = !collapsed && renders(block, html)
+ && (block.kind === "code" || block.kind === "diff");
+ useLayoutEffect(() => {
let element = editor.getElementByKey(block.key);
if (!element) return;
if (hide) element.dataset.planCollapsed = "";
else delete element.dataset.planCollapsed;
+ if (swap) element.dataset.planSwapped = "";
+ else delete element.dataset.planSwapped;
});
let element = editor.getElementByKey(block.key);
@@ -345,9 +371,12 @@ function Preview(
>
{
// Tab indents code, so the way out has to be said
- // where someone is typing it.
+ // where someone is typing it. A block with a preview
+ // returns to it on Escape, and Tab leaves from there.
editing && !disabled && block.kind !== "math" && (
- Esc then Tab to leave
+
+ {hidable ? "Esc to preview" : "Esc then Tab to leave"}
+
)
}
{hidable && }
@@ -443,6 +472,9 @@ function Diagram({ html, stale }: { html: string; stale: boolean }) {
data-stale={stale ? "" : undefined}
role="region"
tabIndex={0}
+ // Mermaid draws its labels as HTML. Inside the editable root, a
+ // click would otherwise put a caret in a label and type into it.
+ contentEditable={false}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
@@ -482,15 +514,200 @@ function Rendered(
// too: a block element here would put a formula somebody wrote mid-clause
// on a line of its own, and the rest of the sentence after it.
if (block.inline) return ;
- return ;
+ return ;
+}
+
+/** Asked for with the toggle, or opened by going into the block. */
+type Shown = "pinned" | "editing";
+
+type Direction = "next" | "previous";
+
+/** A collapsed caret at a text offset inside a block's source. */
+function $caretAt(block: ElementNode, offset: number): void {
+ let start = 0;
+ for (let child of block.getChildren()) {
+ let size = child.getTextContentSize();
+ if ($isTextNode(child) && offset <= start + size) {
+ child.select(offset - start, offset - start);
+ return;
+ }
+ if (offset <= start) {
+ let index = child.getIndexWithinParent();
+ block.select(index, index);
+ return;
+ }
+ start += size;
+ }
+ block.selectEnd();
+}
+
+type CaretPositionAt = (
+ x: number,
+ y: number,
+ options?: { shadowRoots?: ShadowRoot[] },
+) => { offsetNode: Node; offset: number } | null;
+
+/** The character offset of a point inside one drawn line, if the browser can say. */
+function columnAt(line: HTMLElement, x: number, y: number): number | undefined {
+ let root = line.getRootNode();
+ let at = (document as unknown as { caretPositionFromPoint?: CaretPositionAt })
+ .caretPositionFromPoint?.(x, y, root instanceof ShadowRoot ? { shadowRoots: [root] } : {});
+ if (!at || !line.contains(at.offsetNode)) return undefined;
+ let column = 0;
+ let walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT);
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node === at.offsetNode) return column + at.offset;
+ column += node.textContent?.length ?? 0;
+ }
+ return undefined;
+}
+
+/**
+ * Where a click on drawn code lands in its source.
+ *
+ * The renderer paints into shadow roots and marks each line, so the event's
+ * composed path names the line. A file keeps the source's lines one for one; a
+ * patch is matched line by line. Diagrams and formulas have no lines to map.
+ */
+function clickedOffset(block: Block, event: MouseEvent): number | undefined {
+ if (block.kind !== "code" && block.kind !== "diff") return undefined;
+ let path = event.composedPath();
+ let marked = path.find((target): target is HTMLElement =>
+ target instanceof HTMLElement
+ && (target.hasAttribute("data-line") || target.hasAttribute("data-column-number"))
+ );
+ let view = path.find((target): target is HTMLElement =>
+ target instanceof HTMLElement && target.classList.contains("plan-code-view")
+ );
+ if (!marked || !view) return undefined;
+ // A line number in a diff's gutter stands for the start of its line.
+ let line = marked.hasAttribute("data-line")
+ ? marked
+ : (marked.getRootNode() as ParentNode).querySelector(
+ `[data-line][data-line-index="${marked.dataset.lineIndex}"]`,
+ );
+ if (!line) return undefined;
+ let column = line === marked ? columnAt(line, event.clientX, event.clientY) ?? Infinity : 0;
+ if (view.dataset.view !== "diff") {
+ return offsetOfLine(block.source, Number(line.dataset.line) - 1, column);
+ }
+
+ let type = line.dataset.lineType ?? "";
+ let text = line.textContent ?? "";
+ let drawn = [...view.querySelectorAll("diffs-container")].flatMap(container => [
+ ...(container.shadowRoot?.querySelectorAll("[data-line]") ?? []),
+ ]);
+ let before = drawn.slice(0, Math.max(0, drawn.indexOf(line)));
+ let occurrence =
+ before.filter(other => other.dataset.lineType === type && other.textContent === text).length;
+ let index = diffSourceLine(block.source, type, text, occurrence);
+ return index === undefined ? undefined : offsetOfLine(block.source, index, column + 1);
+}
+
+/** The caret's rectangle, and the height of the line it sits on. */
+function caretLine(document: Document): { rect: DOMRect; height: number } | undefined {
+ let selection = document.getSelection();
+ if (!selection?.rangeCount) return undefined;
+ let rects = selection.getRangeAt(0).getClientRects();
+ let rect: DOMRect | undefined = rects[rects.length - 1];
+ let node = selection.focusNode;
+ let line = node instanceof Element ? node : node?.parentElement;
+ // A collapsed range at an empty line has no rectangle; its line stands in.
+ if (!rect || rect.height === 0) rect = line?.getBoundingClientRect();
+ if (!rect || rect.height === 0 || !line) return undefined;
+ // The caret's box is the font's, shorter than the line it sits on.
+ return {
+ rect,
+ height: Math.max(rect.height, parseFloat(getComputedStyle(line).lineHeight) || 0),
+ };
+}
+
+/**
+ * Whether the caret is on the first or last line of a block.
+ *
+ * Measured, because only layout knows where a wrapped line ends.
+ */
+function atEdge(element: HTMLElement, direction: Direction): boolean {
+ let caret = caretLine(element.ownerDocument);
+ if (!caret) return false;
+ let { rect, height } = caret;
+ let box = element.getBoundingClientRect();
+ return direction === "next" ? box.bottom - rect.bottom < height : rect.top - box.top < height;
+}
+
+/**
+ * Whether no other line sits between the caret and a block it shares a parent
+ * with, such as the text of a list item and the code under it.
+ */
+function besideBlock(element: HTMLElement, direction: Direction): boolean {
+ let caret = caretLine(element.ownerDocument);
+ if (!caret) return false;
+ let { rect, height } = caret;
+ let box = element.getBoundingClientRect();
+ let style = getComputedStyle(element);
+ return direction === "next"
+ ? box.top - rect.bottom < height + parseFloat(style.marginBlockStart)
+ : rect.top - box.bottom < height + parseFloat(style.marginBlockEnd);
+}
+
+function $isInlineNode(node: LexicalNode): boolean {
+ return ($isElementNode(node) || $isDecoratorNode(node)) ? node.isInline() : true;
+}
+
+/** Rows and cells are a table's layout, not blocks read one after another. */
+function $isTablePart(node: LexicalNode): boolean {
+ return $isTableRowNode(node) || $isTableCellNode(node);
+}
+
+/**
+ * The block an arrow key reads next, across the containers that callouts and
+ * list items make: the nearest sibling of the caret's line or of one of its
+ * ancestors. `level` is the node that sibling sits beside.
+ */
+function $following(
+ from: LexicalNode,
+ direction: Direction,
+ skipInline: boolean,
+): { level: LexicalNode; node: LexicalNode } | undefined {
+ let step = (node: LexicalNode) =>
+ direction === "next" ? node.getNextSibling() : node.getPreviousSibling();
+ for (
+ let level: LexicalNode | null = from;
+ level && !$isRootNode(level);
+ level = level.getParent()
+ ) {
+ if ($isTablePart(level)) continue;
+ let node = step(level);
+ if (skipInline) { while (node && $isInlineNode(node)) node = step(node); }
+ if (node) return { level, node };
+ }
+ return undefined;
}
+/** The first (or last) block inside a container, down to one that holds text. */
+function $edgeBlock(
+ node: LexicalNode,
+ direction: Direction,
+ folded: (key: string) => boolean,
+): LexicalNode {
+ let at = node;
+ while ($isElementNode(at) && !folded(at.getKey())) {
+ let child = direction === "next" ? at.getFirstChild() : at.getLastChild();
+ if (!child || $isInlineNode(child)) break;
+ at = child;
+ }
+ return at;
+}
+
+/** Something a reader can hold focus on in place of a caret. */
+const FOCUSABLE_PREVIEW = ".plan-code-view, .plan-diagram";
+
export function PreviewPlugin() {
let [editor] = useLexicalComposerContext();
let disabled = useCellValue(readOnly$);
let [blocks, setBlocks] = useState([]);
- /** Blocks this viewer explicitly chose to reveal. */
- let [shown, setShown] = useState>({});
+ /** Blocks whose source this viewer is looking at, and why. */
+ let [shown, setShown] = useState>({});
/** Blocks that currently have a rendered preview. */
let [hidable, setHidable] = useState>(() => new Set());
@@ -499,6 +716,10 @@ export function PreviewPlugin() {
/** Read by listeners that must not re-register whenever one is toggled. */
let current = useRef(shown);
current.current = shown;
+ /** A press that began on a preview, until its click has decided what it was. */
+ let pressing = useRef(false);
+ let latest = useRef({ blocks, hidable, disabled });
+ latest.current = { blocks, hidable, disabled };
let reportHidable = useCallback((key: string, value: boolean) => {
setHidable(previous => {
@@ -525,11 +746,14 @@ export function PreviewPlugin() {
}, [editor, hidable, shown]);
/*
- * Arrowing into a hidden block opens it.
+ * The caret going into a hidden block opens it, and leaving closes it again.
*
* The source is the block's only editable region, so leaving it hidden with
* the caret inside would mean typing into somewhere invisible. A tab strip
* never has to deal with this — a hidden panel is never the selection.
+ *
+ * Only what the caret opened closes behind it. A source somebody asked to
+ * see with the toggle stays until they hide it.
*/
useEffect(() => {
return editor.registerUpdateListener(({ tags }) => {
@@ -537,16 +761,281 @@ export function PreviewPlugin() {
// can recover the local selection into a block, and that should not
// reopen one they chose to close.
if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) return;
- editor.getEditorState().read(() => {
+ // Pressing on a preview resolves to its block, but is a click or a
+ // copy, not the caret arriving; the click decides what that opens.
+ // While the press lasts the browser may report the selection in the
+ // hidden source, so the press itself is what is checked first.
+ if (pressing.current) return;
+ let anchor = editor.getRootElement()?.ownerDocument.getSelection()?.anchorNode;
+ let at = anchor instanceof Element ? anchor : anchor?.parentElement;
+ if (at?.closest("[data-plan-preview]")) return;
+ let key = editor.getEditorState().read(() => {
let selection = $getSelection();
- if (!$isRangeSelection(selection)) return;
- let key = enclosing(selection.anchor.getNode());
- if (!key || current.current[key]) return;
- setShown(prev => ({ ...prev, [key]: true }));
+ if (!$isRangeSelection(selection)) return undefined;
+ return enclosing(selection.anchor.getNode()) ?? "";
+ });
+ // No caret is not somewhere else: the reader may be on a preview
+ // or a control of the block they were editing.
+ if (key === undefined) return;
+ setShown(prev => {
+ let next = { ...prev };
+ let changed = false;
+ for (let [other, why] of Object.entries(prev)) {
+ if (why !== "editing" || other === key) continue;
+ delete next[other];
+ changed = true;
+ }
+ if (key && !prev[key]) {
+ next[key] = "editing";
+ changed = true;
+ }
+ return changed ? next : prev;
});
});
}, [editor]);
+ /*
+ * Getting into a rendered block, and back out, without hunting for its
+ * toggle.
+ *
+ * A click on a preview opens the source with the caret where the click
+ * was, as near as the drawing can say. Arrowing up or down into a block
+ * puts the caret in code, and focus on a drawing, which takes Enter (or any
+ * typing) to edit and another arrow to move on. Escape from a source goes
+ * back to the preview, holding focus there.
+ *
+ * A focused preview is not an editable position, so Lexical's own handling
+ * of a key there would act on the caret it left behind somewhere else. Every
+ * key aimed at a preview stops here.
+ */
+ useEffect(() => {
+ let blockAt = (element: Element | null | undefined) =>
+ element
+ ? latest.current.blocks.find(block =>
+ !block.inline && editor.getElementByKey(block.key) === element
+ )
+ : undefined;
+ let folded = (key: string) => latest.current.hidable.has(key) && !current.current[key];
+ let root = () => editor.getRootElement();
+
+ /** Show a source and put the caret in it. Runs inside an update. */
+ let $open = (key: string, offset: number, typed?: string) => {
+ // Synchronously, so the source is laid out before Lexical puts the
+ // DOM selection in it at the end of this update.
+ flushSync(() => setShown(prev => (prev[key] ? prev : { ...prev, [key]: "editing" })));
+ root()?.focus({ preventScroll: true });
+ let node = $getNodeByKey(key);
+ if (!$isElementNode(node)) return;
+ $caretAt(node, offset);
+ let selection = $getSelection();
+ if (typed && $isRangeSelection(selection)) selection.insertText(typed);
+ };
+
+ let $enter = (key: string, direction: Direction) => {
+ let block = latest.current.blocks.find(candidate => candidate.key === key);
+ if (!block) return;
+ if (block.kind === "mermaid") {
+ let preview = editor.getElementByKey(key)?.querySelector(".plan-diagram");
+ if (preview) {
+ // No caret is left to close what it opened on the way here.
+ $setSelection(null);
+ flushSync(() =>
+ setShown(prev => {
+ let next = Object.fromEntries(
+ Object.entries(prev).filter(([, why]) => why !== "editing"),
+ );
+ return Object.keys(next).length === Object.keys(prev).length ? prev : next;
+ })
+ );
+ preview.focus();
+ return;
+ }
+ }
+ $open(key, direction === "next" ? 0 : lastLineStart(block.source));
+ };
+
+ let $leave = (key: string, direction: Direction) => {
+ let node = $getNodeByKey(key);
+ if (!node) return;
+ let found = $following(node, direction, false);
+ let land = found && $edgeBlock(found.node, direction, folded);
+ if (land && folded(land.getKey())) return $enter(land.getKey(), direction);
+ root()?.focus({ preventScroll: true });
+ if ($isTextNode(land)) {
+ if (direction === "next") land.select(0, 0);
+ else land.select();
+ } else if ($isElementNode(land)) {
+ if (direction === "next") land.selectStart();
+ else land.selectEnd();
+ } else if (direction === "next") node.selectNext(0, 0);
+ else node.selectPrevious();
+ };
+
+ let $close = (block: Block) => {
+ let node = $getNodeByKey(block.key);
+ let preview = editor.getElementByKey(block.key);
+ if (block.kind === "math") node?.selectNext(0, 0);
+ else $setSelection(null);
+ flushSync(() =>
+ setShown(prev => {
+ let next = { ...prev };
+ delete next[block.key];
+ return next;
+ })
+ );
+ preview?.querySelector(FOCUSABLE_PREVIEW)?.focus({ preventScroll: true });
+ };
+
+ let arrow = (command: LexicalCommand, direction: Direction) =>
+ editor.registerCommand(
+ command,
+ event => {
+ if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return false;
+ if (latest.current.disabled) return false;
+ // The DOM's selection rather than Lexical's, which trails a
+ // click by a task: an arrow pressed straight after one would
+ // otherwise be measured from where the caret used to be.
+ let dom = editor.getRootElement()?.ownerDocument.getSelection();
+ if (!dom?.isCollapsed || !dom.focusNode) return false;
+ let from = $getNearestNodeFromDOMNode(dom.focusNode);
+ if (!from) return false;
+ let found = $following(from, direction, true);
+ if (!found) return false;
+ let target = $edgeBlock(found.node, direction, folded);
+ if (!folded(target.getKey())) return false;
+ // A hidden source has no layout, so the browser's own move
+ // would step right over the block. Only the caret's last
+ // (or first) line may step into it.
+ let level = editor.getElementByKey(found.level.getKey());
+ let block = editor.getElementByKey(target.getKey());
+ let edge = $isInlineNode(found.level)
+ ? block && besideBlock(block, direction)
+ : level && atEdge(level, direction);
+ if (!edge) return false;
+ event.preventDefault();
+ $enter(target.getKey(), direction);
+ return true;
+ },
+ // Ahead of the table's own arrows, which select the end of
+ // whatever precedes a table: a hidden source.
+ COMMAND_PRIORITY_CRITICAL,
+ );
+
+ /** Measured on the way down, while the preview is still the one on screen. */
+ let pressed: { x: number; y: number; offset: number | undefined } | undefined;
+ let down = (event: MouseEvent) => {
+ let target = event.target instanceof Element ? event.target : null;
+ let block = blockAt(target?.closest("[data-plan-preview]")?.parentElement);
+ if (block) pressing.current = true;
+ pressed = {
+ x: event.clientX,
+ y: event.clientY,
+ offset: block && clickedOffset(block, event),
+ };
+ };
+ let click = (event: MouseEvent) => {
+ if (event.button !== 0 || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
+ return;
+ }
+ if (latest.current.disabled || !editor.isEditable()) return;
+ // A drag or a double click is someone selecting text to copy.
+ let moved = pressed
+ && Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y) > 4;
+ if (moved || event.detail > 1) return;
+ if (!(event.target instanceof Element) || event.target.closest("[data-plan-chrome]")) return;
+ let block = blockAt(event.target.closest("[data-plan-preview]")?.parentElement);
+ if (!block || !latest.current.hidable.has(block.key)) return;
+ let offset = pressed?.offset ?? block.source.length;
+ editor.update(() => $open(block.key, offset));
+ };
+
+ // After the click that follows, which may not land on the editor at all.
+ let up = () => setTimeout(() => (pressing.current = false));
+
+ let attached: HTMLElement | null = null;
+ let unselect: (() => void) | undefined;
+ let detach = () => {
+ unselect?.();
+ unselect = undefined;
+ attached?.removeEventListener("mousedown", down);
+ attached?.removeEventListener("click", click);
+ attached?.ownerDocument.removeEventListener("mouseup", up);
+ attached = null;
+ };
+
+ return mergeRegister(
+ editor.registerRootListener(next => {
+ detach();
+ attached = next;
+ next?.addEventListener("mousedown", down);
+ next?.addEventListener("click", click);
+ next?.ownerDocument.addEventListener("mouseup", up);
+ if (next) {
+ unselect = registerPreviewSelection(
+ next,
+ target =>
+ blockAt(target.closest("[data-plan-preview]")?.parentElement)
+ ? target.closest(FOCUSABLE_PREVIEW)
+ : null,
+ );
+ }
+ }),
+ detach,
+ editor.registerCommand(
+ KEY_DOWN_COMMAND,
+ event => {
+ if (!(event.target instanceof Element)) return false;
+ let preview = event.target.closest(FOCUSABLE_PREVIEW);
+ let block = blockAt(preview?.closest("[data-plan-preview]")?.parentElement);
+ if (!block) return false;
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ $leave(block.key, event.key === "ArrowDown" ? "next" : "previous");
+ } else if (event.key === "Enter" || event.key === " ") {
+ // Space too: on a focused box it would scroll the page.
+ event.preventDefault();
+ $open(block.key, block.source.length);
+ } else if (event.key.length === 1) {
+ event.preventDefault();
+ $open(block.key, block.source.length, event.key);
+ }
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ // Text selected in a preview is the browser's to copy; Lexical would
+ // copy its own selection, left somewhere else in the document.
+ editor.registerCommand(
+ COPY_COMMAND,
+ event =>
+ event instanceof ClipboardEvent && event.target instanceof Element
+ && !!event.target.closest(FOCUSABLE_PREVIEW),
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ arrow(KEY_ARROW_DOWN_COMMAND, "next"),
+ arrow(KEY_ARROW_UP_COMMAND, "previous"),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ event => {
+ if (latest.current.disabled) return false;
+ let selection = $getSelection();
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false;
+ let key = enclosing(selection.anchor.getNode());
+ let block = key ? blockAt(editor.getElementByKey(key)) : undefined;
+ if (!block || !latest.current.hidable.has(block.key)) return false;
+ // Spent here: a comment preview open on the same passage would
+ // otherwise also close, and send focus back to its marker.
+ event?.preventDefault();
+ event?.stopPropagation();
+ $close(block);
+ return true;
+ },
+ COMMAND_PRIORITY_NORMAL,
+ ),
+ );
+ }, [editor]);
+
useEffect(() => {
let refresh = () => {
let root = editor.getRootElement();
@@ -588,7 +1077,12 @@ export function PreviewPlugin() {
$getNodeByKey(key)?.selectNext(0, 0);
});
}
- setShown(prev => ({ ...prev, [key]: collapsed }));
+ setShown(prev => {
+ let next = { ...prev };
+ if (collapsed) next[key] = "pinned";
+ else delete next[key];
+ return next;
+ });
}, [editor]);
return (
diff --git a/packages/editor/src/widgets/source-offset.test.ts b/packages/editor/src/widgets/source-offset.test.ts
new file mode 100644
index 000000000..c585ef642
--- /dev/null
+++ b/packages/editor/src/widgets/source-offset.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test";
+
+import { diffSourceLine, lastLineStart, offsetOfLine } from "./source-offset";
+
+const PATCH = [
+ "--- a/room.ts",
+ "+++ b/room.ts",
+ "@@ -1,4 +1,4 @@",
+ " open() {",
+ "-\treturn 1;",
+ "+\treturn 2;",
+ " }",
+ "@@ -9,2 +9,2 @@",
+ " }",
+].join("\n");
+
+describe("offsetOfLine", () => {
+ test("finds a column on a line and clamps it to that line", () => {
+ expect(offsetOfLine("ab\ncdef\ng", 1, 2)).toBe(5);
+ expect(offsetOfLine("ab\ncdef\ng", 1, 99)).toBe(7);
+ expect(offsetOfLine("ab\ncdef\ng", 0)).toBe(0);
+ });
+
+ test("refuses a line the source does not have", () => {
+ expect(offsetOfLine("ab", 1)).toBeUndefined();
+ expect(offsetOfLine("ab", -1)).toBeUndefined();
+ });
+});
+
+test("the last line starts after the last newline", () => {
+ expect(lastLineStart("a\nbc\nd")).toBe(5);
+ expect(lastLineStart("abc")).toBe(0);
+});
+
+describe("diffSourceLine", () => {
+ test("finds a changed line by its kind and text", () => {
+ expect(diffSourceLine(PATCH, "change-deletion", "\treturn 1;", 0)).toBe(4);
+ expect(diffSourceLine(PATCH, "change-addition", "\treturn 2;", 0)).toBe(5);
+ });
+
+ test("tells equal context lines apart by their order", () => {
+ expect(diffSourceLine(PATCH, "context", "}", 0)).toBe(6);
+ expect(diffSourceLine(PATCH, "context", "}", 1)).toBe(8);
+ });
+
+ test("does not guess at a line it cannot find", () => {
+ expect(diffSourceLine(PATCH, "change-addition", "\treturn 1;", 0)).toBeUndefined();
+ expect(diffSourceLine(PATCH, "hunk", "@@", 0)).toBeUndefined();
+ });
+});
diff --git a/packages/editor/src/widgets/source-offset.ts b/packages/editor/src/widgets/source-offset.ts
new file mode 100644
index 000000000..569f6cf12
--- /dev/null
+++ b/packages/editor/src/widgets/source-offset.ts
@@ -0,0 +1,56 @@
+/**
+ * Where in a block's source a gesture on its preview should put the caret.
+ *
+ * A preview is a second, derived drawing of the source, so a place in it only
+ * maps back when the drawing keeps the source's lines. Coloured code does, one
+ * for one. A rendered patch keeps every line but drops its first column, so a
+ * line is found again by its kind and its text. Anything that cannot be found
+ * says so, and the caller falls back to the end.
+ */
+
+/** The offset of a column on a line, clamped to that line. */
+export function offsetOfLine(source: string, line: number, column = 0): number | undefined {
+ let lines = source.split("\n");
+ if (line < 0 || line >= lines.length) return undefined;
+ let start = 0;
+ for (let index = 0; index < line; index++) start += lines[index]!.length + 1;
+ return start + Math.max(0, Math.min(column, lines[line]!.length));
+}
+
+/** Where the last line starts, for arriving at a block from below. */
+export function lastLineStart(source: string): number {
+ return source.lastIndexOf("\n") + 1;
+}
+
+const MARKS: Record = {
+ context: " ",
+ "change-addition": "+",
+ "change-deletion": "-",
+};
+
+/**
+ * Which source line a drawn diff line came from.
+ *
+ * `occurrence` counts the identical drawn lines before this one, so two equal
+ * context lines in different hunks each find their own.
+ */
+export function diffSourceLine(
+ source: string,
+ type: string,
+ text: string,
+ occurrence: number,
+): number | undefined {
+ let mark = MARKS[type];
+ if (mark === undefined) return undefined;
+ let wanted = mark + text;
+ let seen = 0;
+ let lines = source.split("\n");
+ for (let [index, line] of lines.entries()) {
+ // An empty context line is often written with its space trimmed.
+ let matches = line === wanted || (mark === " " && text === "" && line === "");
+ if (!matches) continue;
+ if (seen === occurrence) return index;
+ seen++;
+ }
+ return undefined;
+}