From 5d48a8ad55977e05d1f40aff3952c1b3d507b314 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Thu, 29 Oct 2026 22:16:25 -0400 Subject: [PATCH 1/6] WireframeViewer: click elements, outline them (0.5.0) onElementClick(hit, hits, event), onElementHover and selected (a line or lines), on tsquare 0.14's viewer events. Hovered elements get a dashed outline when the board is clickable. The board is measured only when one of the three props is set; while the text has problems, nothing is clickable or outlined (the last valid board's lines no longer match). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++ README.md | 2 +- docs/README.md | 1 + docs/styling.md | 2 + docs/viewer.md | 48 ++++++++++++++++- package-lock.json | 12 ++--- package.json | 4 +- src/index.ts | 1 + src/use-board.ts | 19 ++++--- src/viewer.tsx | 46 ++++++++++++++-- test/react.test.tsx | 125 ++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 249 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97bb1b0..c3f6d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.5.0 + +- **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. +- **The board is measured only when it's needed:** the viewer measures every element (about one more render) only with `onElementClick`, `onElementHover` or `selected`. Other boards cost the same as before. +- **While the text has problems, nothing is clickable or outlined:** the last valid board's lines no longer match the text. +- The `Measured` types (`Measured`, `MeasuredElement`, `MeasuredFlow`, `MeasuredBox`) are exported from `@tsquare-js/react`. +- **Needs tsquare 0.14.0 or later.** +- **Docs:** the WireframeViewer page has a Clicking elements section; Styling lists `--tsquare-highlight` and the outline classes. + ## 0.4.0 - **Needs tsquare 0.13.2 or later** (it was 0.12.1). Boards get tsquare's renderer fixes from 0.12.3 to 0.13.2. diff --git a/README.md b/README.md index 0d955c4..7e6cd44 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ export function Reply({ text, streaming }: { text: string; streaming: boolean }) [tsquare.dev/docs/react](https://tsquare.dev/docs/react), also in [`docs/`](docs/README.md): - [Wireframe](docs/wireframe.md): a board as an image, with an optional full-size view -- [WireframeViewer](docs/viewer.md): zoom, pan and a button per screen +- [WireframeViewer](docs/viewer.md): zoom, pan, a button per screen, and clicking elements - [WireframeEditor](docs/editor.md): write and edit wireframe text, with autocomplete and problems underlined (from `@tsquare-js/react/editor`) - [WireframePlayground](docs/playground.md): the editor and the board side by side (from `@tsquare-js/react/editor`) - [Markdown and chat](docs/chat.md): react-markdown, streaming replies diff --git a/docs/README.md b/docs/README.md index a0e2551..fabe28d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ The stylesheet is optional but recommended: it styles the buttons, problem lists |---|---| | Show a wireframe as an image in a page or card | [``](wireframe.md) | | Let people zoom, pan and jump between screens | [``](viewer.md), or `` to open it full size from the image | +| Let people click an element, or outline one | [``](viewer.md#clicking-elements) with `onElementClick` and `selected` | | Let people write or edit wireframe text | [``](editor.md), from `@tsquare-js/react/editor` | | An editor and the board side by side | [``](playground.md), from `@tsquare-js/react/editor` | | Show the ```` ```tsquare ```` blocks in Markdown you render with react-markdown, such as a model's replies in a chat | [`tsquareComponents`](chat.md) | diff --git a/docs/styling.md b/docs/styling.md index a56b572..68d3a61 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -24,6 +24,7 @@ The stylesheet reads these CSS variables. Set any of them, on `:root` or on a co | `--tsquare-accent` | the selected screen chip, focus rings | `#1f2328` / `#e6e8eb` | | `--tsquare-accent-fg` | text on the accent | `#ffffff` / `#1b1d21` | | `--tsquare-error` | problem lists | `#cf222e` / `#ff7b72` | +| `--tsquare-highlight` | outlines on elements in the viewer (`selected`, hover); works without the stylesheet too | `#3B99FB` | | `--tsquare-font` | text | your page's font | | `--tsquare-font-mono` | problem lines | `ui-monospace, Menlo, …` | | `--tsquare-radius` | corners | `6px` | @@ -86,6 +87,7 @@ Every part has a class, so you can restyle it from your CSS. The stylesheet's ru | `tsquare-viewer-bar` | the bar above the board: `tsquare-chips` (its buttons have `aria-pressed`) and `tsquare-zoom` (`tsquare-zoom-out`, `tsquare-zoom-level`, `tsquare-zoom-in`) | | `tsquare-stage` | the area the board moves in, with `tsquare-canvas` inside; it gets `panning` while dragged and `stale` while problems show | | `tsquare-note` | problems over the viewer's board | +| `tsquare-highlight` | an outline on an element in the viewer, with `tsquare-highlight-selected` or `tsquare-highlight-hover` | | `tsquare-dialog` | the full-size view: `tsquare-dialog-header`, `tsquare-dialog-title`, `tsquare-dialog-close` | | `tsquare-editor` | ``'s root; the editor inside is CodeMirror's `cm-editor` | | `tsquare-playground` | ``'s root (`data-layout` is `auto`, `row` or `column`), with `tsquare-playground-panes` holding the editor and the viewer | diff --git a/docs/viewer.md b/docs/viewer.md index 106c5f1..91ddbc1 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -1,6 +1,6 @@ # WireframeViewer -`` shows a board people can explore: zoom with the buttons, a pinch or Ctrl/Cmd + scroll, drag to pan, double-click to fit, and select a screen's name to zoom to it. Use it where the wireframe is the main thing on screen, such as a design review page or a panel next to a spec. +`` shows a board people can explore: zoom with the buttons, a pinch or Ctrl/Cmd + scroll, drag to pan, double-click to fit, and select a screen's name to zoom to it. It can also tell you which element was clicked and outline elements: see [Clicking elements](#clicking-elements). Use it where the wireframe is the main thing on screen, such as a design review page or a panel next to a spec. ```tsx import { WireframeViewer } from "@tsquare-js/react"; @@ -22,6 +22,9 @@ It fills its box, so give it a height. With the stylesheet it's 32rem tall unles | `streaming` | `false` | The text is still arriving. See [streaming](chat.md#while-a-reply-streams-in). | | `showProblems` | `true` | Show the text's problems over the bottom of the board. See [Problems](problems.md). | | `onProblems` | | Called with the problems when the text has some, and with `[]` once they're fixed. | +| `onElementClick` | | Called with the element clicked, and everything under the point. See [Clicking elements](#clicking-elements). | +| `onElementHover` | | Called when the element under the pointer changes, and with `null` when it leaves the board. | +| `selected` | | Outline the element on this line, or on each line in a list. | | `className` | | Added to the root, next to `tsquare-viewer`. | | `style` | | On the root. | @@ -35,6 +38,49 @@ export function Minimal({ text }: { text: string }) { } ``` +## Clicking elements + +With `onElementClick`, a click on the board tells you what was clicked: a button, an input, a screen, a flow arrow. Dragging still pans; only a press that hardly moves counts as a click. Hovered elements get a dashed outline, so people can see what they're about to pick. `selected` outlines elements by their line in the text. + +This example outlines the clicked element and shows its lines, the part of the text you'd hand a model to change: + +```tsx +import { useState } from "react"; +import { WireframeViewer, type Measured } from "@tsquare-js/react"; + +export function Pick({ text }: { text: string }) { + const [picked, setPicked] = useState(null); + const block = picked ? text.split("\n").slice(picked.line - 1, picked.endLine).join("\n") : ""; + return ( + <> + setPicked(hit && hit.type !== "Board" ? hit : null)} + /> + {picked ?
{block}
: null} + + ); +} +``` + +`onElementClick(hit, hits, event)` gets: + +- **`hit`**: what the person most likely meant. A button over a flow arrow gets the button; empty space inside a screen gets the screen; empty board outside the screens gets the board itself (`type: "Board"`). An open menu or tooltip gets the element it belongs to. +- **`hits`**: everything under the point, most likely first, ending with the screen and the board. Use it to skip what you don't want, such as `hits.find((e) => e.type !== "Flow")`. +- **`event`**: the browser's pointer event, for modifier keys (`event.shiftKey` to add to a selection). + +Each element has its `line` and `endLine` in the text (its whole block, children included), `type`, `label`, its `#id` if the text gives one, and its `box` on the board. A flow arrow has `type: "Flow"`, `from` and `to`. The full list is in tsquare's [element positions](https://tsquare.dev/docs/element-positions) docs. + +`selected` takes a line or a list of lines. A line inside a block, such as a blank line between a stack's children, outlines that block, so you can pass an editor's caret line. The board's own line outlines nothing. + +Some details: + +- **Lines shift when the text changes.** An element picked on line 11 is on line 12 after someone adds a line above it. For anything that has to survive edits, such as a comment pinned to an element, key it by the element's `id` and give the elements you care about one in the text (`button "Save" #save`). +- **While the text has problems, nothing is clickable or outlined.** The viewer still shows the last board that worked, but its lines no longer match the text. Clicks pan, and outlines come back once the text is fixed. +- **Measuring costs about one more render**, so the viewer only measures the board when you set `onElementClick`, `onElementHover` or `selected`. +- **The outline color** is the CSS variable `--tsquare-highlight` (see [Styling](styling.md)). + ## When the text changes The viewer keeps its zoom and position when the board is redrawn at the same size, so editing the text doesn't jump the view. When the board's size changes, or the screen you zoomed to is gone, it fits the board again. diff --git a/package-lock.json b/package-lock.json index 7c2773f..1072089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@tsquare-js/react", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tsquare-js/react", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { - "tsquare": "^0.13.2" + "tsquare": "^0.14.0" }, "devDependencies": { "@types/jsdom": "^30.0.0", @@ -2740,9 +2740,9 @@ } }, "node_modules/tsquare": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.13.2.tgz", - "integrity": "sha512-gnWSWQwgKLXDCc/PGycRgPAETksdEzqFnX6FEZh4JjX1TcEALLb5hk6doJXaiCIV5XgE83rUr53U+Tvz1IUmaw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.14.0.tgz", + "integrity": "sha512-XPulkqhyIiNzZGqS8MABdXw3lw2I2qSv3UIi2r3G/J/qwgznqafXcJZLj95yVnsD4uZF9tIMiYGs2RzXHf3JVg==", "license": "MIT", "dependencies": { "@fontsource/inter": "5.3.0", diff --git a/package.json b/package.json index e0297d7..a91d7d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tsquare-js/react", - "version": "0.4.0", + "version": "0.5.0", "description": "React component for tsquare wireframes: render wireframe text, or the ```tsquare blocks in react-markdown, including while a model's reply is still streaming in.", "keywords": [ "tsquare", @@ -51,7 +51,7 @@ "prepare": "npm run build" }, "dependencies": { - "tsquare": "^0.13.2" + "tsquare": "^0.14.0" }, "peerDependencies": { "react": ">=18" diff --git a/src/index.ts b/src/index.ts index f6a6cc9..fc42ea6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,4 @@ export { WireframeViewer, type WireframeViewerProps } from "./viewer.js"; export { WireframeStreaming } from "./streaming.js"; export { tsquareComponents, createTsquareComponents, TsquarePre, type TsquareComponentsOptions } from "./markdown.js"; export type { Problem } from "./use-board.js"; +export type { Measured, MeasuredBox, MeasuredElement, MeasuredFlow } from "tsquare"; diff --git a/src/use-board.ts b/src/use-board.ts index e6c47c2..ebab97e 100644 --- a/src/use-board.ts +++ b/src/use-board.ts @@ -5,7 +5,7 @@ * board in as it goes (waiting for a pause would draw nothing until the stream ends). */ import { useEffect, useRef, useState } from "react"; -import { compileWireframe, measureBoardLayout, renderWireframeSvg } from "tsquare"; +import { compileWireframe, measureBoardLayout, measureElements, renderWireframeSvg, type Measured } from "tsquare"; import type { BoardItem } from "tsquare/viewer"; export interface Problem { @@ -21,6 +21,8 @@ export interface Board { /** Screens and notes with their boxes on the board, for zooming to a screen. */ items: BoardItem[]; title?: string; + /** Every element and flow with its box and lines (measureElements), when asked for: the viewer's clicks and outlines. */ + elements?: Measured[]; } export interface BoardState { @@ -34,7 +36,8 @@ export interface BoardState { const STREAM_INTERVAL = 200; -export function useBoard(source: string, streaming: boolean, onProblems?: (problems: Problem[]) => void): BoardState { +/** `measure`: also measure every element (a second render's worth of work), for clicks and outlines. */ +export function useBoard(source: string, streaming: boolean, onProblems?: (problems: Problem[]) => void, measure = false): BoardState { const [state, setState] = useState({ board: null, problems: [], pending: true }); const seq = useRef(0); const shown = useRef(0); @@ -71,7 +74,7 @@ export function useBoard(source: string, streaming: boolean, onProblems?: (probl if (key !== reported.current) { reported.current = key; callback.current?.(problems); } } }; - const { spec, issues } = compileWireframe(text); + const { spec, lines, issues } = compileWireframe(text); if (!spec || issues.length) { const problems = issues.map((i) => ({ line: i.line, message: i.message })); apply((s) => ({ ...s, problems }), problems); @@ -79,15 +82,19 @@ export function useBoard(source: string, streaming: boolean, onProblems?: (probl } try { // the exact layout (notes measured as drawn), so the image's size and the screen chips match the SVG - const [svg, { width, height, items }] = await Promise.all([renderWireframeSvg(spec, { skipValidation: true }), measureBoardLayout(spec)]); + const [svg, { width, height, items }, elements] = await Promise.all([ + renderWireframeSvg(spec, { skipValidation: true }), + measureBoardLayout(spec), + measure ? measureElements(spec, lines, { skipValidation: true }) : undefined, + ]); const title = (spec.elements[spec.root]?.props as { title?: string } | undefined)?.title; - apply(() => ({ board: { svg, width, height, items, title }, problems: [], pending: false }), []); + apply(() => ({ board: { svg, width, height, items, title, elements }, problems: [], pending: false }), []); } catch (err) { const problems = [{ line: 0, message: `tsquare couldn't render this board: ${err instanceof Error ? err.message : String(err)}` }]; apply((s) => ({ ...s, problems }), problems); } } - }, [source, streaming]); + }, [source, streaming, measure]); return state; } diff --git a/src/viewer.tsx b/src/viewer.tsx index 8202db8..cde414f 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -1,9 +1,12 @@ /** * The zoomable board: tsquare's viewer (the playground's) inside React. Zoom with the buttons, a - * pinch or Ctrl/Cmd + scroll; drag to pan; a chip per screen zooms to it. + * pinch or Ctrl/Cmd + scroll; drag to pan; a chip per screen zooms to it. With onElementClick, + * onElementHover or selected, the board's elements are measured too, and the viewer reports clicks + * and outlines elements. */ import { useEffect, useRef, type CSSProperties } from "react"; -import { createViewer, type Viewer } from "tsquare/viewer"; +import { elementsAtLine } from "tsquare"; +import { createViewer, type Measured, type Viewer } from "tsquare/viewer"; import { MinusIcon, PlusIcon } from "./icons.js"; import { ProblemList } from "./problems.js"; import { useStreaming } from "./streaming.js"; @@ -22,13 +25,25 @@ export interface WireframeViewerProps { showProblems?: boolean; /** Called with the problems when the complete text has some, and with [] once they're fixed. */ onProblems?: (problems: Problem[]) => void; + /** + * Called when someone clicks the board (a press that hardly moves; dragging pans) with the element + * or flow they most likely meant, and everything under the point, most likely first. On empty board + * outside the screens, `hit` is the board itself (`type: "Board"`). Hovered elements get a dashed outline. + */ + onElementClick?: (hit: Measured | null, hits: Measured[], event: PointerEvent) => void; + /** Called when the element under the pointer changes, and with null when the pointer leaves the board. */ + onElementHover?: (hit: Measured | null, hits: Measured[]) => void; + /** Outline the element on this line (or on each of these lines), such as the one clicked or the one at an editor's caret. */ + selected?: number | number[] | null; className?: string; style?: CSSProperties; } export function WireframeViewer({ source, streaming, onProblems, ...rest }: WireframeViewerProps) { const isStreaming = useStreaming(streaming); - const state = useBoard(source, isStreaming, onProblems); + // measuring every element is another render's worth of work: only when something uses it + const measure = !!(rest.onElementClick || rest.onElementHover || rest.selected != null); + const state = useBoard(source, isStreaming, onProblems, measure); // chips and zoomControls decide which elements the viewer is given: a change makes a new viewer return ; } @@ -41,7 +56,7 @@ export interface BoardViewerProps extends Omit's expanded view with its image). */ -export function BoardViewer({ state, streaming, chips = true, zoomControls = true, showProblems = true, problemsAs = "list", className, style }: BoardViewerProps) { +export function BoardViewer({ state, streaming, chips = true, zoomControls = true, showProblems = true, problemsAs = "list", onElementClick, onElementHover, selected, className, style }: BoardViewerProps) { const stage = useRef(null); const canvas = useRef(null); const screens = useRef(null); @@ -49,6 +64,9 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru const zoomOut = useRef(null); const zoomFit = useRef(null); const viewer = useRef(null); + // the viewer is made once: its callbacks read the latest props + const props = useRef({ onElementClick, onElementHover }); + props.current = { onElementClick, onElementHover }; useEffect(() => { viewer.current = createViewer({ @@ -58,6 +76,12 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru zoomIn: zoomIn.current, zoomOut: zoomOut.current, zoomFit: zoomFit.current, + }, { + onElementClick: (hit, hits, event) => props.current.onElementClick?.(hit, hits, event), + onElementHover: (hit, hits) => { + if (props.current.onElementClick) viewer.current?.highlight(hit && hit.type !== "Board" ? [hit] : [], { kind: "hover" }); + props.current.onElementHover?.(hit, hits); + }, }); return () => { viewer.current?.destroy(); // its listeners, the resize watch, the board image @@ -73,6 +97,20 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru if (img) img.alt = board.title ? `Wireframe: ${board.title}` : "Wireframe"; }, [board]); + // The elements, while the board matches the text: with problems, the board is the last valid one + // and its lines no longer match the text. + const elements = problems.length ? null : board?.elements ?? null; + useEffect(() => { + viewer.current?.setElements(elements); + viewer.current?.highlight([], { kind: "hover" }); + }, [elements]); + + const lines = selected == null ? "" : [selected].flat().join(","); + useEffect(() => { + const outlined = !elements || !lines ? [] : lines.split(",").map((n) => elementsAtLine(elements, Number(n))[0]).filter((e) => e && e.type !== "Board"); + viewer.current?.highlight(outlined); + }, [elements, lines]); + const visible = showProblems && problems.length > 0 && !streaming; useEffect(() => { viewer.current?.setStale(visible && !!board); }, [visible, board]); diff --git a/test/react.test.tsx b/test/react.test.tsx index f72fa8f..1c081a0 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -8,6 +8,7 @@ import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; +import type { MeasuredElement } from "tsquare"; const dom = new JSDOM("", { pretendToBeVisual: true }); // requestAnimationFrame, for CodeMirror let disconnected = 0; // ResizeObservers stopped (the viewer's destroy()) @@ -243,6 +244,130 @@ test("WireframeViewer: chips per screen and zoom buttons, each optional; problem await view.unmount(); }); +/** A stage with a size (jsdom lays nothing out), then a double-click to fit the board to it (it may have arrived already). */ +async function sizeStage(stage: HTMLElement, width = 800, height = 600) { + const { act } = await load(); + Object.defineProperty(stage, "clientWidth", { value: width }); + Object.defineProperty(stage, "clientHeight", { value: height }); + stage.getBoundingClientRect = () => ({ left: 0, top: 0, width, height }) as DOMRect; + await act(async () => { stage.dispatchEvent(new dom.window.MouseEvent("dblclick", { bubbles: true })); }); +} +/** Board pixels → the stage's client pixels, from the canvas' transform. */ +function toClient(canvas: HTMLElement, bx: number, by: number) { + const m = canvas.style.transform.match(/translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/); + assert.ok(m, `transform: ${canvas.style.transform}`); + const [x, y, scale] = m.slice(1).map(Number); + return { clientX: x + bx * scale, clientY: y + by * scale }; +} +const pointer = (stage: HTMLElement, type: string, at: { clientX: number; clientY: number }) => + stage.dispatchEvent(new dom.window.PointerEvent(type, { pointerId: 1, pointerType: "mouse", bubbles: true, ...at })); +/** The center of the element on a line of SIGN_IN, in board pixels. */ +async function centerOf(line: number) { + const { tsquare } = await load(); + const { spec, lines } = tsquare.compileWireframe(SIGN_IN); + const entry = (await tsquare.measureElements(spec!, lines)).find((e) => e.line === line && e.type !== "Flow") as MeasuredElement; + const { box } = entry; + return { entry, x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +test("WireframeViewer: onElementClick gets the element clicked and everything under it; dragging pans instead", async () => { + const { h, act, lib } = await load(); + const clicks: [string, number, string[]][] = []; + const view = await mount(h(lib.WireframeViewer, { source: SIGN_IN, onElementClick: (hit, hits) => clicks.push([hit!.type, hit!.line, hits.map((e) => e.type)]) })); + const stage = view.$(".tsquare-stage")!, canvas = view.$(".tsquare-canvas")!; + await until(() => !!view.$(".tsquare-canvas img"), "the board"); + await settle(); // the elements arrive with the board; setElements runs in an effect + await sizeStage(stage); + + const button = await centerOf(11); // button primary lg "Sign in" + const at = toClient(canvas, button.x, button.y); + await act(async () => { pointer(stage, "pointerdown", at); pointer(stage, "pointerup", at); }); + assert.deepEqual(clicks, [["Button", 11, ["Button", "Screen", "Board"]]]); + + // a press that moves more than a few pixels is a drag: it pans, and isn't a click + const moved = { clientX: at.clientX + 30, clientY: at.clientY }; + await act(async () => { pointer(stage, "pointerdown", at); pointer(stage, "pointermove", moved); pointer(stage, "pointerup", moved); }); + assert.equal(clicks.length, 1); + + // empty board, outside the screen: the board itself + const empty = toClient(canvas, 2, 2); + await act(async () => { pointer(stage, "pointerdown", empty); pointer(stage, "pointerup", empty); }); + assert.deepEqual(clicks[1], ["Board", 1, ["Board"]]); + await view.unmount(); +}); + +test("WireframeViewer: hovering outlines the element (dashed) when it's clickable, and onElementHover follows the pointer", async () => { + const { h, act, lib } = await load(); + const hovers: (string | null)[] = []; + const props = { source: SIGN_IN, onElementHover: (hit: { type: string } | null) => hovers.push(hit?.type ?? null) }; + const view = await mount(h(lib.WireframeViewer, props)); + const stage = view.$(".tsquare-stage")!, canvas = view.$(".tsquare-canvas")!; + await until(() => !!view.$(".tsquare-canvas img"), "the board"); + await settle(); // the elements arrive with the board; setElements runs in an effect + await sizeStage(stage); + + const input = await centerOf(6); // input email "Email" + await act(async () => pointer(stage, "pointermove", toClient(canvas, input.x, input.y))); + assert.deepEqual(hovers, ["Input"]); + assert.equal(view.$(".tsquare-highlight-hover"), null, "no outline: nothing to click (no onElementClick)"); + + await view.rerender(h(lib.WireframeViewer, { ...props, onElementClick: () => {} })); + const heading = await centerOf(4); + await act(async () => pointer(stage, "pointermove", toClient(canvas, heading.x, heading.y))); + assert.deepEqual(hovers, ["Input", "Heading"]); + const outline = view.$$(".tsquare-highlight-hover"); + assert.equal(outline.length, 1); + assert.deepEqual([outline[0].style.left, outline[0].style.width], [`${heading.entry.box.x}px`, `${heading.entry.box.width}px`], "around the heading, in board pixels"); + + await act(async () => { stage.dispatchEvent(new dom.window.PointerEvent("pointerleave", { pointerId: 1 })); }); + assert.deepEqual(hovers, ["Input", "Heading", null]); + assert.equal(view.$(".tsquare-highlight-hover"), null, "gone when the pointer leaves"); + await view.unmount(); +}); + +test("WireframeViewer: selected outlines the element on each line; none while the text has problems", async () => { + const { h, lib } = await load(); + const view = await mount(h(lib.WireframeViewer, { source: SIGN_IN, selected: 11 })); + const outlines = () => view.$$(".tsquare-highlight-selected"); + await until(() => outlines().length === 1, "the selected outline"); + const button = await centerOf(11); + assert.equal(outlines()[0].style.top, `${button.entry.box.y}px`); + + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN, selected: [6, 7] })); + assert.equal(outlines().length, 2, "a list of lines: one outline each"); + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN, selected: 8 })); + assert.equal(outlines().length, 1, "a container: the stack row"); + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN, selected: 1 })); + assert.equal(outlines().length, 0, "not the board: it would outline everything"); + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN, selected: null })); + assert.equal(outlines().length, 0); + + // with problems, the board shown is the last valid one: its lines no longer match the text + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN + ' buton "Oops"\n', selected: 11 })); + await until(() => !!view.$(".tsquare-note .tsquare-problems"), "the problems"); + assert.ok(view.$(".tsquare-canvas img"), "the last valid board stays"); + assert.equal(outlines().length, 0); + await view.rerender(h(lib.WireframeViewer, { source: SIGN_IN, selected: 11 })); + await until(() => outlines().length === 1, "the outline once the text is fixed"); + await view.unmount(); +}); + +test("useBoard measures the elements only when asked (WireframeViewer: with an element prop)", async () => { + const { h } = await load(); + const { useBoard } = await import("../src/use-board.js"); + const Probe = ({ measure }: { measure: boolean }) => { + const { board } = useBoard(SIGN_IN, false, undefined, measure); + return h("output", null, board ? String(board.elements?.length ?? "none") : ""); + }; + const view = await mount(h(Probe, { measure: false })); + await until(() => !!view.$("output")!.textContent, "the board"); + assert.equal(view.$("output")!.textContent, "none"); + await view.rerender(h(Probe, { measure: true })); + await until(() => view.$("output")!.textContent !== "none", "the elements"); + assert.ok(Number(view.$("output")!.textContent) > 10); + await view.unmount(); +}); + test("tsquareComponents: react-markdown's ```tsquare blocks become boards, other code blocks stay", async () => { const { h, lib, Markdown } = await load(); const markdown = `Here's the screen:\n\n\`\`\`tsquare\n${SIGN_IN}\`\`\`\n\nAnd some code:\n\n\`\`\`js\nconst x = 1;\n\`\`\`\n`; From dbf9fe821c366473b6d0c5c6618c136e116e55bd Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Fri, 30 Oct 2026 09:30:21 -0400 Subject: [PATCH 2/6] WireframePlayground links the board and the text (linkBoard) Clicking an element selects its line in the editor; the caret's element is outlined on the board. On by default; linkBoard={false} turns it off and skips measuring. WireframeEditor gets onCursorLine, to connect your own editor and viewer. A hover outline now goes as soon as the board stops being clickable, instead of at the next redraw. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++- docs/editor.md | 28 +++++++++++++++++++++++ docs/playground.md | 17 ++++++++++++++ docs/viewer.md | 2 +- src/editor.tsx | 7 +++++- src/playground.tsx | 35 ++++++++++++++++++++++++----- src/viewer.tsx | 4 ++++ test/react.test.tsx | 55 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f6d0a..99485d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ - **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. - **The board is measured only when it's needed:** the viewer measures every element (about one more render) only with `onElementClick`, `onElementHover` or `selected`. Other boards cost the same as before. - **While the text has problems, nothing is clickable or outlined:** the last valid board's lines no longer match the text. +- **`` links the board and the text:** click an element to select its line in the editor; move the caret and its element is outlined on the board. On by default; `linkBoard={false}` turns it off (and skips measuring the board). +- **``:** the caret's line when it moves to another one, to connect your own editor and viewer (`selected`, `goToLine`). - The `Measured` types (`Measured`, `MeasuredElement`, `MeasuredFlow`, `MeasuredBox`) are exported from `@tsquare-js/react`. - **Needs tsquare 0.14.0 or later.** -- **Docs:** the WireframeViewer page has a Clicking elements section; Styling lists `--tsquare-highlight` and the outline classes. +- **Docs:** the WireframeViewer page has a Clicking elements section, the Playground page Clicking the board, the Editor page Next to a viewer; Styling lists `--tsquare-highlight` and the outline classes. ## 0.4.0 diff --git a/docs/editor.md b/docs/editor.md index 421749d..ec4ad12 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -22,6 +22,7 @@ The editor fills its box, so give it a height: with the stylesheet it's 20rem un | `defaultValue` | | The starting text, when the editor keeps it. | | `onChange` | | Called with the whole text after every change. | | `onProblems` | | Called with the problems when the text has some, after a pause in typing, and with `[]` once they're fixed. See [Problems](problems.md). | +| `onCursorLine` | | Called with the caret's line (1 is the first) when it moves to another line. See [Next to a viewer](#next-to-a-viewer). | | `autocomplete` | `true` | Suggestions for components, props, option words, icons and accents as you type (Ctrl+Space to ask). | | `lineNumbers` | `true` | Line numbers in a gutter. | | `lineWrapping` | `true` | Wrap long lines instead of scrolling sideways. A wrapped line continues 4 spaces past its own indentation, so it doesn't look like a nested element. | @@ -64,6 +65,33 @@ export function EditorWithExample({ example }: { example: string }) { | `focus()` | Put the cursor in the editor. | | `goToLine(n)` | Select line `n`, scroll to it and focus the editor. | +## Next to a viewer + +To build your own layout around the editor and a [viewer](viewer.md), connect them by line: `onCursorLine` gives the caret's line, which the viewer's `selected` outlines, and a click on the board moves the caret with `goToLine`. This is what [WireframePlayground](playground.md#clicking-the-board) does. + +```tsx +import { useRef, useState } from "react"; +import { WireframeViewer } from "@tsquare-js/react"; +import { WireframeEditor, type WireframeEditorHandle } from "@tsquare-js/react/editor"; + +export function Split({ initial }: { initial: string }) { + const [text, setText] = useState(initial); + const [caret, setCaret] = useState(null); + const editor = useRef(null); + return ( +
+ + { if (hit && hit.type !== "Board") editor.current?.goToLine(hit.line); }} + style={{ flex: 1 }} + /> +
+ ); +} +``` + ## Why it's imported from `@tsquare-js/react/editor` The editor is built on CodeMirror, bundled inside tsquare (about 125 KB compressed after minifying, on top of the renderer). Importing it from its own entry point keeps it out of apps that only show boards: `@tsquare-js/react` never loads it. It doesn't share CodeMirror with the rest of your app and takes no CodeMirror extensions. If only some pages edit wireframes, load it when it's first needed (`lazy()` or a dynamic `import()`). diff --git a/docs/playground.md b/docs/playground.md index 1ab5b23..3103bcc 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -31,6 +31,7 @@ With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-h | `showProblems` | `true` | The note on the board counting the problems. The editor underlines them either way. | | `chips` | `true` | A chip per screen above the board. | | `zoomControls` | `true` | Zoom out, zoom level and zoom in above the board. | +| `linkBoard` | `true` | Click an element on the board to put the editor's caret on its line, and outline the element at the caret. See [Clicking the board](#clicking-the-board). | | `autocomplete` | `true` | Autocomplete in the editor. | | `lineNumbers` | `true` | Line numbers in the editor. | | `lineWrapping` | `true` | Wrap long lines in the editor instead of scrolling sideways. | @@ -43,6 +44,22 @@ A `ref` gets the editor's methods (`getText`, `setText`, `focus`, `goToLine`), a The board redraws every 200 ms or so with the latest text that's valid, so it keeps up without redrawing on every key. Problems wait for a pause: about 400 ms after the last key, the text is checked, problems are underlined in the editor, and a note on the board counts them: "2 problems · showing the last valid board", with that board dimmed. `onProblems` fires then, not while typing. +## Clicking the board + +Click an element on the board, and the editor selects its line. Move the caret in the editor, and the element on that line is outlined on the board. A line inside a block (a blank line between a stack's children, say) outlines the block. Hovering the board outlines what a click would pick, dashed. + +While the text has problems, the board shows the last valid version, whose lines no longer match the text: clicks pan and nothing is outlined until the problems are fixed. + +Turn it off with `linkBoard={false}`, for example where the board is only a preview. Linking measures where every element is drawn, which costs about one more render each time the board is redrawn; off, it costs nothing. + +```tsx +import { WireframePlayground } from "@tsquare-js/react/editor"; + +export function Preview({ text }: { text: string }) { + return ; +} +``` + ## Saving the text Keep the text in your state to save it, share it or load another: diff --git a/docs/viewer.md b/docs/viewer.md index 91ddbc1..cb41c03 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -72,7 +72,7 @@ export function Pick({ text }: { text: string }) { Each element has its `line` and `endLine` in the text (its whole block, children included), `type`, `label`, its `#id` if the text gives one, and its `box` on the board. A flow arrow has `type: "Flow"`, `from` and `to`. The full list is in tsquare's [element positions](https://tsquare.dev/docs/element-positions) docs. -`selected` takes a line or a list of lines. A line inside a block, such as a blank line between a stack's children, outlines that block, so you can pass an editor's caret line. The board's own line outlines nothing. +`selected` takes a line or a list of lines. A line inside a block, such as a blank line between a stack's children, outlines that block, so you can pass an editor's caret line (see [Next to a viewer](editor.md#next-to-a-viewer)). The board's own line outlines nothing. Some details: diff --git a/src/editor.tsx b/src/editor.tsx index 5c996c3..700c5a2 100644 --- a/src/editor.tsx +++ b/src/editor.tsx @@ -16,6 +16,8 @@ export interface WireframeEditorProps { onChange?: (text: string) => void; /** Called with the problems when the text has some (after a pause in typing), and with [] once they're fixed. */ onProblems?: (problems: Problem[]) => void; + /** Called with the caret's line (1 = the first) when it moves to another line: pass it to a viewer's `selected` to outline that element. */ + onCursorLine?: (line: number) => void; /** Autocomplete for components, props, option words, icons and accents. Default true. */ autocomplete?: boolean; /** Line numbers. Default true. */ @@ -97,13 +99,15 @@ export interface EditorCoreProps extends Omit(function EditorCore( - { value, defaultValue, onChange, issues, autocomplete = true, lineNumbers = true, lineWrapping = true, ariaLabel, className, style }, + { value, defaultValue, onChange, onCursorLine, issues, autocomplete = true, lineNumbers = true, lineWrapping = true, ariaLabel, className, style }, ref, ) { const host = useRef(null); const editor = useRef(null); const change = useRef(onChange); change.current = onChange; + const cursor = useRef(onCursorLine); + cursor.current = onCursorLine; // Made in a layout effect, which runs before useImperativeHandle below hands out the ref: so a // callback ref or a parent's useLayoutEffect can already focus it or read its text. @@ -115,6 +119,7 @@ export const EditorCore = forwardRef(fun lineWrapping, ariaLabel, onChange: (text) => change.current?.(text), + onCursorLine: (line) => cursor.current?.(line), }); editor.current = instance; return () => { diff --git a/src/playground.tsx b/src/playground.tsx index 886e8e6..d150c1b 100644 --- a/src/playground.tsx +++ b/src/playground.tsx @@ -1,9 +1,10 @@ /** * : the editor and the viewer side by side (stacked when narrow), sharing one * text. While someone types, the board redraws every 200 ms or so and problems wait; after a pause - * they're underlined in the editor and counted in a note on the viewer. + * they're underlined in the editor and counted in a note on the viewer. With linkBoard (the + * default), clicking an element puts the caret on its line, and the caret's element is outlined. */ -import { forwardRef, useEffect, useRef, useState, type CSSProperties } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; import { EditorCore, type WireframeEditorHandle } from "./editor.js"; import { useBoard, type Problem } from "./use-board.js"; import { BoardViewer } from "./viewer.js"; @@ -25,6 +26,8 @@ export interface WireframePlaygroundProps { chips?: boolean; /** Zoom out, zoom level and zoom in above the board. Default true. */ zoomControls?: boolean; + /** Click an element on the board to put the editor's caret on its line, and outline the element at the caret. Default true. */ + linkBoard?: boolean; /** Autocomplete in the editor. Default true. */ autocomplete?: boolean; /** Line numbers in the editor. Default true. */ @@ -38,32 +41,52 @@ export interface WireframePlaygroundProps { const TYPING_PAUSE = 400; export const WireframePlayground = forwardRef(function WireframePlayground( - { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, autocomplete, lineNumbers, lineWrapping, className, style }, + { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, linkBoard = true, autocomplete, lineNumbers, lineWrapping, className, style }, ref, ) { const [inner, setInner] = useState(defaultValue ?? ""); const text = value ?? inner; const typing = useTyping(text); - const state = useBoard(text, typing, onProblems); + const state = useBoard(text, typing, onProblems, linkBoard); // linking needs the elements measured + const [caret, setCaret] = useState(null); + const editor = useRef(null); + useImperativeHandle(ref, () => ({ + getText: () => editor.current?.getText() ?? "", + setText: (t) => editor.current?.setText(t), + focus: () => editor.current?.focus(), + goToLine: (line) => editor.current?.goToLine(line), + }), []); // underline once the text has been checked after a pause; meanwhile keep the current underlines const issues = typing || state.pending ? undefined : state.problems; return (
{ setInner(t); onChange?.(t); }} + onCursorLine={setCaret} issues={issues} autocomplete={autocomplete} lineNumbers={lineNumbers} lineWrapping={lineWrapping} /> - + { if (hit && hit.type !== "Board") editor.current?.goToLine(hit.line); } : undefined} + selected={linkBoard ? caret : null} + />
); diff --git a/src/viewer.tsx b/src/viewer.tsx index cde414f..43bfe4a 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -105,6 +105,10 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru viewer.current?.highlight([], { kind: "hover" }); }, [elements]); + // no longer clickable: no hover outline (new ones aren't drawn; this clears the one showing) + const clickable = !!onElementClick; + useEffect(() => { if (!clickable) viewer.current?.highlight([], { kind: "hover" }); }, [clickable]); + const lines = selected == null ? "" : [selected].flat().join(","); useEffect(() => { const outlined = !elements || !lines ? [] : lines.split(",").map((n) => elementsAtLine(elements, Number(n))[0]).filter((e) => e && e.type !== "Board"); diff --git a/test/react.test.tsx b/test/react.test.tsx index 1c081a0..2a1fe27 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -322,6 +322,12 @@ test("WireframeViewer: hovering outlines the element (dashed) when it's clickabl await act(async () => { stage.dispatchEvent(new dom.window.PointerEvent("pointerleave", { pointerId: 1 })); }); assert.deepEqual(hovers, ["Input", "Heading", null]); assert.equal(view.$(".tsquare-highlight-hover"), null, "gone when the pointer leaves"); + + // no longer clickable (the elements stay, for onElementHover): the outline goes at once + await act(async () => pointer(stage, "pointermove", toClient(canvas, heading.x, heading.y))); + assert.equal(view.$$(".tsquare-highlight-hover").length, 1); + await view.rerender(h(lib.WireframeViewer, props)); + assert.equal(view.$(".tsquare-highlight-hover"), null, "cleared when onElementClick goes"); await view.unmount(); }); @@ -516,6 +522,55 @@ test("WireframePlayground: editor and viewer on one text; problems wait for a pa await view.unmount(); }); +test("WireframePlayground: clicking an element puts the caret on its line, and the caret's element is outlined; linkBoard={false} turns both off", async () => { + const { h, act, lib } = await load(); + const React = await import("react"); + const ref = React.createRef(); + const view = await mount(h(lib.WireframePlayground, { ref, defaultValue: SIGN_IN })); + const stage = view.$(".tsquare-stage")!, canvas = view.$(".tsquare-canvas")!; + await until(() => !!view.$(".tsquare-canvas img"), "the board"); + await settle(); + await sizeStage(stage); + const activeLine = () => view.$(".cm-activeLine")?.textContent?.trim(); + const outlines = () => view.$$(".tsquare-highlight-selected"); + assert.equal(outlines().length, 0, "the caret starts on the board's line: nothing outlined"); + + const button = await centerOf(11); + const at = toClient(canvas, button.x, button.y); + await act(async () => { pointer(stage, "pointerdown", at); pointer(stage, "pointerup", at); }); + assert.equal(activeLine(), 'button primary lg "Sign in" fullWidth', "the caret on the button's line"); + assert.equal(outlines().length, 1); + assert.equal(outlines()[0].style.top, `${button.entry.box.y}px`, "the button outlined"); + + // the caret moving outlines another element + await act(async () => ref.current!.goToLine(6)); + const input = await centerOf(6); + assert.equal(outlines()[0].style.top, `${input.entry.box.y}px`); + await act(async () => pointer(stage, "pointermove", at)); + assert.equal(view.$$(".tsquare-highlight-hover").length, 1, "hovering outlines too: the board is clickable"); + + await view.rerender(h(lib.WireframePlayground, { ref, defaultValue: SIGN_IN, linkBoard: false })); + assert.equal(outlines().length, 0, "no caret outline"); + assert.equal(view.$$(".tsquare-highlight-hover").length, 0, "nor the hover outline, right away (not once the board is redrawn)"); + await settle(); // the board redrawn without elements + await act(async () => { pointer(stage, "pointerdown", at); pointer(stage, "pointerup", at); }); + assert.equal(activeLine(), 'input email "Email" placeholder="you@example.com"', "a click no longer moves the caret"); + await view.unmount(); +}); + +test("WireframeEditor: onCursorLine reports the caret's line when it moves to another one", async () => { + const { h, act, lib } = await load(); + const React = await import("react"); + const ref = React.createRef(); + const lines: number[] = []; + const view = await mount(h(lib.WireframeEditor, { ref, defaultValue: SIGN_IN, onCursorLine: (n: number) => lines.push(n) })); + await act(async () => ref.current!.goToLine(6)); + await act(async () => ref.current!.goToLine(6)); + await act(async () => ref.current!.goToLine(11)); + assert.deepEqual(lines, [6, 11]); + await view.unmount(); +}); + test("unmounting a viewer stops it (destroy(): its resize watch, listeners and image)", async () => { const { h, lib } = await load(); const view = await mount(h(lib.WireframeViewer, { source: SIGN_IN })); From c9f9ff965355411fa175fe4501d30b6b01afa475 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Fri, 30 Oct 2026 11:21:13 -0400 Subject: [PATCH 3/6] The outline waits for the board when the text and selected change together After Enter in the playground, the caret's new line was matched against the board still on show (the older text's lines) and briefly outlined a neighbor. useBoard now knows a board is behind from the render that brings the new text (pending was set in an effect, a render late), and the viewer keeps its outline until the board catches up. A test records every outline drawn and checks the old board's line is never used. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/playground.md | 2 +- docs/viewer.md | 1 + src/use-board.ts | 21 +++++++++++++++------ src/viewer.tsx | 7 ++++++- test/react.test.tsx | 28 ++++++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99485d2..0f5deaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.5.0 -- **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. +- **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). When the text and `selected` change together, the outline waits for the new board instead of matching the new line against the old one. The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. - **The board is measured only when it's needed:** the viewer measures every element (about one more render) only with `onElementClick`, `onElementHover` or `selected`. Other boards cost the same as before. - **While the text has problems, nothing is clickable or outlined:** the last valid board's lines no longer match the text. - **`` links the board and the text:** click an element to select its line in the editor; move the caret and its element is outlined on the board. On by default; `linkBoard={false}` turns it off (and skips measuring the board). diff --git a/docs/playground.md b/docs/playground.md index 3103bcc..f5c90d1 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -46,7 +46,7 @@ The board redraws every 200 ms or so with the latest text that's valid, so it ke ## Clicking the board -Click an element on the board, and the editor selects its line. Move the caret in the editor, and the element on that line is outlined on the board. A line inside a block (a blank line between a stack's children, say) outlines the block. Hovering the board outlines what a click would pick, dashed. +Click an element on the board, and the editor selects its line. Move the caret in the editor, and the element on that line is outlined on the board. A line inside a block (a blank line between a stack's children, say) outlines the block. Hovering the board outlines what a click would pick, dashed. While you type, the outline stays put until the board is redrawn with your change, so it never jumps to a neighbor whose line just moved. While the text has problems, the board shows the last valid version, whose lines no longer match the text: clicks pan and nothing is outlined until the problems are fixed. diff --git a/docs/viewer.md b/docs/viewer.md index cb41c03..75f142b 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -77,6 +77,7 @@ Each element has its `line` and `endLine` in the text (its whole block, children Some details: - **Lines shift when the text changes.** An element picked on line 11 is on line 12 after someone adds a line above it. For anything that has to survive edits, such as a comment pinned to an element, key it by the element's `id` and give the elements you care about one in the text (`button "Save" #save`). +- **When `source` and `selected` change together, the outline waits for the new board.** Until it's drawn, the board on show has the old text's lines, so the new line could name a neighbor (after Enter in an editor, the line below). The outline stays where it was, then moves. - **While the text has problems, nothing is clickable or outlined.** The viewer still shows the last board that worked, but its lines no longer match the text. Clicks pan, and outlines come back once the text is fixed. - **Measuring costs about one more render**, so the viewer only measures the board when you set `onElementClick`, `onElementHover` or `selected`. - **The outline color** is the CSS variable `--tsquare-highlight` (see [Styling](styling.md)). diff --git a/src/use-board.ts b/src/use-board.ts index ebab97e..343d6ca 100644 --- a/src/use-board.ts +++ b/src/use-board.ts @@ -4,7 +4,7 @@ * STREAM_INTERVAL ms and keeps any result newer than what's shown, so a steady stream fills the * board in as it goes (waiting for a pause would draw nothing until the stream ends). */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { compileWireframe, measureBoardLayout, measureElements, renderWireframeSvg, type Measured } from "tsquare"; import type { BoardItem } from "tsquare/viewer"; @@ -30,15 +30,20 @@ export interface BoardState { board: Board | null; /** Problems in the latest checked text; [] when it's valid. */ problems: Problem[]; - /** Newer text than what's shown hasn't been drawn or checked yet. */ + /** Newer text than what's shown hasn't been drawn or checked yet. True from the render that brings the text, not one later. */ pending: boolean; } +interface DrawState extends BoardState { + /** The text of the last draw applied (valid or not): until it's the current text, the result is behind. */ + text: string | null; +} + const STREAM_INTERVAL = 200; /** `measure`: also measure every element (a second render's worth of work), for clicks and outlines. */ export function useBoard(source: string, streaming: boolean, onProblems?: (problems: Problem[]) => void, measure = false): BoardState { - const [state, setState] = useState({ board: null, problems: [], pending: true }); + const [state, setState] = useState({ board: null, problems: [], pending: true, text: null }); const seq = useRef(0); const shown = useRef(0); const lastStart = useRef(0); @@ -63,11 +68,11 @@ export function useBoard(source: string, streaming: boolean, onProblems?: (probl return () => clearTimeout(timer); async function draw(text: string, n: number) { - const apply = (update: (s: BoardState) => BoardState, problems: Problem[]) => { + const apply = (update: (s: DrawState) => BoardState, problems: Problem[]) => { if (!alive.current || n < shown.current) return; shown.current = n; const pending = n !== seq.current; - setState((s) => ({ ...update(s), pending })); + setState((s) => ({ ...update(s), pending, text })); // Problems are reported only for complete text: mid-stream, half-written lines are always wrong. if (!streaming && !pending) { const key = JSON.stringify(problems); @@ -96,7 +101,11 @@ export function useBoard(source: string, streaming: boolean, onProblems?: (probl } }, [source, streaming, measure]); - return state; + // pending from the text itself: the effect above marks it a render late, and in that render a + // board drawn from older text would be taken for this text's (its lines matched against the new ones) + const { board, problems, pending, text } = state; + const behind = pending || text !== source; + return useMemo(() => ({ board, problems, pending: behind }), [board, problems, behind]); } /** "1 problem in this wireframe", "2 problems in this wireframe". */ diff --git a/src/viewer.tsx b/src/viewer.tsx index 43bfe4a..10eba34 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -109,11 +109,16 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru const clickable = !!onElementClick; useEffect(() => { if (!clickable) viewer.current?.highlight([], { kind: "hover" }); }, [clickable]); + // While the board is behind the text (typing, streaming), its lines are the older text's: a line + // from the new text (an editor's caret after Enter) would pick a neighbor, so the outline stays put + // until the board catches up. const lines = selected == null ? "" : [selected].flat().join(","); + const behind = state.pending && !!elements; useEffect(() => { + if (behind) return; const outlined = !elements || !lines ? [] : lines.split(",").map((n) => elementsAtLine(elements, Number(n))[0]).filter((e) => e && e.type !== "Board"); viewer.current?.highlight(outlined); - }, [elements, lines]); + }, [elements, lines, behind]); const visible = showProblems && problems.length > 0 && !streaming; useEffect(() => { viewer.current?.setStale(visible && !!board); }, [visible, board]); diff --git a/test/react.test.tsx b/test/react.test.tsx index 2a1fe27..523693d 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -358,6 +358,34 @@ test("WireframeViewer: selected outlines the element on each line; none while th await view.unmount(); }); +test("WireframeViewer: when the text and selected change together (Enter in an editor), the outline waits for the new board", async () => { + const { h, tsquare, lib } = await load(); + // a line added after line 10 (button "Forgot?"), with the caret on it: line 11 is "Help" in the new + // text, but "Sign in" in the board still on show until it's redrawn + const lines = SIGN_IN.split("\n"); + const next = [...lines.slice(0, 10), ' button ghost sm "Help"', ...lines.slice(10)].join("\n"); + const boxOf = async (text: string, line: number) => { + const { spec, lines: map } = tsquare.compileWireframe(text); + const entry = (await tsquare.measureElements(spec!, map)).find((e) => e.line === line && e.type !== "Flow") as MeasuredElement; + return `${entry.box.x},${entry.box.y} ${entry.box.width}x${entry.box.height}`; + }; + const [forgot, signIn, help] = [await boxOf(SIGN_IN, 10), await boxOf(SIGN_IN, 11), await boxOf(next, 11)]; + + const view = await mount(h(lib.WireframeViewer, { source: SIGN_IN, selected: 10 })); + const outlined = () => view.$$(".tsquare-highlight-selected").map((d) => `${parseFloat(d.style.left)},${parseFloat(d.style.top)} ${parseFloat(d.style.width)}x${parseFloat(d.style.height)}`); + await until(() => outlined()[0] === forgot, "the Forgot? button outlined"); + + // every outline drawn from here on, however quickly the board is redrawn + const seen: string[] = []; + const watch = new dom.window.MutationObserver(() => seen.push(...outlined())); + watch.observe(view.$(".tsquare-canvas")!, { childList: true, subtree: true }); + await view.rerender(h(lib.WireframeViewer, { source: next, selected: 11 })); + await until(() => outlined()[0] === help, "the Help button outlined once the board is redrawn"); + watch.disconnect(); + assert.ok(!seen.includes(signIn), `never the old board's line 11 (the Sign in button): saw ${JSON.stringify(seen)}`); + await view.unmount(); +}); + test("useBoard measures the elements only when asked (WireframeViewer: with an element prop)", async () => { const { h } = await load(); const { useBoard } = await import("../src/use-board.js"); From a34f0fd1580e3446b83499fa60acf25e4209b5b7 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Fri, 30 Oct 2026 11:51:51 -0400 Subject: [PATCH 4/6] Clear the outline when a board for older text lands meanwhile While the board is behind the text the outline is held, but a board for text edited since can land in that window (its draw started before the last edit). The held outline was drawn on the board before and doesn't fit the new one, so it's cleared until a board matches the text. Tested with BoardViewer's states in exact order (Copilot on tsquare#50 found the same window in the library's playground). Co-Authored-By: Claude Opus 5.5 --- docs/viewer.md | 2 +- src/viewer.tsx | 11 +++++++++-- test/react.test.tsx | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/viewer.md b/docs/viewer.md index 75f142b..bea1a0f 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -77,7 +77,7 @@ Each element has its `line` and `endLine` in the text (its whole block, children Some details: - **Lines shift when the text changes.** An element picked on line 11 is on line 12 after someone adds a line above it. For anything that has to survive edits, such as a comment pinned to an element, key it by the element's `id` and give the elements you care about one in the text (`button "Save" #save`). -- **When `source` and `selected` change together, the outline waits for the new board.** Until it's drawn, the board on show has the old text's lines, so the new line could name a neighbor (after Enter in an editor, the line below). The outline stays where it was, then moves. +- **When `source` and `selected` change together, the outline waits for the new board.** Until it's drawn, the board on show has the old text's lines, so the new line could name a neighbor (after Enter in an editor, the line below). The outline stays where it was, then moves. If a board for text that has changed since is drawn meanwhile, the outline is cleared instead, since it was drawn on the board before, and comes back with the next board. - **While the text has problems, nothing is clickable or outlined.** The viewer still shows the last board that worked, but its lines no longer match the text. Clicks pan, and outlines come back once the text is fixed. - **Measuring costs about one more render**, so the viewer only measures the board when you set `onElementClick`, `onElementHover` or `selected`. - **The outline color** is the CSS variable `--tsquare-highlight` (see [Styling](styling.md)). diff --git a/src/viewer.tsx b/src/viewer.tsx index 10eba34..4099894 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -111,13 +111,20 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru // While the board is behind the text (typing, streaming), its lines are the older text's: a line // from the new text (an editor's caret after Enter) would pick a neighbor, so the outline stays put - // until the board catches up. + // until the board catches up. A board for older text that lands meanwhile clears it: the outline + // was drawn on the board before and doesn't fit this one. const lines = selected == null ? "" : [selected].flat().join(","); const behind = state.pending && !!elements; + const outlinedOn = useRef(null); useEffect(() => { - if (behind) return; + if (behind) { + if (elements !== outlinedOn.current) viewer.current?.highlight([]); + outlinedOn.current = elements; + return; + } const outlined = !elements || !lines ? [] : lines.split(",").map((n) => elementsAtLine(elements, Number(n))[0]).filter((e) => e && e.type !== "Board"); viewer.current?.highlight(outlined); + outlinedOn.current = elements; }, [elements, lines, behind]); const visible = showProblems && problems.length > 0 && !streaming; diff --git a/test/react.test.tsx b/test/react.test.tsx index 523693d..949c0bf 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -386,6 +386,38 @@ test("WireframeViewer: when the text and selected change together (Enter in an e await view.unmount(); }); +test("WireframeViewer: a board for older text that lands meanwhile clears the outline instead of leaving the last board's over it", async () => { + // BoardViewer with the states useBoard goes through, so the order is exact: a board for edit 1 + // lands while the text is already edit 2's (pending), then edit 2's board + const { h, tsquare } = await load(); + const { BoardViewer } = await import("../src/viewer.js"); + const boardOf = async (text: string) => { + const { spec, lines } = tsquare.compileWireframe(text); + const [svg, layout, elements] = await Promise.all([tsquare.renderWireframeSvg(spec!), tsquare.measureBoardLayout(spec!), tsquare.measureElements(spec!, lines)]); + return { svg, width: layout.width, height: layout.height, items: layout.items, elements }; + }; + const lines = SIGN_IN.split("\n"); + const edit1 = [...lines.slice(0, 2), ' text "Hi"', ...lines.slice(2)].join("\n"); // "Forgot?" moves to line 11, lower on the board + const edit2 = edit1 + "\n"; + const [board0, board1, board2] = [await boardOf(SIGN_IN), await boardOf(edit1), await boardOf(edit2)]; + const key = (b: { x: number; y: number; width: number; height: number }) => `${b.x},${b.y} ${b.width}x${b.height}`; + const forgot = (b: typeof board0, line: number) => key((b.elements.find((e) => e.line === line && e.type !== "Flow") as MeasuredElement).box); + const viewer = (board: typeof board0, pending: boolean, selected: number) => h(BoardViewer, { state: { board, problems: [], pending }, streaming: false, selected }); + + const view = await mount(viewer(board0, false, 10)); + const outlined = () => view.$$(".tsquare-highlight-selected").map((d) => key({ x: parseFloat(d.style.left), y: parseFloat(d.style.top), width: parseFloat(d.style.width), height: parseFloat(d.style.height) })); + assert.deepEqual(outlined(), [forgot(board0, 10)]); + + await view.rerender(viewer(board0, true, 11)); // edit 1 typed: the caret's line moved, the board hasn't + assert.deepEqual(outlined(), [forgot(board0, 10)], "held while nothing new has landed"); + await view.rerender(viewer(board1, true, 11)); // edit 1's board lands, but the text is edit 2's + assert.notEqual(forgot(board1, 11), forgot(board0, 10), "the button moved on the new board"); + assert.deepEqual(outlined(), [], "no outline from the board before over this one"); + await view.rerender(viewer(board2, false, 11)); + assert.deepEqual(outlined(), [forgot(board2, 11)], "outlined again once the board matches the text"); + await view.unmount(); +}); + test("useBoard measures the elements only when asked (WireframeViewer: with an element prop)", async () => { const { h } = await load(); const { useBoard } = await import("../src/use-board.js"); From a5b219e0d2b0e72728e3e3ff60fd42a5d8d09381 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Fri, 30 Oct 2026 13:34:30 -0400 Subject: [PATCH 5/6] WireframePlayground: rename linkBoard to syncSelection Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/playground.md | 6 +++--- src/playground.tsx | 12 ++++++------ test/react.test.tsx | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5deaf..0faf541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). When the text and `selected` change together, the outline waits for the new board instead of matching the new line against the old one. The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. - **The board is measured only when it's needed:** the viewer measures every element (about one more render) only with `onElementClick`, `onElementHover` or `selected`. Other boards cost the same as before. - **While the text has problems, nothing is clickable or outlined:** the last valid board's lines no longer match the text. -- **`` links the board and the text:** click an element to select its line in the editor; move the caret and its element is outlined on the board. On by default; `linkBoard={false}` turns it off (and skips measuring the board). +- **`` syncs the selection between the board and the text:** click an element to select its line in the editor; move the caret and its element is outlined on the board. On by default; `syncSelection={false}` turns it off (and skips measuring the board). - **``:** the caret's line when it moves to another one, to connect your own editor and viewer (`selected`, `goToLine`). - The `Measured` types (`Measured`, `MeasuredElement`, `MeasuredFlow`, `MeasuredBox`) are exported from `@tsquare-js/react`. - **Needs tsquare 0.14.0 or later.** diff --git a/docs/playground.md b/docs/playground.md index f5c90d1..2f63566 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -31,7 +31,7 @@ With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-h | `showProblems` | `true` | The note on the board counting the problems. The editor underlines them either way. | | `chips` | `true` | A chip per screen above the board. | | `zoomControls` | `true` | Zoom out, zoom level and zoom in above the board. | -| `linkBoard` | `true` | Click an element on the board to put the editor's caret on its line, and outline the element at the caret. See [Clicking the board](#clicking-the-board). | +| `syncSelection` | `true` | Click an element on the board to put the editor's caret on its line, and outline the element at the caret. See [Clicking the board](#clicking-the-board). | | `autocomplete` | `true` | Autocomplete in the editor. | | `lineNumbers` | `true` | Line numbers in the editor. | | `lineWrapping` | `true` | Wrap long lines in the editor instead of scrolling sideways. | @@ -50,13 +50,13 @@ Click an element on the board, and the editor selects its line. Move the caret i While the text has problems, the board shows the last valid version, whose lines no longer match the text: clicks pan and nothing is outlined until the problems are fixed. -Turn it off with `linkBoard={false}`, for example where the board is only a preview. Linking measures where every element is drawn, which costs about one more render each time the board is redrawn; off, it costs nothing. +Turn it off with `syncSelection={false}`, for example where the board is only a preview. Syncing the selection measures where every element is drawn, which costs about one more render each time the board is redrawn; off, it costs nothing. ```tsx import { WireframePlayground } from "@tsquare-js/react/editor"; export function Preview({ text }: { text: string }) { - return ; + return ; } ``` diff --git a/src/playground.tsx b/src/playground.tsx index d150c1b..87fc60d 100644 --- a/src/playground.tsx +++ b/src/playground.tsx @@ -1,7 +1,7 @@ /** * : the editor and the viewer side by side (stacked when narrow), sharing one * text. While someone types, the board redraws every 200 ms or so and problems wait; after a pause - * they're underlined in the editor and counted in a note on the viewer. With linkBoard (the + * they're underlined in the editor and counted in a note on the viewer. With syncSelection (the * default), clicking an element puts the caret on its line, and the caret's element is outlined. */ import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; @@ -27,7 +27,7 @@ export interface WireframePlaygroundProps { /** Zoom out, zoom level and zoom in above the board. Default true. */ zoomControls?: boolean; /** Click an element on the board to put the editor's caret on its line, and outline the element at the caret. Default true. */ - linkBoard?: boolean; + syncSelection?: boolean; /** Autocomplete in the editor. Default true. */ autocomplete?: boolean; /** Line numbers in the editor. Default true. */ @@ -41,13 +41,13 @@ export interface WireframePlaygroundProps { const TYPING_PAUSE = 400; export const WireframePlayground = forwardRef(function WireframePlayground( - { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, linkBoard = true, autocomplete, lineNumbers, lineWrapping, className, style }, + { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, syncSelection = true, autocomplete, lineNumbers, lineWrapping, className, style }, ref, ) { const [inner, setInner] = useState(defaultValue ?? ""); const text = value ?? inner; const typing = useTyping(text); - const state = useBoard(text, typing, onProblems, linkBoard); // linking needs the elements measured + const state = useBoard(text, typing, onProblems, syncSelection); // syncing the selection needs the elements measured const [caret, setCaret] = useState(null); const editor = useRef(null); useImperativeHandle(ref, () => ({ @@ -84,8 +84,8 @@ export const WireframePlayground = forwardRef { if (hit && hit.type !== "Board") editor.current?.goToLine(hit.line); } : undefined} - selected={linkBoard ? caret : null} + onElementClick={syncSelection ? (hit) => { if (hit && hit.type !== "Board") editor.current?.goToLine(hit.line); } : undefined} + selected={syncSelection ? caret : null} /> diff --git a/test/react.test.tsx b/test/react.test.tsx index 949c0bf..5dd2c50 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -582,7 +582,7 @@ test("WireframePlayground: editor and viewer on one text; problems wait for a pa await view.unmount(); }); -test("WireframePlayground: clicking an element puts the caret on its line, and the caret's element is outlined; linkBoard={false} turns both off", async () => { +test("WireframePlayground: clicking an element puts the caret on its line, and the caret's element is outlined; syncSelection={false} turns both off", async () => { const { h, act, lib } = await load(); const React = await import("react"); const ref = React.createRef(); @@ -609,7 +609,7 @@ test("WireframePlayground: clicking an element puts the caret on its line, and t await act(async () => pointer(stage, "pointermove", at)); assert.equal(view.$$(".tsquare-highlight-hover").length, 1, "hovering outlines too: the board is clickable"); - await view.rerender(h(lib.WireframePlayground, { ref, defaultValue: SIGN_IN, linkBoard: false })); + await view.rerender(h(lib.WireframePlayground, { ref, defaultValue: SIGN_IN, syncSelection: false })); assert.equal(outlines().length, 0, "no caret outline"); assert.equal(view.$$(".tsquare-highlight-hover").length, 0, "nor the hover outline, right away (not once the board is redrawn)"); await settle(); // the board redrawn without elements From d9f2676e60697cb09943ef9434b04c323bc459a8 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Sat, 31 Oct 2026 08:45:10 -0400 Subject: [PATCH 6/6] WireframeViewer: no clicks while the board is behind; clearing selected doesn't wait A click on a board that's behind its text reported the older text's line, so the playground could put the caret on a neighbor after a line was inserted above. Clicks now wait for the board, like the outline. Clearing selected (null, [], syncSelection off) while behind left the outline showing until the redraw; it now clears right away. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/playground.md | 2 +- docs/viewer.md | 2 ++ src/viewer.tsx | 28 +++++++++++++++------------- test/react.test.tsx | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faf541..6f1f4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.5.0 -- **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). When the text and `selected` change together, the outline waits for the new board instead of matching the new line against the old one. The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. +- **Click elements in ``:** `onElementClick(hit, hits, event)` gets the element or flow arrow clicked (a press that hardly moves; dragging still pans) and everything under the point, most likely first, each with its lines in the text, type, label, `#id` and box. Hovered elements get a dashed outline. `onElementHover` follows the pointer. `selected` outlines the element on a line, or on each line in a list (a line inside a block, such as an editor's caret, outlines that block). When the text and `selected` change together, the outline waits for the new board instead of matching the new line against the old one; clicks aren't reported until then, and clearing `selected` takes effect right away. The outline color is `--tsquare-highlight`. Built on tsquare 0.14's viewer events. - **The board is measured only when it's needed:** the viewer measures every element (about one more render) only with `onElementClick`, `onElementHover` or `selected`. Other boards cost the same as before. - **While the text has problems, nothing is clickable or outlined:** the last valid board's lines no longer match the text. - **`` syncs the selection between the board and the text:** click an element to select its line in the editor; move the caret and its element is outlined on the board. On by default; `syncSelection={false}` turns it off (and skips measuring the board). diff --git a/docs/playground.md b/docs/playground.md index 2f63566..efc546a 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -46,7 +46,7 @@ The board redraws every 200 ms or so with the latest text that's valid, so it ke ## Clicking the board -Click an element on the board, and the editor selects its line. Move the caret in the editor, and the element on that line is outlined on the board. A line inside a block (a blank line between a stack's children, say) outlines the block. Hovering the board outlines what a click would pick, dashed. While you type, the outline stays put until the board is redrawn with your change, so it never jumps to a neighbor whose line just moved. +Click an element on the board, and the editor selects its line. Move the caret in the editor, and the element on that line is outlined on the board. A line inside a block (a blank line between a stack's children, say) outlines the block. Hovering the board outlines what a click would pick, dashed. While you type, the outline stays put until the board is redrawn with your change, so it never jumps to a neighbor whose line just moved, and a click on the board waits for that redraw too. While the text has problems, the board shows the last valid version, whose lines no longer match the text: clicks pan and nothing is outlined until the problems are fixed. diff --git a/docs/viewer.md b/docs/viewer.md index bea1a0f..42f3d7e 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -42,6 +42,8 @@ export function Minimal({ text }: { text: string }) { With `onElementClick`, a click on the board tells you what was clicked: a button, an input, a screen, a flow arrow. Dragging still pans; only a press that hardly moves counts as a click. Hovered elements get a dashed outline, so people can see what they're about to pick. `selected` outlines elements by their line in the text. +While the board is behind its text (redrawing after an edit, or while a reply streams in), its lines are the older text's, so clicks aren't reported until the new board is shown, and `selected` keeps the outline where it was. Clearing `selected` (`null` or `[]`) takes effect right away. + This example outlines the clicked element and shows its lines, the part of the text you'd hand a model to change: ```tsx diff --git a/src/viewer.tsx b/src/viewer.tsx index 4099894..2d64dc7 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -64,9 +64,15 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru const zoomOut = useRef(null); const zoomFit = useRef(null); const viewer = useRef(null); + const { board, problems } = state; + // The elements, while the board matches the text: with problems, the board is the last valid one + // and its lines no longer match the text. + const elements = problems.length ? null : board?.elements ?? null; + // While the board is behind the text (typing, streaming), its lines are the older text's. + const behind = state.pending && !!elements; // the viewer is made once: its callbacks read the latest props - const props = useRef({ onElementClick, onElementHover }); - props.current = { onElementClick, onElementHover }; + const props = useRef({ onElementClick, onElementHover, behind }); + props.current = { onElementClick, onElementHover, behind }; useEffect(() => { viewer.current = createViewer({ @@ -77,7 +83,8 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru zoomOut: zoomOut.current, zoomFit: zoomFit.current, }, { - onElementClick: (hit, hits, event) => props.current.onElementClick?.(hit, hits, event), + // not while the board is behind: the hit's line would be the older text's (the editor would go to a neighbor) + onElementClick: (hit, hits, event) => { if (!props.current.behind) props.current.onElementClick?.(hit, hits, event); }, onElementHover: (hit, hits) => { if (props.current.onElementClick) viewer.current?.highlight(hit && hit.type !== "Board" ? [hit] : [], { kind: "hover" }); props.current.onElementHover?.(hit, hits); @@ -89,7 +96,6 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru }; }, []); - const { board, problems } = state; useEffect(() => { if (!board || !viewer.current) return; viewer.current.show(board.svg, board.width, board.height, board.items); @@ -97,9 +103,6 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru if (img) img.alt = board.title ? `Wireframe: ${board.title}` : "Wireframe"; }, [board]); - // The elements, while the board matches the text: with problems, the board is the last valid one - // and its lines no longer match the text. - const elements = problems.length ? null : board?.elements ?? null; useEffect(() => { viewer.current?.setElements(elements); viewer.current?.highlight([], { kind: "hover" }); @@ -109,16 +112,15 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru const clickable = !!onElementClick; useEffect(() => { if (!clickable) viewer.current?.highlight([], { kind: "hover" }); }, [clickable]); - // While the board is behind the text (typing, streaming), its lines are the older text's: a line - // from the new text (an editor's caret after Enter) would pick a neighbor, so the outline stays put - // until the board catches up. A board for older text that lands meanwhile clears it: the outline - // was drawn on the board before and doesn't fit this one. + // While the board is behind, a line from the new text (an editor's caret after Enter) would pick a + // neighbor, so the outline stays put until the board catches up. A board for older text that lands + // meanwhile clears it: the outline was drawn on the board before and doesn't fit this one. So does + // clearing `selected`, which needs no lines matched. const lines = selected == null ? "" : [selected].flat().join(","); - const behind = state.pending && !!elements; const outlinedOn = useRef(null); useEffect(() => { if (behind) { - if (elements !== outlinedOn.current) viewer.current?.highlight([]); + if (elements !== outlinedOn.current || !lines) viewer.current?.highlight([]); outlinedOn.current = elements; return; } diff --git a/test/react.test.tsx b/test/react.test.tsx index 5dd2c50..f8509dd 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -418,6 +418,39 @@ test("WireframeViewer: a board for older text that lands meanwhile clears the ou await view.unmount(); }); +test("WireframeViewer: while the board is behind the text, clicks aren't reported, and clearing selected clears the outline", async () => { + const { h, act, tsquare } = await load(); + const { BoardViewer } = await import("../src/viewer.js"); + const { spec, lines } = tsquare.compileWireframe(SIGN_IN); + const [svg, layout, elements] = await Promise.all([tsquare.renderWireframeSvg(spec!), tsquare.measureBoardLayout(spec!), tsquare.measureElements(spec!, lines)]); + const board = { svg, width: layout.width, height: layout.height, items: layout.items, elements }; + const clicks: number[] = []; + const viewer = (pending: boolean, selected: number | null) => + h(BoardViewer, { state: { board, problems: [], pending }, streaming: false, selected, onElementClick: (hit) => clicks.push(hit!.line) }); + + const view = await mount(viewer(false, 11)); + const stage = view.$(".tsquare-stage")!, canvas = view.$(".tsquare-canvas")!; + await sizeStage(stage); + const button = await centerOf(11); // button primary lg "Sign in" + const click = () => act(async () => { const at = toClient(canvas, button.x, button.y); pointer(stage, "pointerdown", at); pointer(stage, "pointerup", at); }); + await click(); + assert.deepEqual(clicks, [11]); + assert.equal(view.$$(".tsquare-highlight-selected").length, 1); + + // a line typed above the button: the board still shows the text before, so line 11 is the old text's + await view.rerender(viewer(true, 11)); + await click(); + assert.deepEqual(clicks, [11], "a click on the board that's behind isn't reported"); + assert.equal(view.$$(".tsquare-highlight-selected").length, 1, "the outline is held"); + await view.rerender(viewer(true, null)); // syncSelection off, or the host clears its selection + assert.equal(view.$$(".tsquare-highlight-selected").length, 0, "clearing the selection doesn't wait for the board"); + + await view.rerender(viewer(false, null)); // caught up + await click(); + assert.deepEqual(clicks, [11, 11]); + await view.unmount(); +}); + test("useBoard measures the elements only when asked (WireframeViewer: with an element prop)", async () => { const { h } = await load(); const { useBoard } = await import("../src/use-board.js");