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; +}