From 358bb431f7ce187759831eb410ebcd63d7292ebb Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Tue, 20 Oct 2026 12:20:56 -0400 Subject: [PATCH 1/4] WireframeEditor and WireframePlayground; the viewer stops on unmount - : tsquare/editor in React. value/defaultValue, onChange, onProblems (after a 300 ms pause), autocomplete, lineNumbers, ariaLabel; a ref gets getText, setText, focus, goToLine. EditorCore is the shared part. - : the editor and the viewer on one text, side by side (stacked under 640px via a container query, or layout="row"/"column"). While typing (until 400 ms without a change) the board redraws on the streaming throttle and problems wait; then they're underlined and counted in a note on the board. - The viewer calls tsquare's destroy() on unmount. - styles.css: the editor's colors from the --tsquare-* theme, the playground layout, --tsquare-editor-height and --tsquare-playground-height. - Docs: editor.md, playground.md; the overview table and styling.md. - Tests (20): editor text/ref/onChange, problems underlined after a pause and onProblems, controlled value and options, playground (board, problems held while typing then underlined and noted, layout, showProblems), viewer destroy on unmount; docs examples typecheck and props documented. - tsquare ^0.12.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ README.md | 4 +- docs/README.md | 2 + docs/editor.md | 70 ++++++++++++++++++++++ docs/playground.md | 58 +++++++++++++++++++ docs/styling.md | 15 +++++ package-lock.json | 8 +-- package.json | 2 +- src/editor.tsx | 137 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/playground.tsx | 80 ++++++++++++++++++++++++++ src/styles.css | 53 ++++++++++++++++- src/viewer.tsx | 5 +- test/docs.test.ts | 2 + test/react.test.tsx | 109 +++++++++++++++++++++++++++++++++-- 15 files changed, 541 insertions(+), 15 deletions(-) create mode 100644 docs/editor.md create mode 100644 docs/playground.md create mode 100644 src/editor.tsx create mode 100644 src/playground.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a56da..1535a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- **``:** tsquare's code editor (the playground's): highlighting, autocomplete, problems underlined about 300 ms after typing stops, Enter keeping the indentation, a whole pasted model reply trimmed to its code block. `value`/`defaultValue`, `onChange`, `onProblems`, `autocomplete`, `lineNumbers`, `ariaLabel`; a `ref` gets `getText`, `setText`, `focus` and `goToLine`. Built on `tsquare/editor` (tsquare 0.12), with CodeMirror bundled inside it. +- **``:** the editor and the viewer side by side on one text (stacked below 640px, or `layout="row"`/`"column"`). 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 board. +- **The viewer stops when it unmounts** (tsquare's new `destroy()`): no listeners, resize watching or board image left behind. +- **Styles** for the editor (its colors from the `--tsquare-*` theme; highlighting from `--tsquare-syntax-*`) and the playground; `--tsquare-editor-height`, `--tsquare-playground-height`. +- **Docs:** Editor and Playground pages; the overview's table and Styling cover them. +- Needs tsquare 0.12.0 or later. + ## 0.2.0 - **``:** the board to explore: zoom (buttons, pinch, Ctrl/Cmd + scroll), pan, double-click to fit, and a chip per screen that zooms to it. `chips` and `zoomControls` turn each part off. diff --git a/README.md b/README.md index a2687ae..13a4a95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @tsquare-js/react -React components for [tsquare](https://tsquare.dev) wireframes: show a board from wireframe text, let people zoom and pan it, and render the ```` ```tsquare ```` blocks in a model's replies, even while they're still streaming in. Boards are drawn in the browser; nothing is sent anywhere. +React components for [tsquare](https://tsquare.dev) wireframes: show a board from wireframe text, let people zoom and pan it, edit it in a playground, and render the ```` ```tsquare ```` blocks in a model's replies, even while they're still streaming in. Boards are drawn in the browser; nothing is sent anywhere. ```bash npm install @tsquare-js/react @@ -28,6 +28,8 @@ export function Reply({ text, streaming }: { text: string; streaming: boolean }) - [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 +- [WireframeEditor](docs/editor.md): write and edit wireframe text, with autocomplete and problems underlined +- [WireframePlayground](docs/playground.md): the editor and the board side by side - [Markdown and chat](docs/chat.md): react-markdown, streaming replies - [Problems](docs/problems.md): showing them, or sending them back to the model - [Styling](docs/styling.md): the stylesheet, your colors, light and dark, class names diff --git a/docs/README.md b/docs/README.md index e2687fa..1d2d5dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,8 @@ 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 write or edit wireframe text | [``](editor.md) | +| An editor and the board side by side | [``](playground.md) | | Show the ```` ```tsquare ```` blocks in Markdown you render with react-markdown, such as a model's replies in a chat | [`tsquareComponents`](chat.md) | | Show a reply while it's still streaming in | [``](chat.md#while-a-reply-streams-in) | | Handle invalid wireframes yourself, for example by sending the problems back to the model | [`onProblems`](problems.md) | diff --git a/docs/editor.md b/docs/editor.md new file mode 100644 index 0000000..269b246 --- /dev/null +++ b/docs/editor.md @@ -0,0 +1,70 @@ +# WireframeEditor + +`` is tsquare's code editor for writing wireframes: the one in the [playground](https://tsquare.dev/playground). It highlights the language, autocompletes components, props, option words, icons and accents, and underlines problems on their lines. Use it where people edit wireframe text, for example to adjust what a model wrote; for the editor and the board side by side, use the [playground](playground.md). + +```tsx +import { useState } from "react"; +import { WireframeEditor } from "@tsquare-js/react"; + +export function EditSketch({ initial }: { initial: string }) { + const [text, setText] = useState(initial); + return ; +} +``` + +The editor fills its box, so give it a height: with the stylesheet it's 20rem unless you set one (or `--tsquare-editor-height`). + +## Props + +| Prop | Default | | +|---|---|---| +| `value` | | The text, when your app keeps it in state. Pass `onChange` too. | +| `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). | +| `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. | +| `ariaLabel` | `"Wireframe text"` | The editable area's accessible name. | +| `className` | | Added to the root, next to `tsquare-editor`. | +| `style` | | On the root. | + +`autocomplete`, `lineNumbers` and `ariaLabel` are read when the editor is created; give the component a new `key` to change them later. + +## What it does as you type + +- **Problems:** about 300 ms after you stop typing, the text is checked, and each problem is underlined on its line, with its message when you hover the line or its gutter marker. Nothing flashes while you're mid-word. +- **Enter** keeps the indentation of the line above, so nesting stays easy. +- **Pasting a whole model reply** keeps just its ```` ```tsquare ```` block. +- **Undo, search** (Cmd/Ctrl+F) and the usual editing keys work as in any code editor. + +## Control it from your code + +A `ref` gets the editor's methods, for a "Load example" button or jumping to a problem: + +```tsx +import { useRef } from "react"; +import { WireframeEditor, type WireframeEditorHandle } from "@tsquare-js/react"; + +export function EditorWithExample({ example }: { example: string }) { + const editor = useRef(null); + return ( + <> + + + + ); +} +``` + +| Method | | +|---|---| +| `getText()` | The current text. | +| `setText(text)` | Replace everything, as one step undo reverts; `onChange` reports it. | +| `focus()` | Put the cursor in the editor. | +| `goToLine(n)` | Select line `n`, scroll to it and focus the editor. | + +## Bundle size + +The editor is built on CodeMirror, bundled inside tsquare (about 125 KB compressed after minifying, on top of the renderer). 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()`). + +Colors and fonts follow the [stylesheet's variables](styling.md); the highlighting has its own (`--tsquare-syntax-*`). diff --git a/docs/playground.md b/docs/playground.md new file mode 100644 index 0000000..c553589 --- /dev/null +++ b/docs/playground.md @@ -0,0 +1,58 @@ +# WireframePlayground + +`` puts the [editor](editor.md) and the [viewer](viewer.md) side by side on one text: type on the left, see the board on the right. It's the tsquare.dev playground's core, for your own app, without the site's menus. + +```tsx +import { WireframePlayground } from "@tsquare-js/react"; + +const START = `board "Sign in" + screen phone + stack gap=12 padding=24 + text lg bold "Welcome back" + input "Email" + button primary "Sign in" fullWidth`; + +export function SketchPad() { + return ; +} +``` + +With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-height`). The editor takes two fifths of the width and the board three. + +## Props + +| Prop | Default | | +|---|---|---| +| `value` | | The text, when your app keeps it in state. Pass `onChange` too. | +| `defaultValue` | | The starting text, when the playground 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. | +| `layout` | `"auto"` | `"row"`: editor left, board right. `"column"`: editor above the board. `"auto"`: a row when the playground is at least 640px wide, a column below that. | +| `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. | +| `autocomplete` | `true` | Autocomplete in the editor. | +| `lineNumbers` | `true` | Line numbers in the editor. | +| `className` | | Added to the root, next to `tsquare-playground`. | +| `style` | | On the root. | + +A `ref` gets the editor's methods (`getText`, `setText`, `focus`, `goToLine`), as on [WireframeEditor](editor.md#control-it-from-your-code). + +## While someone types + +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. + +## Saving the text + +Keep the text in your state to save it, share it or load another: + +```tsx +import { useEffect, useState } from "react"; +import { WireframePlayground } from "@tsquare-js/react"; + +export function SavedSketch({ id, initial }: { id: string; initial: string }) { + const [text, setText] = useState(() => localStorage.getItem(`sketch:${id}`) ?? initial); + useEffect(() => { localStorage.setItem(`sketch:${id}`, text); }, [id, text]); + return ; +} +``` diff --git a/docs/styling.md b/docs/styling.md index d65611c..a56b572 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -28,6 +28,8 @@ The stylesheet reads these CSS variables. Set any of them, on `:root` or on a co | `--tsquare-font-mono` | problem lines | `ui-monospace, Menlo, …` | | `--tsquare-radius` | corners | `6px` | | `--tsquare-viewer-height` | ``'s height | `32rem` | +| `--tsquare-editor-height` | ``'s height | `20rem` | +| `--tsquare-playground-height` | ``'s height | `36rem` | Point them at your own design tokens: @@ -49,6 +51,17 @@ The components follow your page's color scheme: A page that changes its colors only with `@media (prefers-color-scheme: dark)` and never declares a color scheme gets the light components. Add `:root { color-scheme: light dark; }` to fix that. +## The editor's highlighting + +The editor takes its background, text, gutter, popups, error color and fonts from the variables above. Its syntax colors have their own, each with a light and a dark default: `--tsquare-syntax-component`, `--tsquare-syntax-string`, `--tsquare-syntax-prop`, `--tsquare-syntax-number`, `--tsquare-syntax-word`, `--tsquare-syntax-color`, `--tsquare-syntax-comment` and `--tsquare-syntax-punct`. The active line and selection are `--tsquare-editor-active-line` and `--tsquare-editor-selection`. + +```css +:root { + --tsquare-syntax-component: var(--brand-700); + --tsquare-syntax-string: #0a7a42; +} +``` + For different dark colors, set the variables inside your dark selector: ```css @@ -74,6 +87,8 @@ Every part has a class, so you can restyle it from your CSS. The stylesheet's ru | `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-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 | For example, a quieter problem list and square chips: diff --git a/package-lock.json b/package-lock.json index 66dfc5f..a600d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "tsquare": "^0.11.0" + "tsquare": "^0.12.0" }, "devDependencies": { "@types/jsdom": "^30.0.0", @@ -2740,9 +2740,9 @@ } }, "node_modules/tsquare": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.11.0.tgz", - "integrity": "sha512-1bizICOCe50Zm2B3pKUG8zoRWdNVvhLQlThMbzU4GvG0SRQ0KF4tAiVrvVxwsF5ZHmRNLWotDL3/eKyycbRzAA==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.12.0.tgz", + "integrity": "sha512-kruBBkM4qumewhKKyZYuGsyBNgwb3TOQw42Pv/Mvxmln+WM2FAER/H+IRW5M1SlH7f+JOT4tSyfChpWEe5bggA==", "license": "MIT", "dependencies": { "@fontsource/inter": "5.3.0", diff --git a/package.json b/package.json index 68190e1..98321e1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "prepare": "npm run build" }, "dependencies": { - "tsquare": "^0.11.0" + "tsquare": "^0.12.0" }, "peerDependencies": { "react": ">=18" diff --git a/src/editor.tsx b/src/editor.tsx new file mode 100644 index 0000000..930727c --- /dev/null +++ b/src/editor.tsx @@ -0,0 +1,137 @@ +/** + * : tsquare's code editor (tsquare/editor, the playground's) in React. Highlighting, + * autocomplete, problems underlined on their lines as you type. + */ +import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; +import { compileWireframe, languageData } from "tsquare"; +import { createEditor, type WireframeEditor as EditorInstance } from "tsquare/editor"; +import type { Problem } from "./use-board.js"; + +export interface WireframeEditorProps { + /** The text, when the app keeps it in state (with onChange). */ + value?: string; + /** The starting text, when the editor keeps it. */ + defaultValue?: string; + /** Called with the whole text after every change. */ + 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; + /** Autocomplete for components, props, option words, icons and accents. Default true. */ + autocomplete?: boolean; + /** Line numbers. Default true. */ + lineNumbers?: boolean; + /** The editable area's accessible name. Default "Wireframe text". */ + ariaLabel?: string; + className?: string; + style?: CSSProperties; +} + +/** What a ref to the editor (or the playground) can do. */ +export interface WireframeEditorHandle { + getText(): string; + /** Replace the whole text, as one step that undo reverts (and onChange reports). */ + setText(text: string): void; + focus(): void; + /** Select a line, scroll to it and focus the editor. */ + goToLine(line: number): void; +} + +const CHECK_DELAY = 300; // problems wait for a pause in typing + +export const WireframeEditor = forwardRef(function WireframeEditor( + { onChange, onProblems, ...props }, + ref, +) { + const [problems, setProblems] = useProblems(props.value ?? props.defaultValue ?? "", onProblems); + return ( + { + setProblems(text); + onChange?.(text); + }} + /> + ); +}); + +/** Checks the text after a pause in typing; returns the problems to underline and a function that takes new text. */ +function useProblems(initial: string, onProblems?: (problems: Problem[]) => void) { + const [problems, setProblems] = useState(() => issuesOf(initial)); + const timer = useRef>(undefined); + const reported = useRef("[]"); // onProblems fires only on a change, and not for valid text that was always valid + const callback = useRef(onProblems); + callback.current = onProblems; + const report = (p: Problem[]) => { + const key = JSON.stringify(p); + if (key !== reported.current) { reported.current = key; callback.current?.(p); } + }; + useEffect(() => { + report(problems); // the starting text's + return () => clearTimeout(timer.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const check = (text: string) => { + clearTimeout(timer.current); + timer.current = setTimeout(() => { + const p = issuesOf(text); + setProblems(p); + report(p); + }, CHECK_DELAY); + }; + return [problems, check] as const; +} + +const issuesOf = (text: string): Problem[] => compileWireframe(text).issues.map((i) => ({ line: i.line, message: i.message })); + +export interface EditorCoreProps extends Omit { + /** Problems to underline; undefined keeps the current ones (while the playground waits for typing to pause). */ + issues?: Problem[]; +} + +/** The editor itself: creates tsquare/editor in a div, keeps it in sync with `value`, underlines `issues`. */ +export const EditorCore = forwardRef(function EditorCore( + { value, defaultValue, onChange, issues, autocomplete = true, lineNumbers = true, ariaLabel, className, style }, + ref, +) { + const host = useRef(null); + const editor = useRef(null); + const change = useRef(onChange); + change.current = onChange; + + useEffect(() => { + const instance = createEditor(host.current!, { + doc: value ?? defaultValue ?? "", + data: autocomplete ? languageData() : undefined, + lineNumbers, + ariaLabel, + onChange: (text) => change.current?.(text), + }); + editor.current = instance; + return () => { + instance.destroy(); + editor.current = null; + }; + // autocomplete, lineNumbers and ariaLabel are set when the editor is made (remount to change them) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // A new `value` from the app replaces the text, unless it's what the editor already has (its own change coming back) + useEffect(() => { + if (value !== undefined && editor.current && value !== editor.current.getText()) editor.current.setText(value); + }, [value]); + + useEffect(() => { + if (issues) editor.current?.setIssues(issues); + }, [issues]); + + useImperativeHandle(ref, () => ({ + getText: () => editor.current?.getText() ?? "", + setText: (text) => editor.current?.setText(text), + focus: () => editor.current?.focus(), + goToLine: (line) => editor.current?.goToLine(line), + }), []); + + return
; +}); diff --git a/src/index.ts b/src/index.ts index 6753fc2..1002ef7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ */ export { Wireframe, type WireframeProps } from "./wireframe.js"; export { WireframeViewer, type WireframeViewerProps } from "./viewer.js"; +export { WireframeEditor, type WireframeEditorProps, type WireframeEditorHandle } from "./editor.js"; +export { WireframePlayground, type WireframePlaygroundProps } from "./playground.js"; export { WireframeStreaming } from "./streaming.js"; export { tsquareComponents, createTsquareComponents, TsquarePre, type TsquareComponentsOptions } from "./markdown.js"; export type { Problem } from "./use-board.js"; diff --git a/src/playground.tsx b/src/playground.tsx new file mode 100644 index 0000000..a21cc25 --- /dev/null +++ b/src/playground.tsx @@ -0,0 +1,80 @@ +/** + * : 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. + */ +import { forwardRef, useEffect, 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"; + +export interface WireframePlaygroundProps { + /** The text, when the app keeps it in state (with onChange). */ + value?: string; + /** The starting text, when the playground keeps it. */ + defaultValue?: string; + /** Called with the whole text after every change. */ + 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; + /** "row": editor left, board right. "column": editor above the board. "auto" (default): row when the playground is at least 640px wide, column below that. */ + layout?: "auto" | "row" | "column"; + /** The note on the board counting the problems ("2 problems · showing the last valid board"). Default true; the editor underlines them either way. */ + showProblems?: boolean; + /** A chip per screen above the board. Default true. */ + chips?: boolean; + /** Zoom out, zoom level and zoom in above the board. Default true. */ + zoomControls?: boolean; + /** Autocomplete in the editor. Default true. */ + autocomplete?: boolean; + /** Line numbers in the editor. Default true. */ + lineNumbers?: boolean; + className?: string; + style?: CSSProperties; +} + +const TYPING_PAUSE = 400; + +export const WireframePlayground = forwardRef(function WireframePlayground( + { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, autocomplete, lineNumbers, className, style }, + ref, +) { + const [inner, setInner] = useState(defaultValue ?? ""); + const text = value ?? inner; + const typing = useTyping(text); + const state = useBoard(text, typing, onProblems); + // 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); + }} + issues={issues} + autocomplete={autocomplete} + lineNumbers={lineNumbers} + /> + +
+
+ ); +}); + +/** True from a change in the text until TYPING_PAUSE ms without one (not for the starting text). */ +function useTyping(text: string) { + const [typing, setTyping] = useState(false); + const first = useRef(true); + useEffect(() => { + if (first.current) { first.current = false; return; } + setTyping(true); + const timer = setTimeout(() => setTyping(false), TYPING_PAUSE); + return () => clearTimeout(timer); + }, [text]); + return typing; +} diff --git a/src/styles.css b/src/styles.css index 9242473..abc4899 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4,7 +4,10 @@ * Theme: set any of these variables (on :root, or on a component) to match your app: * --tsquare-bg --tsquare-fg --tsquare-muted --tsquare-border --tsquare-stage * --tsquare-accent --tsquare-accent-fg --tsquare-error - * --tsquare-font --tsquare-font-mono --tsquare-radius --tsquare-viewer-height + * --tsquare-font --tsquare-font-mono --tsquare-radius + * --tsquare-viewer-height --tsquare-editor-height --tsquare-playground-height + * The editor's highlighting: --tsquare-syntax-component, -string, -prop, -number, -word, -color, + * -comment, -punct (see tsquare/editor in the tsquare README). * * Light or dark follows the page's color-scheme (`color-scheme: dark` on , or `light dark` to * follow the system), and a `dark`/`light` class or data-theme="dark"/"light" on an ancestor. @@ -13,7 +16,7 @@ */ /* Defaults, behind the public variables */ -:where(.tsquare-board, .tsquare-viewer, .tsquare-dialog) { +:where(.tsquare-board, .tsquare-viewer, .tsquare-dialog, .tsquare-editor, .tsquare-playground) { --_tsq-bg: var(--tsquare-bg, light-dark(#ffffff, #1b1d21)); --_tsq-fg: var(--tsquare-fg, light-dark(#1f2328, #e6e8eb)); --_tsq-muted: var(--tsquare-muted, light-dark(#656d76, #9aa1a9)); @@ -30,7 +33,7 @@ (a dark section inside a light page). Unset, color-scheme falls back to inheriting the page's. */ :where(.dark, [data-theme="dark"]) { --_tsq-scheme: dark; } :where(.light, [data-theme="light"]) { --_tsq-scheme: light; } -:where(.tsquare-board, .tsquare-viewer, .tsquare-dialog) { color-scheme: var(--_tsq-scheme); } +:where(.tsquare-board, .tsquare-viewer, .tsquare-dialog, .tsquare-editor, .tsquare-playground) { color-scheme: var(--_tsq-scheme); } /* : the board as an image */ :where(.tsquare-board) { position: relative; margin: 1em 0; color: var(--_tsq-fg); font-family: var(--_tsq-font); } @@ -167,3 +170,47 @@ cursor: pointer; } :where(.tsquare-dialog) :where(.tsquare-viewer) { flex: 1; height: auto; border: 0; border-top: 1px solid var(--_tsq-border); border-radius: 0; } + +/* : tsquare/editor, its colors from the theme above (the highlighting keeps the + editor's own light/dark defaults unless you set --tsquare-syntax-*) */ +:where(.tsquare-editor) { + --tsquare-editor-bg: var(--_tsq-bg); + --tsquare-editor-fg: var(--_tsq-fg); + --tsquare-editor-gutter: var(--_tsq-muted); + --tsquare-editor-error: var(--_tsq-error); + --tsquare-editor-popup-bg: var(--_tsq-bg); + --tsquare-editor-popup-border: var(--_tsq-border); + --tsquare-editor-font: var(--_tsq-font-mono); + --tsquare-editor-ui-font: var(--_tsq-font); + height: var(--tsquare-editor-height, 20rem); + border: 1px solid var(--_tsq-border); + border-radius: var(--_tsq-radius); + overflow: hidden; +} +:where(.tsquare-editor) > :where(.cm-editor) { height: 100%; } +:where(.tsquare-editor) :where(.cm-editor.cm-focused) { outline: none; } + +/* : editor and viewer, side by side when there's room */ +:where(.tsquare-playground) { + height: var(--tsquare-playground-height, 36rem); + border: 1px solid var(--_tsq-border); + border-radius: var(--_tsq-radius); + overflow: hidden; + container-type: inline-size; + background: var(--_tsq-bg); + color: var(--_tsq-fg); + font-family: var(--_tsq-font); +} +:where(.tsquare-playground-panes) { display: grid; height: 100%; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); } +@container (max-width: 639px) { + :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); } +} +:where(.tsquare-playground[data-layout="row"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); grid-template-rows: none; } +:where(.tsquare-playground[data-layout="column"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); } +:where(.tsquare-playground) :where(.tsquare-editor, .tsquare-viewer) { height: 100%; min-height: 0; border: 0; border-radius: 0; } +:where(.tsquare-playground) :where(.tsquare-viewer) { border-left: 1px solid var(--_tsq-border); } +@container (max-width: 639px) { + :where(.tsquare-playground) :where(.tsquare-viewer) { border-left: 0; border-top: 1px solid var(--_tsq-border); } +} +:where(.tsquare-playground[data-layout="row"]) :where(.tsquare-viewer) { border-left: 1px solid var(--_tsq-border); border-top: 0; } +:where(.tsquare-playground[data-layout="column"]) :where(.tsquare-viewer) { border-left: 0; border-top: 1px solid var(--_tsq-border); } diff --git a/src/viewer.tsx b/src/viewer.tsx index 843c41c..8202db8 100644 --- a/src/viewer.tsx +++ b/src/viewer.tsx @@ -59,7 +59,10 @@ export function BoardViewer({ state, streaming, chips = true, zoomControls = tru zoomOut: zoomOut.current, zoomFit: zoomFit.current, }); - return () => { viewer.current = null; }; + return () => { + viewer.current?.destroy(); // its listeners, the resize watch, the board image + viewer.current = null; + }; }, []); const { board, problems } = state; diff --git a/test/docs.test.ts b/test/docs.test.ts index 720a0e3..d1e30dd 100644 --- a/test/docs.test.ts +++ b/test/docs.test.ts @@ -50,6 +50,8 @@ test("every prop and option is documented on its page", () => { ["wireframe.tsx", "WireframeProps", "wireframe.md"], ["viewer.tsx", "WireframeViewerProps", "viewer.md"], ["markdown.tsx", "TsquareComponentsOptions", "chat.md"], + ["editor.tsx", "WireframeEditorProps", "editor.md"], + ["playground.tsx", "WireframePlaygroundProps", "playground.md"], ]; for (const [file, iface, page] of cases) { const names = props(src(file), iface); diff --git a/test/react.test.tsx b/test/react.test.tsx index 7fd29b5..3953c58 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -1,23 +1,33 @@ /** * The components in jsdom with React's client renderer and react-markdown: boards, problems and - * onProblems, streaming, the viewer (chips, zoom), the expanded view, and the stylesheet. jsdom has - * no ResizeObserver, setPointerCapture or methods, so they're stubbed. + * onProblems, streaming, the viewer (chips, zoom), the expanded view, the editor and playground, and + * the stylesheet. jsdom has no ResizeObserver, setPointerCapture, methods or ranges with + * layout (CodeMirror), so they're stubbed. */ import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; -const dom = new JSDOM(""); +const dom = new JSDOM("", { pretendToBeVisual: true }); // requestAnimationFrame, for CodeMirror +let disconnected = 0; // ResizeObservers stopped (the viewer's destroy()) before(() => { Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); // Node 21+ has its own navigator (read-only); Node 20 has none, and React DOM reads it if (typeof navigator === "undefined") Object.assign(globalThis, { navigator: dom.window.navigator }); Object.assign(globalThis, { - ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, + ResizeObserver: class { observe() {} unobserve() {} disconnect() { disconnected++; } }, + // CodeMirror (the editor) + MutationObserver: dom.window.MutationObserver, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + getSelection: dom.window.getSelection.bind(dom.window), + Window: dom.window.Window, getComputedStyle: dom.window.getComputedStyle.bind(dom.window), // the viewer checks the stage's position + AbortController: dom.window.AbortController, // the viewer's listeners: jsdom only takes its own AbortSignal }); dom.window.HTMLElement.prototype.setPointerCapture = () => {}; + (dom.window.document as any).createRange = () => ({ setStart() {}, setEnd() {}, getClientRects: () => [], getBoundingClientRect: () => ({ left: 0, top: 0, right: 0, bottom: 0 }) }); const D = dom.window.HTMLDialogElement.prototype as any; D.showModal ??= function (this: HTMLDialogElement) { this.setAttribute("open", ""); }; D.close ??= function (this: HTMLDialogElement) { this.removeAttribute("open"); this.dispatchEvent(new dom.window.Event("close")); }; @@ -257,7 +267,7 @@ test("WireframeStreaming sets streaming for the boards inside (a reply still arr test("styles.css: every class the components use is styled, every rule is low-priority (:where), dark and light hooks", () => { const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); - const sources = ["wireframe", "viewer", "problems"].map((f) => readFileSync(new URL(`../src/${f}.tsx`, import.meta.url), "utf8")).join("\n"); + const sources = ["wireframe", "viewer", "problems", "editor", "playground"].map((f) => readFileSync(new URL(`../src/${f}.tsx`, import.meta.url), "utf8")).join("\n"); const used = new Set([...sources.matchAll(/\btsquare-[a-z-]+/g)].map((m) => m[0])); for (const cls of used) assert.ok(css.includes(`.${cls}`), `.${cls} has styles`); const rules = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/[^{}]+(?=\{)/g)!.map((s) => s.trim()).filter((s) => s && !s.startsWith("@")); @@ -266,3 +276,92 @@ test("styles.css: every class the components use is styled, every rule is low-pr assert.match(css, /:where\(\.light, \[data-theme="light"\]\)/); for (const v of ["bg", "fg", "muted", "border", "stage", "accent", "accent-fg", "error", "font", "font-mono", "radius"]) assert.ok(css.includes(`var(--tsquare-${v},`), `--tsquare-${v}`); }); + +test("WireframeEditor: the text with its accessible name; ref.setText reports through onChange", async () => { + const { h, act, lib } = await load(); + const React = await import("react"); + const ref = React.createRef(); + const changes: string[] = []; + const view = await mount(h(lib.WireframeEditor, { ref, defaultValue: SIGN_IN, onChange: (t: string) => changes.push(t), className: "mine" })); + assert.equal(view.$(".tsquare-editor")!.className, "tsquare-editor mine"); + assert.equal(view.$(".cm-content")!.getAttribute("aria-label"), "Wireframe text"); + assert.ok(view.$(".cm-lineNumbers"), "line numbers by default"); + assert.equal(ref.current!.getText(), SIGN_IN); + await act(async () => ref.current!.setText("board\n screen tablet")); + assert.deepEqual(changes, ["board\n screen tablet"]); + assert.equal(ref.current!.getText(), "board\n screen tablet"); + await view.unmount(); + assert.equal(view.container.querySelector(".cm-editor"), null, "unmounting removes the editor"); +}); + +test("WireframeEditor: problems underlined after a pause in typing, onProblems with the list, [] once fixed", async () => { + const { h, act, lib } = await load(); + const React = await import("react"); + const ref = React.createRef(); + const calls: unknown[] = []; + const view = await mount(h(lib.WireframeEditor, { ref, defaultValue: SIGN_IN, onProblems: (p: unknown) => calls.push(p) })); + await settle(400); + assert.deepEqual(calls, [], "valid starting text: no call"); + + await act(async () => ref.current!.setText(BROKEN)); + assert.equal(view.$(".cm-lintRange-error"), null, "not right away (mid-word, it would flash)"); + await until(() => !!view.$(".cm-lintRange-error"), "the underline"); + assert.equal(view.$(".cm-lintRange-error")!.textContent, 'buton "Oops"'); + assert.equal(calls.length, 1); + assert.equal((calls[0] as { line: number }[])[0].line, 3); + + await act(async () => ref.current!.setText(SIGN_IN)); + await until(() => calls.length === 2, "the all-clear"); + assert.deepEqual(calls[1], []); + assert.equal(view.$(".cm-lintRange-error"), null); + await view.unmount(); +}); + +test("WireframeEditor: a controlled value replaces the text; autocomplete and lineNumbers turn off", async () => { + const { h, lib } = await load(); + const view = await mount(h(lib.WireframeEditor, { value: "board", onChange: () => {}, lineNumbers: false, autocomplete: false, ariaLabel: "Sketch" })); + assert.equal(view.$(".cm-lineNumbers"), null); + assert.equal(view.$(".cm-content")!.getAttribute("aria-label"), "Sketch"); + await view.rerender(h(lib.WireframeEditor, { value: "board\n screen phone", onChange: () => {}, lineNumbers: false, autocomplete: false })); + assert.equal(view.$(".cm-content")!.textContent, "board screen phone"); + await view.unmount(); +}); + +test("WireframePlayground: editor and viewer on one text; problems wait for a pause, then underlined and noted", async () => { + const { h, act, lib } = await load(); + const React = await import("react"); + const ref = React.createRef(); + const calls: unknown[] = []; + const view = await mount(h(lib.WireframePlayground, { ref, defaultValue: FLOW, onProblems: (p: unknown) => calls.push(p) })); + assert.equal(view.$(".tsquare-playground")!.getAttribute("data-layout"), "auto"); + assert.ok(view.$(".tsquare-playground-panes > .tsquare-editor")); + assert.ok(view.$(".tsquare-playground-panes > .tsquare-viewer")); + await until(() => !!view.$(".tsquare-canvas img"), "the board"); + assert.equal(view.$$(".tsquare-chips button").length, 4); + + await act(async () => ref.current!.setText(FLOW + "\n buton")); + await settle(150); + assert.equal(view.$(".tsquare-note"), null, "no note while typing"); + assert.equal(view.$(".cm-lintRange-error"), null, "no underline while typing"); + await until(() => !!view.$(".tsquare-note"), "the note after a pause"); + assert.match(view.$(".tsquare-note")!.textContent!, /^1 problem · showing the last valid board$/); + await until(() => !!view.$(".cm-lintRange-error"), "the underline"); + assert.ok(view.$(".tsquare-stage")!.classList.contains("stale"), "the last good board, dimmed"); + await until(() => calls.length === 1, "onProblems, once the final text is checked"); + assert.match((calls[0] as { message: string }[])[0].message, /unknown component "buton"/); + + await view.rerender(h(lib.WireframePlayground, { ref, defaultValue: FLOW, layout: "column", showProblems: false })); + assert.equal(view.$(".tsquare-playground")!.getAttribute("data-layout"), "column"); + assert.equal(view.$(".tsquare-note"), null, "showProblems={false}: no note"); + assert.ok(view.$(".cm-lintRange-error"), "the editor still underlines"); + 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 })); + await until(() => !!view.$(".tsquare-canvas img"), "the board"); + const before = disconnected; + await view.unmount(); + assert.equal(disconnected, before + 1); +}); From b4805ec84b93b6609031fa7b43258d6966cccd10 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Tue, 20 Oct 2026 12:25:56 -0400 Subject: [PATCH 2/4] Playground: stack below 900px, editor and board half and half At 760px wide (a typical docs or app column) the editor got 303px while the example's lines needed 746px; most of each line was hidden behind sideways scrolling. Side by side now starts at 900px, and the two panes share the space evenly. Wrapping long lines (tsquare 0.12.1) comes next. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/playground.md | 4 ++-- src/playground.tsx | 2 +- src/styles.css | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1535a30..6bfc2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased - **``:** tsquare's code editor (the playground's): highlighting, autocomplete, problems underlined about 300 ms after typing stops, Enter keeping the indentation, a whole pasted model reply trimmed to its code block. `value`/`defaultValue`, `onChange`, `onProblems`, `autocomplete`, `lineNumbers`, `ariaLabel`; a `ref` gets `getText`, `setText`, `focus` and `goToLine`. Built on `tsquare/editor` (tsquare 0.12), with CodeMirror bundled inside it. -- **``:** the editor and the viewer side by side on one text (stacked below 640px, or `layout="row"`/`"column"`). 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 board. +- **``:** the editor and the viewer side by side on one text, half and half (stacked below 900px, or `layout="row"`/`"column"`). 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 board. - **The viewer stops when it unmounts** (tsquare's new `destroy()`): no listeners, resize watching or board image left behind. - **Styles** for the editor (its colors from the `--tsquare-*` theme; highlighting from `--tsquare-syntax-*`) and the playground; `--tsquare-editor-height`, `--tsquare-playground-height`. - **Docs:** Editor and Playground pages; the overview's table and Styling cover them. diff --git a/docs/playground.md b/docs/playground.md index c553589..3fe2a47 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -17,7 +17,7 @@ export function SketchPad() { } ``` -With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-height`). The editor takes two fifths of the width and the board three. +With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-height`). The editor and the board share it evenly, side by side or stacked. ## Props @@ -27,7 +27,7 @@ With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-h | `defaultValue` | | The starting text, when the playground 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. | -| `layout` | `"auto"` | `"row"`: editor left, board right. `"column"`: editor above the board. `"auto"`: a row when the playground is at least 640px wide, a column below that. | +| `layout` | `"auto"` | `"row"`: editor left, board right. `"column"`: editor above the board. `"auto"`: a row when the playground is at least 900px wide, a column below that (narrower, side by side would leave the editor too little room for its lines). | | `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. | diff --git a/src/playground.tsx b/src/playground.tsx index a21cc25..cbba9f0 100644 --- a/src/playground.tsx +++ b/src/playground.tsx @@ -17,7 +17,7 @@ export interface WireframePlaygroundProps { 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; - /** "row": editor left, board right. "column": editor above the board. "auto" (default): row when the playground is at least 640px wide, column below that. */ + /** "row": editor left, board right. "column": editor above the board. "auto" (default): row when the playground is at least 900px wide, column below that. */ layout?: "auto" | "row" | "column"; /** The note on the board counting the problems ("2 problems · showing the last valid board"). Default true; the editor underlines them either way. */ showProblems?: boolean; diff --git a/src/styles.css b/src/styles.css index abc4899..f6a2d25 100644 --- a/src/styles.css +++ b/src/styles.css @@ -201,15 +201,15 @@ color: var(--_tsq-fg); font-family: var(--_tsq-font); } -:where(.tsquare-playground-panes) { display: grid; height: 100%; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); } -@container (max-width: 639px) { - :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); } +:where(.tsquare-playground-panes) { display: grid; height: 100%; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +@container (max-width: 899px) { + :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } } -:where(.tsquare-playground[data-layout="row"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); grid-template-rows: none; } -:where(.tsquare-playground[data-layout="column"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 2fr) minmax(0, 3fr); } +:where(.tsquare-playground[data-layout="row"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: none; } +:where(.tsquare-playground[data-layout="column"]) :where(.tsquare-playground-panes) { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } :where(.tsquare-playground) :where(.tsquare-editor, .tsquare-viewer) { height: 100%; min-height: 0; border: 0; border-radius: 0; } :where(.tsquare-playground) :where(.tsquare-viewer) { border-left: 1px solid var(--_tsq-border); } -@container (max-width: 639px) { +@container (max-width: 899px) { :where(.tsquare-playground) :where(.tsquare-viewer) { border-left: 0; border-top: 1px solid var(--_tsq-border); } } :where(.tsquare-playground[data-layout="row"]) :where(.tsquare-viewer) { border-left: 1px solid var(--_tsq-border); border-top: 0; } From 32fd7aa948e88f5873d194afeac6ce429b58b295 Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Tue, 20 Oct 2026 12:50:10 -0400 Subject: [PATCH 3/4] Editor and playground wrap long lines (tsquare 0.12.1) lineWrapping, default true, on WireframeEditor and WireframePlayground: long lines wrap instead of scrolling sideways, continuing 4 spaces past their indentation. tsquare ^0.12.1. Docs and CHANGELOG; a test for the default and for turning it off. Checked in Chrome: a 379px editor in a side-by-side playground wraps every line and doesn't scroll sideways. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- docs/editor.md | 3 ++- docs/playground.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- src/editor.tsx | 7 +++++-- src/playground.tsx | 5 ++++- test/react.test.tsx | 8 ++++++-- 8 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfc2e2..367d6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,12 @@ ## Unreleased -- **``:** tsquare's code editor (the playground's): highlighting, autocomplete, problems underlined about 300 ms after typing stops, Enter keeping the indentation, a whole pasted model reply trimmed to its code block. `value`/`defaultValue`, `onChange`, `onProblems`, `autocomplete`, `lineNumbers`, `ariaLabel`; a `ref` gets `getText`, `setText`, `focus` and `goToLine`. Built on `tsquare/editor` (tsquare 0.12), with CodeMirror bundled inside it. +- **``:** tsquare's code editor (the playground's): highlighting, autocomplete, problems underlined about 300 ms after typing stops, Enter keeping the indentation, a whole pasted model reply trimmed to its code block, long lines wrapped (continuing 4 spaces past their indentation). `value`/`defaultValue`, `onChange`, `onProblems`, `autocomplete`, `lineNumbers`, `lineWrapping`, `ariaLabel`; a `ref` gets `getText`, `setText`, `focus` and `goToLine`. Built on `tsquare/editor` (tsquare 0.12), with CodeMirror bundled inside it. - **``:** the editor and the viewer side by side on one text, half and half (stacked below 900px, or `layout="row"`/`"column"`). 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 board. - **The viewer stops when it unmounts** (tsquare's new `destroy()`): no listeners, resize watching or board image left behind. - **Styles** for the editor (its colors from the `--tsquare-*` theme; highlighting from `--tsquare-syntax-*`) and the playground; `--tsquare-editor-height`, `--tsquare-playground-height`. - **Docs:** Editor and Playground pages; the overview's table and Styling cover them. -- Needs tsquare 0.12.0 or later. +- Needs tsquare 0.12.1 or later. ## 0.2.0 diff --git a/docs/editor.md b/docs/editor.md index 269b246..a95a41e 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -24,11 +24,12 @@ The editor fills its box, so give it a height: with the stylesheet it's 20rem un | `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). | | `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. | | `ariaLabel` | `"Wireframe text"` | The editable area's accessible name. | | `className` | | Added to the root, next to `tsquare-editor`. | | `style` | | On the root. | -`autocomplete`, `lineNumbers` and `ariaLabel` are read when the editor is created; give the component a new `key` to change them later. +`autocomplete`, `lineNumbers`, `lineWrapping` and `ariaLabel` are read when the editor is created; give the component a new `key` to change them later. ## What it does as you type diff --git a/docs/playground.md b/docs/playground.md index 3fe2a47..4255a72 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -33,6 +33,7 @@ With the stylesheet it's 36rem tall (or set a height, or `--tsquare-playground-h | `zoomControls` | `true` | Zoom out, zoom level and zoom in above 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. | | `className` | | Added to the root, next to `tsquare-playground`. | | `style` | | On the root. | diff --git a/package-lock.json b/package-lock.json index a600d7e..4623b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "tsquare": "^0.12.0" + "tsquare": "^0.12.1" }, "devDependencies": { "@types/jsdom": "^30.0.0", @@ -2740,9 +2740,9 @@ } }, "node_modules/tsquare": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.12.0.tgz", - "integrity": "sha512-kruBBkM4qumewhKKyZYuGsyBNgwb3TOQw42Pv/Mvxmln+WM2FAER/H+IRW5M1SlH7f+JOT4tSyfChpWEe5bggA==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/tsquare/-/tsquare-0.12.1.tgz", + "integrity": "sha512-ZVSZaOCFLKQMXuP2njeRriUGoXzclrkS93kH0uqhFZR/ZMO+vCvmLok4XIAiB6tQa+6bpsIMyOxfpZC9iucWlg==", "license": "MIT", "dependencies": { "@fontsource/inter": "5.3.0", diff --git a/package.json b/package.json index 98321e1..b898446 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "prepare": "npm run build" }, "dependencies": { - "tsquare": "^0.12.0" + "tsquare": "^0.12.1" }, "peerDependencies": { "react": ">=18" diff --git a/src/editor.tsx b/src/editor.tsx index 930727c..148b67f 100644 --- a/src/editor.tsx +++ b/src/editor.tsx @@ -20,6 +20,8 @@ export interface WireframeEditorProps { autocomplete?: boolean; /** Line numbers. Default true. */ lineNumbers?: boolean; + /** Wrap long lines instead of scrolling sideways; a wrapped line continues 4 spaces past its indentation. Default true. */ + lineWrapping?: boolean; /** The editable area's accessible name. Default "Wireframe text". */ ariaLabel?: string; className?: string; @@ -92,7 +94,7 @@ export interface EditorCoreProps extends Omit(function EditorCore( - { value, defaultValue, onChange, issues, autocomplete = true, lineNumbers = true, ariaLabel, className, style }, + { value, defaultValue, onChange, issues, autocomplete = true, lineNumbers = true, lineWrapping = true, ariaLabel, className, style }, ref, ) { const host = useRef(null); @@ -105,6 +107,7 @@ export const EditorCore = forwardRef(fun doc: value ?? defaultValue ?? "", data: autocomplete ? languageData() : undefined, lineNumbers, + lineWrapping, ariaLabel, onChange: (text) => change.current?.(text), }); @@ -113,7 +116,7 @@ export const EditorCore = forwardRef(fun instance.destroy(); editor.current = null; }; - // autocomplete, lineNumbers and ariaLabel are set when the editor is made (remount to change them) + // autocomplete, lineNumbers, lineWrapping and ariaLabel are set when the editor is made (remount to change them) // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/playground.tsx b/src/playground.tsx index cbba9f0..886e8e6 100644 --- a/src/playground.tsx +++ b/src/playground.tsx @@ -29,6 +29,8 @@ export interface WireframePlaygroundProps { autocomplete?: boolean; /** Line numbers in the editor. Default true. */ lineNumbers?: boolean; + /** Wrap long lines in the editor instead of scrolling sideways. Default true. */ + lineWrapping?: boolean; className?: string; style?: CSSProperties; } @@ -36,7 +38,7 @@ 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, className, style }, + { value, defaultValue, onChange, onProblems, layout = "auto", showProblems = true, chips = true, zoomControls = true, autocomplete, lineNumbers, lineWrapping, className, style }, ref, ) { const [inner, setInner] = useState(defaultValue ?? ""); @@ -59,6 +61,7 @@ export const WireframePlayground = forwardRef
diff --git a/test/react.test.tsx b/test/react.test.tsx index 3953c58..2163ccd 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -317,10 +317,14 @@ test("WireframeEditor: problems underlined after a pause in typing, onProblems w await view.unmount(); }); -test("WireframeEditor: a controlled value replaces the text; autocomplete and lineNumbers turn off", async () => { +test("WireframeEditor: a controlled value replaces the text; autocomplete, lineNumbers and lineWrapping turn off", async () => { const { h, lib } = await load(); - const view = await mount(h(lib.WireframeEditor, { value: "board", onChange: () => {}, lineNumbers: false, autocomplete: false, ariaLabel: "Sketch" })); + const wrapped = await mount(h(lib.WireframeEditor, { defaultValue: "board" })); + assert.ok(wrapped.$(".cm-content")!.classList.contains("cm-lineWrapping"), "wraps by default"); + await wrapped.unmount(); + const view = await mount(h(lib.WireframeEditor, { value: "board", onChange: () => {}, lineNumbers: false, lineWrapping: false, autocomplete: false, ariaLabel: "Sketch" })); assert.equal(view.$(".cm-lineNumbers"), null); + assert.ok(!view.$(".cm-content")!.classList.contains("cm-lineWrapping"), "lineWrapping: false"); assert.equal(view.$(".cm-content")!.getAttribute("aria-label"), "Sketch"); await view.rerender(h(lib.WireframeEditor, { value: "board\n screen phone", onChange: () => {}, lineNumbers: false, autocomplete: false })); assert.equal(view.$(".cm-content")!.textContent, "board screen phone"); From dacf266b4204ddecaa43084c092c6af0f36ea52b Mon Sep 17 00:00:00 2001 From: David Vogeleer Date: Tue, 20 Oct 2026 20:42:00 -0400 Subject: [PATCH 4/4] Review fixes: editor entry point, ref ready at mount, saved-sketch example - The editor and playground move to @tsquare-js/react/editor (a second esbuild entry with a shared chunk): the main entry never reaches tsquare/editor, so a Wireframe-only app bundles 489 KB gzipped instead of 617 KB (no CodeMirror). test/package.test.ts checks what each entry reaches, the shared chunk and "use client" on every file. - The editor is created in a layout effect declared before useImperativeHandle, so a callback ref or a parent's useLayoutEffect can focus it and read its text (before: no-op and ""). useEffect on the server. Test added (mutation-checked: it fails with the old useEffect). - docs/playground.md: the saved-sketch example keys its stateful part by id, so switching sketches can't save one's text under another's id. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 4 ++-- build.mjs | 10 +++++++--- docs/README.md | 6 +++--- docs/editor.md | 8 ++++---- docs/playground.md | 9 +++++++-- package.json | 5 +++++ src/editor.tsx | 9 +++++++-- src/entry-editor.ts | 8 ++++++++ src/index.ts | 4 ++-- test/docs.test.ts | 2 +- test/package.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ test/react.test.tsx | 23 +++++++++++++++++++---- 13 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 src/entry-editor.ts create mode 100644 test/package.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 367d6a0..8881ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **The editor and playground are imported from `@tsquare-js/react/editor`,** their own entry point, because they bundle CodeMirror (about 125 KB compressed): apps that import only `@tsquare-js/react` (boards, viewer, react-markdown) don't get it. - **``:** tsquare's code editor (the playground's): highlighting, autocomplete, problems underlined about 300 ms after typing stops, Enter keeping the indentation, a whole pasted model reply trimmed to its code block, long lines wrapped (continuing 4 spaces past their indentation). `value`/`defaultValue`, `onChange`, `onProblems`, `autocomplete`, `lineNumbers`, `lineWrapping`, `ariaLabel`; a `ref` gets `getText`, `setText`, `focus` and `goToLine`. Built on `tsquare/editor` (tsquare 0.12), with CodeMirror bundled inside it. - **``:** the editor and the viewer side by side on one text, half and half (stacked below 900px, or `layout="row"`/`"column"`). 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 board. - **The viewer stops when it unmounts** (tsquare's new `destroy()`): no listeners, resize watching or board image left behind. diff --git a/README.md b/README.md index 13a4a95..0d955c4 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ export function Reply({ text, streaming }: { text: string; streaming: boolean }) - [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 -- [WireframeEditor](docs/editor.md): write and edit wireframe text, with autocomplete and problems underlined -- [WireframePlayground](docs/playground.md): the editor and the board side by side +- [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 - [Problems](docs/problems.md): showing them, or sending them back to the model - [Styling](docs/styling.md): the stylesheet, your colors, light and dark, class names diff --git a/build.mjs b/build.mjs index 3036053..d542716 100644 --- a/build.mjs +++ b/build.mjs @@ -1,4 +1,6 @@ -// Builds dist/: index.js (ESM, "use client" for React Server Components frameworks), index.d.ts, styles.css. +// Builds dist/: index.js and editor.js (ESM, "use client" for React Server Components frameworks), +// their shared code in dist/chunks/, the .d.ts files, styles.css. Two entries so the main one never +// imports tsquare/editor (CodeMirror): apps that only show boards don't bundle it. // react and tsquare stay imports: the app's bundler resolves them, tsquare to its browser build. import { execFileSync } from "node:child_process"; import { copyFileSync, rmSync } from "node:fs"; @@ -6,8 +8,10 @@ import { build } from "esbuild"; rmSync("dist", { recursive: true, force: true }); await build({ - entryPoints: ["src/index.ts"], - outfile: "dist/index.js", + entryPoints: { index: "src/index.ts", editor: "src/entry-editor.ts" }, + outdir: "dist", + splitting: true, // one copy of the shared code (the streaming context must be one instance) + chunkNames: "chunks/[name]-[hash]", bundle: true, format: "esm", platform: "neutral", diff --git a/docs/README.md b/docs/README.md index 1d2d5dc..a0e2551 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,8 +18,8 @@ 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 write or edit wireframe text | [``](editor.md) | -| An editor and the board side by side | [``](playground.md) | +| 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) | | Show a reply while it's still streaming in | [``](chat.md#while-a-reply-streams-in) | | Handle invalid wireframes yourself, for example by sending the problems back to the model | [`onProblems`](problems.md) | @@ -49,7 +49,7 @@ The board fits its container's width and keeps its proportions. `expandable` add Each component turns the wireframe text into an SVG in the browser with the tsquare library's browser build (your bundler picks it automatically) and shows it as an image. The text is checked first: if it has mistakes, the component lists them by line instead, under the last version that worked. The components are client components (`"use client"`); on the server they render an empty frame, filled in once the page loads. -The renderer adds about 1.7 MB minified (490 KB compressed) to your bundle. If wireframes are an occasional thing in your app, load the component when it's first needed: +The renderer adds about 1.7 MB minified (490 KB compressed) to your bundle. The editor and playground live in their own entry point, `@tsquare-js/react/editor`, because they add CodeMirror (another 125 KB or so); apps that only show boards never load it. If wireframes are an occasional thing in your app, load the component when it's first needed: ```tsx import { lazy, Suspense } from "react"; diff --git a/docs/editor.md b/docs/editor.md index a95a41e..421749d 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -4,7 +4,7 @@ ```tsx import { useState } from "react"; -import { WireframeEditor } from "@tsquare-js/react"; +import { WireframeEditor } from "@tsquare-js/react/editor"; export function EditSketch({ initial }: { initial: string }) { const [text, setText] = useState(initial); @@ -44,7 +44,7 @@ A `ref` gets the editor's methods, for a "Load example" button or jumping to a p ```tsx import { useRef } from "react"; -import { WireframeEditor, type WireframeEditorHandle } from "@tsquare-js/react"; +import { WireframeEditor, type WireframeEditorHandle } from "@tsquare-js/react/editor"; export function EditorWithExample({ example }: { example: string }) { const editor = useRef(null); @@ -64,8 +64,8 @@ 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. | -## Bundle size +## 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). 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()`). +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()`). Colors and fonts follow the [stylesheet's variables](styling.md); the highlighting has its own (`--tsquare-syntax-*`). diff --git a/docs/playground.md b/docs/playground.md index 4255a72..1ab5b23 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -3,7 +3,7 @@ `` puts the [editor](editor.md) and the [viewer](viewer.md) side by side on one text: type on the left, see the board on the right. It's the tsquare.dev playground's core, for your own app, without the site's menus. ```tsx -import { WireframePlayground } from "@tsquare-js/react"; +import { WireframePlayground } from "@tsquare-js/react/editor"; const START = `board "Sign in" screen phone @@ -49,9 +49,14 @@ Keep the text in your state to save it, share it or load another: ```tsx import { useEffect, useState } from "react"; -import { WireframePlayground } from "@tsquare-js/react"; +import { WireframePlayground } from "@tsquare-js/react/editor"; export function SavedSketch({ id, initial }: { id: string; initial: string }) { + // keyed by id: another sketch gets fresh state, instead of saving this one's text under its id + return ; +} + +function Sketch({ id, initial }: { id: string; initial: string }) { const [text, setText] = useState(() => localStorage.getItem(`sketch:${id}`) ?? initial); useEffect(() => { localStorage.setItem(`sketch:${id}`, text); }, [id, text]); return ; diff --git a/package.json b/package.json index b898446..00b82ae 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,11 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./editor": { + "types": "./dist/entry-editor.d.ts", + "import": "./dist/editor.js", + "default": "./dist/editor.js" + }, "./styles.css": "./dist/styles.css" }, "files": [ diff --git a/src/editor.tsx b/src/editor.tsx index 148b67f..5c996c3 100644 --- a/src/editor.tsx +++ b/src/editor.tsx @@ -2,7 +2,7 @@ * : tsquare's code editor (tsquare/editor, the playground's) in React. Highlighting, * autocomplete, problems underlined on their lines as you type. */ -import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type CSSProperties } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; import { compileWireframe, languageData } from "tsquare"; import { createEditor, type WireframeEditor as EditorInstance } from "tsquare/editor"; import type { Problem } from "./use-board.js"; @@ -87,6 +87,9 @@ function useProblems(initial: string, onProblems?: (problems: Problem[]) => void const issuesOf = (text: string): Problem[] => compileWireframe(text).issues.map((i) => ({ line: i.line, message: i.message })); +/** useLayoutEffect in the browser; useEffect on the server, where React 18 warns about layout effects. */ +const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + export interface EditorCoreProps extends Omit { /** Problems to underline; undefined keeps the current ones (while the playground waits for typing to pause). */ issues?: Problem[]; @@ -102,7 +105,9 @@ export const EditorCore = forwardRef(fun const change = useRef(onChange); change.current = onChange; - useEffect(() => { + // 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. + useClientLayoutEffect(() => { const instance = createEditor(host.current!, { doc: value ?? defaultValue ?? "", data: autocomplete ? languageData() : undefined, diff --git a/src/entry-editor.ts b/src/entry-editor.ts new file mode 100644 index 0000000..1e59a17 --- /dev/null +++ b/src/entry-editor.ts @@ -0,0 +1,8 @@ +/** + * @tsquare-js/react/editor: the components that edit wireframe text. A separate entry point because + * the editor bundles CodeMirror (about 125 KB compressed): apps that only show boards import + * "@tsquare-js/react" and don't get it. Shares its rendering, viewer and streaming context with the + * main entry (one copy, in a shared chunk). + */ +export { WireframeEditor, type WireframeEditorProps, type WireframeEditorHandle } from "./editor.js"; +export { WireframePlayground, type WireframePlaygroundProps } from "./playground.js"; diff --git a/src/index.ts b/src/index.ts index 1002ef7..f6a6cc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ /** * React components for tsquare wireframes. Boards are drawn in the browser by tsquare; nothing is * sent anywhere. Styles: import "@tsquare-js/react/styles.css" (optional; see docs/styling.md). + * The editor and playground are in "@tsquare-js/react/editor" (entry-editor.ts), so apps that only + * show boards don't bundle CodeMirror. */ export { Wireframe, type WireframeProps } from "./wireframe.js"; export { WireframeViewer, type WireframeViewerProps } from "./viewer.js"; -export { WireframeEditor, type WireframeEditorProps, type WireframeEditorHandle } from "./editor.js"; -export { WireframePlayground, type WireframePlaygroundProps } from "./playground.js"; export { WireframeStreaming } from "./streaming.js"; export { tsquareComponents, createTsquareComponents, TsquarePre, type TsquareComponentsOptions } from "./markdown.js"; export type { Problem } from "./use-board.js"; diff --git a/test/docs.test.ts b/test/docs.test.ts index d1e30dd..031ff5d 100644 --- a/test/docs.test.ts +++ b/test/docs.test.ts @@ -27,7 +27,7 @@ test("every tsx example in the docs and README typechecks against the package", writeFileSync(join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, skipLibCheck: true, noEmit: true, - lib: ["ES2022", "DOM"], baseUrl: ".", paths: { "@tsquare-js/react": ["../../src/index.ts"], "@tsquare-js/react/styles.css": ["../../src/styles.css"] }, + lib: ["ES2022", "DOM"], baseUrl: ".", paths: { "@tsquare-js/react": ["../../src/index.ts"], "@tsquare-js/react/editor": ["../../src/entry-editor.ts"], "@tsquare-js/react/styles.css": ["../../src/styles.css"] }, }, include: ["*.tsx", "css.d.ts"], })); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..7847f90 --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,38 @@ +/** + * The built package (needs npm run build): the main entry never reaches tsquare/editor, so apps that + * only show boards don't bundle CodeMirror; the editor entry does; both share one copy of the code. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const dist = new URL("../dist/", import.meta.url).pathname; + +/** Every file an entry loads (following relative imports) and every package import among them. */ +function reach(entry: string) { + const files = new Set(); + const packages = new Set(); + const visit = (file: string) => { + if (files.has(file)) return; + files.add(file); + for (const [, spec] of readFileSync(file, "utf8").matchAll(/(?:from|import)\s*"([^"]+)"/g)) { + if (spec.startsWith(".")) visit(join(dirname(file), spec)); + else packages.add(spec); + } + }; + visit(join(dist, entry)); + return { files, packages }; +} + +test("the main entry doesn't reach tsquare/editor (CodeMirror); the editor entry does, sharing the rest", () => { + const main = reach("index.js"); + const editor = reach("editor.js"); + assert.ok(!main.packages.has("tsquare/editor"), `main reaches: ${[...main.packages]}`); + assert.ok(editor.packages.has("tsquare/editor")); + const shared = [...main.files].filter((f) => f.includes("/chunks/")); + assert.ok(shared.length && shared.every((f) => editor.files.has(f)), "one shared chunk (one streaming context)"); + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + assert.deepEqual(Object.keys(pkg.exports), [".", "./editor", "./styles.css"]); + for (const f of [...main.files, ...editor.files]) assert.ok(readFileSync(f, "utf8").startsWith('"use client";'), `${f} starts with "use client"`); +}); diff --git a/test/react.test.tsx b/test/react.test.tsx index 2163ccd..118b452 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -43,7 +43,7 @@ const BROKEN = 'board\n screen phone\n buton "Oops"'; const load = async () => { const React = await import("react"); const { createRoot } = await import("react-dom/client"); - const lib = await import("../src/index.js"); + const lib = { ...(await import("../src/index.js")), ...(await import("../src/entry-editor.js")) }; const tsquare = await import("tsquare"); const Markdown = (await import("react-markdown")).default; return { h: React.createElement, act: React.act, createRoot, lib, tsquare, Markdown }; @@ -280,7 +280,7 @@ test("styles.css: every class the components use is styled, every rule is low-pr test("WireframeEditor: the text with its accessible name; ref.setText reports through onChange", async () => { const { h, act, lib } = await load(); const React = await import("react"); - const ref = React.createRef(); + const ref = React.createRef(); const changes: string[] = []; const view = await mount(h(lib.WireframeEditor, { ref, defaultValue: SIGN_IN, onChange: (t: string) => changes.push(t), className: "mine" })); assert.equal(view.$(".tsquare-editor")!.className, "tsquare-editor mine"); @@ -294,10 +294,25 @@ test("WireframeEditor: the text with its accessible name; ref.setText reports th assert.equal(view.container.querySelector(".cm-editor"), null, "unmounting removes the editor"); }); +test("WireframeEditor: the ref works as soon as it's handed out (a callback ref can focus it and read its text)", async () => { + const { h, lib } = await load(); + const seen: { text: string; focused: boolean }[] = []; + const view = await mount(h(lib.WireframeEditor, { + defaultValue: SIGN_IN, + ref: (handle: import("../src/entry-editor.js").WireframeEditorHandle | null) => { + if (!handle) return; + handle.focus(); + seen.push({ text: handle.getText(), focused: !!document.activeElement?.closest(".cm-editor") }); + }, + })); + assert.deepEqual(seen, [{ text: SIGN_IN, focused: true }]); + await view.unmount(); +}); + test("WireframeEditor: problems underlined after a pause in typing, onProblems with the list, [] once fixed", async () => { const { h, act, lib } = await load(); const React = await import("react"); - const ref = React.createRef(); + const ref = React.createRef(); const calls: unknown[] = []; const view = await mount(h(lib.WireframeEditor, { ref, defaultValue: SIGN_IN, onProblems: (p: unknown) => calls.push(p) })); await settle(400); @@ -334,7 +349,7 @@ test("WireframeEditor: a controlled value replaces the text; autocomplete, lineN test("WireframePlayground: editor and viewer on one text; problems wait for a pause, then underlined and noted", async () => { const { h, act, lib } = await load(); const React = await import("react"); - const ref = React.createRef(); + const ref = React.createRef(); const calls: unknown[] = []; const view = await mount(h(lib.WireframePlayground, { ref, defaultValue: FLOW, onProblems: (p: unknown) => calls.push(p) })); assert.equal(view.$(".tsquare-playground")!.getAttribute("data-layout"), "auto");