From f2b69c4d2bd94c817ad7f9e4678670a91af2a9f7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:21:30 +0100 Subject: [PATCH 1/6] Open rendered code, diff and diagram blocks by clicking or arrowing in Clicking a preview opens its source with the caret where the click was (the clicked line and column for code, the matching patch line for a diff, the end for a diagram or formula). Code and diffs swap the coloured copy for the source instead of showing both. Escape returns to the preview and holds focus on it; Enter, Space or typing on a focused preview opens the source, and ArrowUp/ArrowDown move on. Arrowing from a neighbouring block enters a collapsed block instead of skipping it: the caret goes into code, and a diagram is held as a block. A source the caret opened closes again when the caret leaves; one opened with the toggle stays open. Co-Authored-By: Claude Opus 5.5 --- e2e/code.e2e.ts | 106 +++++ e2e/syntax-contrast.e2e.ts | 6 +- packages/editor/src/styles.css | 43 ++ packages/editor/src/widgets/render-blocks.tsx | 393 +++++++++++++++++- .../editor/src/widgets/source-offset.test.ts | 50 +++ packages/editor/src/widgets/source-offset.ts | 56 +++ 6 files changed, 636 insertions(+), 18 deletions(-) create mode 100644 packages/editor/src/widgets/source-offset.test.ts create mode 100644 packages/editor/src/widgets/source-offset.ts diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts index 36cbea9cc..ceda5bd81 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -302,6 +302,112 @@ 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); +}); + /* * Two people in one fence. * diff --git a/e2e/syntax-contrast.e2e.ts b/e2e/syntax-contrast.e2e.ts index f55d24982..0e71acdd1 100644 --- a/e2e/syntax-contrast.e2e.ts +++ b/e2e/syntax-contrast.e2e.ts @@ -1,4 +1,3 @@ -import { expectFocusIndicator } from "./focus"; import { content, expect, test } from "./room"; const CODE = `\`\`\`typescript title="tokens.ts" @@ -60,7 +59,10 @@ 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); + await expect(preview).toBeFocused(); + // The block clips its corners and the renderer paints over the preview's + // own box, so the whole block carries the ring. + await expect(content(page).locator(".planCode")).toHaveCSS("outline-style", "solid"); 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..64e8920c2 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2448,6 +2448,49 @@ 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); +} + +/* + * A focused preview is the block held as a whole, between the prose around it + * and its source. The block clips its corners and the renderer paints over its + * own box, so the ring goes on the 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: var(--focus-ring-offset); +} + +@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/render-blocks.tsx b/packages/editor/src/widgets/render-blocks.tsx index 33de9531f..9833f8cac 100644 --- a/packages/editor/src/widgets/render-blocks.tsx +++ b/packages/editor/src/widgets/render-blocks.tsx @@ -19,8 +19,8 @@ * 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"; @@ -30,11 +30,20 @@ import { $getSelection, $isElementNode, $isRangeSelection, + $isRootNode, + $isTextNode, + $setSelection, BLUR_COMMAND, COLLABORATION_TAG, + COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_NORMAL, FOCUS_COMMAND, HISTORIC_TAG, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_DOWN_COMMAND, + KEY_ESCAPE_COMMAND, mergeRegister, } from "lexical"; import { $isCodeBlockNode, $isMathNode } from "@chopin/dialect"; @@ -43,9 +52,10 @@ import { enclosing, remember } from "../collapse"; import { describeDiagramError, kindOf, languageOptions, titleOf } from "./code"; import { CodeView } from "./code-view"; import { LanguageMenu } from "./language-menu"; +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 +306,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 +366,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 +467,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 +509,126 @@ 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); +} + +/** + * Whether the caret is on the first or last line of a block. + * + * Measured, because only layout knows where a wrapped line ends. A collapsed + * range at an empty line has no rectangle, so its line's element stands in. + */ +function atEdge(element: HTMLElement, direction: Direction): boolean { + let selection = element.ownerDocument.getSelection(); + if (!selection?.rangeCount) return false; + let rects = selection.getRangeAt(0).getClientRects(); + let rect: DOMRect | undefined = rects[direction === "next" ? rects.length - 1 : 0]; + let node = selection.focusNode; + let line = node instanceof Element ? node : node?.parentElement; + if (!rect || rect.height === 0) rect = line?.getBoundingClientRect(); + if (!rect || rect.height === 0 || !line) return false; + // The caret's box is the font's, shorter than the line it sits on. + let height = Math.max(rect.height, parseFloat(getComputedStyle(line).lineHeight) || 0); + let box = element.getBoundingClientRect(); + return direction === "next" ? box.bottom - rect.bottom < height : rect.top - box.top < height; +} + +/** 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 +637,8 @@ export function PreviewPlugin() { /** Read by listeners that must not re-register whenever one is toggled. */ let current = useRef(shown); current.current = shown; + let latest = useRef({ blocks, hidable, disabled }); + latest.current = { blocks, hidable, disabled }; let reportHidable = useCallback((key: string, value: boolean) => { setHidable(previous => { @@ -525,11 +665,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 +680,229 @@ 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. + 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) { + $setSelection(null); + 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 neighbor = direction === "next" ? node.getNextSibling() : node.getPreviousSibling(); + if (neighbor && folded(neighbor.getKey())) return $enter(neighbor.getKey(), direction); + root()?.focus({ preventScroll: true }); + 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; + let selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; + // Not `getTopLevelElement`, which stops at a table cell. + let top: LexicalNode = selection.focus.getNode(); + for ( + let parent = top.getParent(); + parent && !$isRootNode(parent); + parent = top.getParent() + ) { + top = parent; + } + if ($isRootNode(top)) return false; + let neighbor = direction === "next" ? top.getNextSibling() : top.getPreviousSibling(); + if (!neighbor || !folded(neighbor.getKey())) return false; + // A hidden source has no layout, so the browser's own move + // would step right over the block. + let element = editor.getElementByKey(top.getKey()); + if (!element || !atEdge(element, direction)) return false; + event.preventDefault(); + $enter(neighbor.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); + 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)); + }; + + let attached: HTMLElement | null = null; + let detach = () => { + attached?.removeEventListener("mousedown", down); + attached?.removeEventListener("click", click); + attached = null; + }; + + return mergeRegister( + editor.registerRootListener(next => { + detach(); + attached = next; + next?.addEventListener("mousedown", down); + next?.addEventListener("click", click); + }), + 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, + ), + arrow(KEY_ARROW_DOWN_COMMAND, "next"), + arrow(KEY_ARROW_UP_COMMAND, "previous"), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + 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; + $close(block); + return true; + }, + COMMAND_PRIORITY_NORMAL, + ), + ); + }, [editor]); + useEffect(() => { let refresh = () => { let root = editor.getRootElement(); @@ -588,7 +944,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; +} From 20cd41e3059eef3a5f7cb9cce4a72c64584e3d46 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:33:22 +0100 Subject: [PATCH 2/6] Close an opened source when arrowing onto a diagram Co-Authored-By: Claude Opus 5.5 --- packages/editor/src/widgets/render-blocks.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/editor/src/widgets/render-blocks.tsx b/packages/editor/src/widgets/render-blocks.tsx index 9833f8cac..d4e845366 100644 --- a/packages/editor/src/widgets/render-blocks.tsx +++ b/packages/editor/src/widgets/render-blocks.tsx @@ -753,7 +753,16 @@ export function PreviewPlugin() { 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; } From b9c6731852072bcebf2d5a0db7820ba158149882 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:10:53 +0100 Subject: [PATCH 3/6] Select preview text by dragging, and arrow into nested code - A press on a preview no longer lets the browser drop the caret into the hidden source. The selection follows the pointer inside the renderer's shadow roots, so code can be dragged over and copied without opening it. Copy from a preview is left to the browser. While the press lasts, the caret-follows-selection opening is suspended; the click decides. - Arrow keys find a folded block across callouts and list items, not only top-level siblings, measuring from the DOM selection so a key pressed right after a click starts from the click. - Escape that returns to a preview is not also taken by a comment preview. - The focus ring sits on the block's border, clear of the corner controls. - The syntax-contrast test checks the ring with expectFocusIndicator again. Co-Authored-By: Claude Opus 5.5 --- e2e/code.e2e.ts | 52 +++++ e2e/focus.ts | 8 +- e2e/syntax-contrast.e2e.ts | 4 +- packages/editor/src/styles.css | 6 +- .../editor/src/widgets/preview-selection.ts | 115 +++++++++++ packages/editor/src/widgets/render-blocks.tsx | 192 ++++++++++++++---- 6 files changed, 338 insertions(+), 39 deletions(-) create mode 100644 packages/editor/src/widgets/preview-selection.ts diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts index ceda5bd81..f2eb45e42 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -408,6 +408,58 @@ test("arrows step into code, onto a diagram, and out again", async ({ join, room 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 + 200, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + + 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(3); + 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, /^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 0e71acdd1..8a907d039 100644 --- a/e2e/syntax-contrast.e2e.ts +++ b/e2e/syntax-contrast.e2e.ts @@ -1,3 +1,4 @@ +import { expectFocusIndicator } from "./focus"; import { content, expect, test } from "./room"; const CODE = `\`\`\`typescript title="tokens.ts" @@ -59,10 +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 expect(preview).toBeFocused(); // The block clips its corners and the renderer paints over the preview's // own box, so the whole block carries the ring. - await expect(content(page).locator(".planCode")).toHaveCSS("outline-style", "solid"); + 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 64e8920c2..e0267c63d 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2476,7 +2476,11 @@ .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: var(--focus-ring-offset); + /* + * On the border, not inside it, where the controls in the top-end corner + * would paint over it. The document's inset ring offset does not apply. + */ + outline-offset: 0; } @media (prefers-reduced-motion: no-preference) { diff --git a/packages/editor/src/widgets/preview-selection.ts b/packages/editor/src/widgets/preview-selection.ts new file mode 100644 index 000000000..00e435413 --- /dev/null +++ b/packages/editor/src/widgets/preview-selection.ts @@ -0,0 +1,115 @@ +/** + * 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; + } + 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 d4e845366..19300aabd 100644 --- a/packages/editor/src/widgets/render-blocks.tsx +++ b/packages/editor/src/widgets/render-blocks.tsx @@ -25,9 +25,11 @@ 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, @@ -38,6 +40,7 @@ import { COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, + COPY_COMMAND, FOCUS_COMMAND, HISTORIC_TAG, KEY_ARROW_DOWN_COMMAND, @@ -46,12 +49,14 @@ import { 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"; @@ -599,27 +604,101 @@ function clickedOffset(block: Block, event: MouseEvent): number | undefined { return index === undefined ? undefined : offsetOfLine(block.source, index, column + 1); } -/** - * Whether the caret is on the first or last line of a block. - * - * Measured, because only layout knows where a wrapped line ends. A collapsed - * range at an empty line has no rectangle, so its line's element stands in. - */ -function atEdge(element: HTMLElement, direction: Direction): boolean { - let selection = element.ownerDocument.getSelection(); - if (!selection?.rangeCount) return false; +/** 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[direction === "next" ? rects.length - 1 : 0]; + 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 false; + if (!rect || rect.height === 0 || !line) return undefined; // The caret's box is the font's, shorter than the line it sits on. - let height = Math.max(rect.height, parseFloat(getComputedStyle(line).lineHeight) || 0); + 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"; @@ -637,6 +716,8 @@ 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 }; @@ -682,6 +763,9 @@ export function PreviewPlugin() { if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) return; // 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; @@ -773,10 +857,17 @@ export function PreviewPlugin() { let $leave = (key: string, direction: Direction) => { let node = $getNodeByKey(key); if (!node) return; - let neighbor = direction === "next" ? node.getNextSibling() : node.getPreviousSibling(); - if (neighbor && folded(neighbor.getKey())) return $enter(neighbor.getKey(), direction); + 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 (direction === "next") node.selectNext(0, 0); + 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(); }; @@ -801,26 +892,28 @@ export function PreviewPlugin() { event => { if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return false; if (latest.current.disabled) return false; - let selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; - // Not `getTopLevelElement`, which stops at a table cell. - let top: LexicalNode = selection.focus.getNode(); - for ( - let parent = top.getParent(); - parent && !$isRootNode(parent); - parent = top.getParent() - ) { - top = parent; - } - if ($isRootNode(top)) return false; - let neighbor = direction === "next" ? top.getNextSibling() : top.getPreviousSibling(); - if (!neighbor || !folded(neighbor.getKey())) 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. - let element = editor.getElementByKey(top.getKey()); - if (!element || !atEdge(element, direction)) return false; + // 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(neighbor.getKey(), direction); + $enter(target.getKey(), direction); return true; }, // Ahead of the table's own arrows, which select the end of @@ -833,6 +926,7 @@ export function PreviewPlugin() { 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, @@ -855,10 +949,17 @@ export function PreviewPlugin() { 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; }; @@ -868,6 +969,16 @@ export function PreviewPlugin() { 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( @@ -893,17 +1004,30 @@ export function PreviewPlugin() { }, 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; }, From c4ec5c6ac96cda11ff25653333b1e6467b8e7eaf Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:21:14 +0100 Subject: [PATCH 4/6] Allow the callout's indent in the nested-arrow test Co-Authored-By: Claude Opus 5.5 --- e2e/code.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts index f2eb45e42..4955a073b 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -452,7 +452,7 @@ test("arrows reach code inside a callout and a list item", async ({ join, room, await content(page).getByText("Inside.").click(); await page.keyboard.press("ArrowDown"); await page.keyboard.type("Q"); - await written(page, room, /^Qlet a = 1;$/m); + await written(page, room, /^\s*Qlet a = 1;$/m); await content(page).getByText("Next").click(); await page.keyboard.press("ArrowUp"); From 3e460b8ec7f3b3204a8ab122a3236274b4ddf920 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:36:53 +0100 Subject: [PATCH 5/6] Extend a preview selection with Shift and a click Co-Authored-By: Claude Opus 5.5 --- e2e/code.e2e.ts | 11 +++++++++-- packages/editor/src/widgets/preview-selection.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/e2e/code.e2e.ts b/e2e/code.e2e.ts index 4955a073b..3911a1e5d 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -426,13 +426,20 @@ test("dragging across drawn code selects it to copy, without opening", async ({ 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 + 200, box.y + box.height / 2, { steps: 8 }); + 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(3); + 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. diff --git a/packages/editor/src/widgets/preview-selection.ts b/packages/editor/src/widgets/preview-selection.ts index 00e435413..18e32bf4f 100644 --- a/packages/editor/src/widgets/preview-selection.ts +++ b/packages/editor/src/widgets/preview-selection.ts @@ -79,6 +79,15 @@ export function registerPreviewSelection( 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"); From 86cb5beb56c8e0096c50fd17502eb412b6d9880b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:03:15 +0100 Subject: [PATCH 6/6] Keep rendered block focus ring in shared theme --- apps/web/src/theme.css | 12 ++++++++++++ packages/editor/src/styles.css | 20 -------------------- 2 files changed, 12 insertions(+), 20 deletions(-) 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/packages/editor/src/styles.css b/packages/editor/src/styles.css index e0267c63d..0d3072c8e 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2463,26 +2463,6 @@ background: var(--color-page); } -/* - * A focused preview is the block held as a whole, between the prose around it - * and its source. The block clips its corners and the renderer paints over its - * own box, so the ring goes on the 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); - /* - * On the border, not inside it, where the controls in the top-end corner - * would paint over it. The document's inset ring offset does not apply. - */ - outline-offset: 0; -} - @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);