diff --git a/CHANGELOG.md b/CHANGELOG.md index 97bb1b0..6f1f4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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). 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). +- **``:** 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, the Playground page Clicking the board, the Editor page Next to a viewer; 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/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..efc546a 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. | +| `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. | @@ -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 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. + +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 ; +} +``` + ## Saving the text Keep the text in your state to save it, share it or load another: 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..42f3d7e 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,52 @@ 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. + +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 +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 (see [Next to a viewer](editor.md#next-to-a-viewer)). 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`). +- **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)). + ## 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/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/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/playground.tsx b/src/playground.tsx index 886e8e6..87fc60d 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 syncSelection (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. */ + syncSelection?: 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, 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); + 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, () => ({ + 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={syncSelection ? caret : null} + />
); diff --git a/src/use-board.ts b/src/use-board.ts index e6c47c2..343d6ca 100644 --- a/src/use-board.ts +++ b/src/use-board.ts @@ -4,8 +4,8 @@ * 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 { compileWireframe, measureBoardLayout, renderWireframeSvg } from "tsquare"; +import { useEffect, useMemo, useRef, useState } from "react"; +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 { @@ -28,14 +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; -export function useBoard(source: string, streaming: boolean, onProblems?: (problems: Problem[]) => void): BoardState { - const [state, setState] = useState({ board: null, problems: [], pending: true }); +/** `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, text: null }); const seq = useRef(0); const shown = useRef(0); const lastStart = useRef(0); @@ -60,18 +68,18 @@ 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); 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,17 +87,25 @@ 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; + // 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 8202db8..2d64dc7 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,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, behind }); + props.current = { onElementClick, onElementHover, behind }; useEffect(() => { viewer.current = createViewer({ @@ -58,6 +82,13 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru zoomIn: zoomIn.current, zoomOut: zoomOut.current, zoomFit: zoomFit.current, + }, { + // 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); + }, }); return () => { viewer.current?.destroy(); // its listeners, the resize watch, the board image @@ -65,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); @@ -73,6 +103,32 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru if (img) img.alt = board.title ? `Wireframe: ${board.title}` : "Wireframe"; }, [board]); + useEffect(() => { + viewer.current?.setElements(elements); + 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]); + + // 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 outlinedOn = useRef(null); + useEffect(() => { + if (behind) { + if (elements !== outlinedOn.current || !lines) 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; useEffect(() => { viewer.current?.setStale(visible && !!board); }, [visible, board]); diff --git a/test/react.test.tsx b/test/react.test.tsx index f72fa8f..f8509dd 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,229 @@ 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"); + + // 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(); +}); + +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("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("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("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"); + 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`; @@ -391,6 +615,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; syncSelection={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, 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 + 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 }));