Components
Canvas
A plane that pans and zooms, on which things are placed, selected, moved and resized by hand.
MarkdownA canvas is a plane, larger than the screen, on which things are placed and then arranged by hand: selected, moved, resized, lined up. It is the ground that drawing tools, layout tools, diagrams and node editors stand on.
A page layout
Drag a block: it comes to rest in line with the others and shows the line; hold Control to move freely, Shift to keep to one direction, Alt to leave a copy. Drag a handle to resize — Alt from the middle, Shift in proportion. Nothing leaves the page, which is locked: a drag begun on it draws a marquee. Tab, Space and the arrow keys do all of it without a pointer; Command-Z takes a gesture back.
Nothing selected
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import {
Canvas,
CanvasItem,
applyItemChanges,
invertItemChanges,
} from "@publictheta/ui/components/canvas/canvas.tsx";
import type {
CanvasChangeKind,
CanvasCopy,
CanvasItemChange,
CanvasItemData,
Rect,
Viewport,
} from "@publictheta/ui/components/canvas/canvas.tsx";
import { rectUnion } from "@publictheta/ui/algorithms/geometry.ts";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { IDENTITY, fit, zoomStop, zoomTo } from "@publictheta/ui/primitives/viewport.ts";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";
interface Piece extends CanvasItemData {
readonly kind: "page" | "panel" | "heading" | "text" | "image";
readonly name: string;
readonly text?: string;
}
const PAGE: Rect = { x: 0, y: 0, width: 640, height: 400 };
const PIECES: readonly Piece[] = [
// The page itself: there to be aligned with and drawn over, not to be moved.
{ id: "page", kind: "page", name: "Page", rect: PAGE, locked: true },
{
id: "title",
kind: "heading",
name: "Title",
text: "Autumn catalogue",
rect: { x: 40, y: 36, width: 300, height: 44 },
},
{
id: "lede",
kind: "text",
name: "Introduction",
text: "Twelve pieces in stoneware, thrown and glazed by hand.",
rect: { x: 40, y: 100, width: 232, height: 92 },
},
{ id: "photo", kind: "image", name: "Photograph", rect: { x: 324, y: 100, width: 276, height: 180 } },
{ id: "price", kind: "panel", name: "Price panel", rect: { x: 40, y: 220, width: 232, height: 60 } },
{ id: "strip", kind: "panel", name: "Footer strip", rect: { x: 40, y: 316, width: 560, height: 48 } },
];
/** What undo puts back: places that changed, or the whole list where pieces came or went. */
type Entry =
| { readonly kind: "places"; readonly changes: readonly CanvasItemChange[] }
| { readonly kind: "pieces"; readonly before: readonly Piece[]; readonly after: readonly Piece[] };
const clamp = (value: number, least: number, most: number) => Math.min(Math.max(value, least), most);
/** A place held within the page: moved back inside, or — while being resized — cut off at its edge. */
function withinPage(rect: Rect, kind: CanvasChangeKind): Rect {
if (kind === "move") {
return {
...rect,
x: clamp(rect.x, PAGE.x, PAGE.x + PAGE.width - rect.width),
y: clamp(rect.y, PAGE.y, PAGE.y + PAGE.height - rect.height),
};
}
const x = clamp(rect.x, PAGE.x, PAGE.x + PAGE.width);
const y = clamp(rect.y, PAGE.y, PAGE.y + PAGE.height);
return {
x,
y,
width: clamp(rect.x + rect.width, x, PAGE.x + PAGE.width) - x,
height: clamp(rect.y + rect.height, y, PAGE.y + PAGE.height) - y,
};
}
function renderPiece(piece: Piece) {
if (piece.kind === "page") {
return (
<CanvasItem aria-label={piece.name} className="rounded-sm border border-border-muted bg-surface" />
);
}
if (piece.kind === "image") {
return (
<CanvasItem
aria-label={piece.name}
className="flex cursor-default items-center justify-center overflow-hidden bg-surface-strong text-fg-muted"
>
<svg aria-hidden viewBox="0 0 48 32" className="h-1/2 max-h-16" fill="none" stroke="currentColor">
<path d="M2 28 16 12l9 10 6-6 15 12" strokeWidth="2" strokeLinejoin="round" />
<circle cx="36" cy="8" r="4" strokeWidth="2" />
</svg>
</CanvasItem>
);
}
if (piece.kind === "heading" || piece.kind === "text") {
return (
<CanvasItem
aria-label={piece.name}
className={
piece.kind === "heading"
? "cursor-default overflow-hidden text-3xl leading-tight font-semibold text-fg-strong"
: "cursor-default overflow-hidden text-sm leading-snug text-fg"
}
>
{piece.text}
</CanvasItem>
);
}
return (
<CanvasItem
aria-label={piece.name}
className="flex cursor-default items-center overflow-hidden rounded-md border border-border bg-surface-muted px-3 text-xs text-fg-muted"
>
{piece.name}
</CanvasItem>
);
}
const whole = (value: number) => Math.round(value * 10) / 10;
export default function CanvasLayout() {
const [pieces, setPieces] = useState(PIECES);
const [selected, setSelected] = useState<readonly string[]>([]);
const [view, setView] = useState<Viewport>(IDENTITY);
const [snapping, setSnapping] = useState<"on" | "off">("on");
const [bounds, setBounds] = useState<"page" | "none">("page");
const [history] = useState(() => createHistory<Entry>());
const [, drawAgain] = useState(0);
const canvas = useRef<HTMLDivElement>(null);
const copies = useRef(0);
const size = () => ({ width: canvas.current?.clientWidth ?? 0, height: canvas.current?.clientHeight ?? 0 });
// Also run once the canvas exists, from the size it turns out to have.
// oxlint-disable-next-line react/set-state-in-effect
const fitPage = () => setView(fit(PAGE, size(), { padding: 20 }));
// A phone's canvas is narrower than the page: begin with the whole page in view.
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- once
useEffect(fitPage, []);
function record(entry: Entry): void {
history.push(entry);
drawAgain((count) => count + 1);
}
function replace(after: readonly Piece[]): void {
record({ kind: "pieces", before: pieces, after });
setPieces(after);
}
function step(entry: Entry | undefined, back: boolean): void {
if (entry === undefined) return;
if (entry.kind === "pieces") setPieces(back ? entry.before : entry.after);
else setPieces(applyItemChanges(pieces, back ? invertItemChanges(entry.changes) : entry.changes));
drawAgain((count) => count + 1);
}
function duplicate(made: readonly CanvasCopy[]): void {
const added = made.flatMap(({ source, rect }) => {
const original = pieces.find((piece) => piece.id === source);
copies.current += 1;
return original === undefined
? []
: [{ ...original, id: `${original.id}-${copies.current}`, name: `${original.name} copy`, rect }];
});
replace([...pieces, ...added]);
setSelected(added.map((piece) => piece.id));
}
function onKeyDown(event: KeyboardEvent): void {
const key = event.key.toLowerCase();
if (isCommandKey(event) && key === "z") {
event.preventDefault();
step(event.shiftKey ? history.redo() : history.undo(), !event.shiftKey);
} else if (key === "backspace" || key === "delete") {
const going = new Set(selected);
const kept = pieces.filter((piece) => piece.locked === true || !going.has(piece.id));
if (kept.length === pieces.length) return;
event.preventDefault();
replace(kept);
setSelected([]);
}
}
const chosen = pieces.filter((piece) => selected.includes(piece.id));
const around = rectUnion(chosen.map((piece) => piece.rect));
const zoomBy = (direction: 1 | -1) => {
const { width, height } = size();
setView(zoomTo(view, zoomStop(view.zoom, direction), { x: width / 2, y: height / 2 }));
};
return (
<div className="flex w-full flex-col">
<Controls>
<RadioGroup label="Snapping" size="small" value={snapping} onValueChange={setSnapping}>
<Radio value="on">On</Radio>
<Radio value="off">Off</Radio>
</RadioGroup>
<RadioGroup label="Keep within" size="small" value={bounds} onValueChange={setBounds}>
<Radio value="page">The page</Radio>
<Radio value="none">Nothing</Radio>
</RadioGroup>
</Controls>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Button
focusableWhenDisabled
disabled={!history.canUndo()}
onClick={() => step(history.undo(), true)}
>
Undo
</Button>
<Button
focusableWhenDisabled
disabled={!history.canRedo()}
onClick={() => step(history.redo(), false)}
>
Redo
</Button>
<div className="ms-auto flex items-center gap-2">
<Button size="icon" aria-label="Zoom out" onClick={() => zoomBy(-1)}>
−
</Button>
<output className="w-12 text-center text-xs text-fg-muted tabular-nums">
{Math.round(view.zoom * 100)}%
</output>
<Button size="icon" aria-label="Zoom in" onClick={() => zoomBy(1)}>
+
</Button>
<Button onClick={fitPage}>Fit</Button>
</div>
</div>
<Canvas<Piece>
ref={canvas}
aria-label="Catalogue page"
items={pieces}
onItemsChange={(changes) => {
record({ kind: "places", changes });
setPieces(applyItemChanges(pieces, changes));
}}
renderItem={renderPiece}
value={selected}
onValueChange={setSelected}
viewport={view}
onViewportChange={setView}
snap={snapping === "on"}
minSize={8}
{...(bounds === "page" && {
constrain: (_: string, rect: Rect, kind: CanvasChangeKind) => withinPage(rect, kind),
})}
onDuplicate={duplicate}
onKeyDown={onKeyDown}
className="h-[420px] w-full rounded-lg border border-border-muted bg-surface-muted max-sm:h-[340px]"
/>
<Readout>
{chosen.length === 0 || around === null
? "Nothing selected"
: `${chosen.length === 1 ? chosen[0]!.name : `${chosen.length} selected`} · ${whole(around.x)}, ${whole(around.y)} · ${whole(around.width)} × ${whole(around.height)}`}
</Readout>
</div>
);
}Ten thousand tiles
Scroll, pinch, or drag with Space held; on a phone, drag with one finger and pinch with two. Only the tiles in and around the view are drawn. Draw a marquee from an empty gap, press Command-A, and drag them all.
0 of 10,000 drawn · 100% · 0 selected
Code
"use client";
import { Canvas, CanvasItem, applyItemChanges } from "@publictheta/ui/components/canvas/canvas.tsx";
import type { CanvasItemData, Viewport } from "@publictheta/ui/components/canvas/canvas.tsx";
import { useEffect, useRef, useState } from "react";
import { Readout } from "../../ui/controls.tsx";
const COUNT = 10_000;
const ACROSS = 100;
const PITCH = 72;
interface Tile extends CanvasItemData {
readonly tone: number;
}
/** The same field on every visit: each tile's place and size follow from its number alone. */
function make(): Tile[] {
return Array.from({ length: COUNT }, (_, index) => {
const mixed = (index * 2654435761) % 4294967296;
const width = 28 + (mixed % 28);
const height = 24 + ((mixed >>> 7) % 28);
return {
id: `t${index}`,
tone: mixed % 3,
rect: {
x: (index % ACROSS) * PITCH + ((mixed >>> 3) % 14),
y: Math.floor(index / ACROSS) * PITCH + ((mixed >>> 11) % 14),
width,
height,
},
};
});
}
const TONES = ["bg-surface-strong", "bg-selected-subtle", "bg-surface-raised"];
// One function for every render: a tile is drawn again only when it changes.
const renderTile = (tile: Tile) => (
<CanvasItem
aria-label={`Tile ${tile.id.slice(1)}`}
className={`cursor-default rounded-sm border border-border ${TONES[tile.tone]}`}
/>
);
export default function CanvasField() {
const [tiles, setTiles] = useState(make);
const [selected, setSelected] = useState<readonly string[]>([]);
const [view, setView] = useState<Viewport>({ x: -24, y: -24, zoom: 1 });
const [drawn, setDrawn] = useState(0);
const canvas = useRef<HTMLDivElement>(null);
// Counted from what is on the page, after each move of the view.
useEffect(() => {
// oxlint-disable-next-line react/set-state-in-effect -- read back from the DOM the canvas has just drawn
setDrawn(canvas.current?.querySelectorAll("[data-canvas-item]").length ?? 0);
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- what is drawn changes when the view or the tiles do
}, [view, tiles]);
return (
<div className="flex w-full flex-col">
<Canvas<Tile>
ref={canvas}
aria-label="Ten thousand tiles"
items={tiles}
onItemsChange={(changes) => setTiles((current) => [...applyItemChanges(current, changes)])}
renderItem={renderTile}
value={selected}
onValueChange={setSelected}
viewport={view}
onViewportChange={setView}
className="h-[420px] w-full rounded-lg border border-border-muted bg-surface max-sm:h-[340px]"
/>
<Readout>
{drawn.toLocaleString("en-US")} of {COUNT.toLocaleString("en-US")} drawn ·{" "}
{Math.round(view.zoom * 100)}% · {selected.length.toLocaleString("en-US")} selected
</Readout>
</div>
);
}When to use
Use a canvas when where a thing is, and how large, is itself what people are deciding. When things have an order but no position, a listbox or a data grid says so more plainly, and is far easier to use without a pointer.
Best practices
Move the view as paper under the hand. Scrolling moves the view; a pinch, or the wheel with the command key, zooms about the point under the pointer; a drag with Space held, or with the middle button, pulls the plane about. Offer "fit everything", "fit the selection" and "actual size" by name. See Pan and zoom.
Select as collections do, and by area. A click selects one thing; with Shift or the command key it adds a thing or takes it out; a click on empty space clears. A drag from empty space draws a marquee that selects what it touches — with Alt, only what lies wholly inside it; with Shift, in addition to what was selected. A press on one of several selected things keeps them all, so that a drag can begin from it. See Selection.
Move everything selected together. Dragging a selected thing moves every selected thing by the same distance. A press on a thing that is not selected selects it first, and the drag moves it alone.
Help things into line, visibly, and offer a way out. What is moved or resized comes to rest in line with the edges and middles of what is around it, and shows the line it found. Holding Control or the command key moves freely; holding Shift keeps a move to one direction. See Snapping.
Resize by handles. One selected thing shows handles at its corners and sides; several show one frame around them all. See Resizing.
Let the application say where things may go. A frame things must stay within, lanes, a minimum size: apply the rule to every place a thing is shown at while it is dragged, not only to where it is dropped, so that what people see is what they will get.
Lock what should stay put. A locked thing is not moved, resized, or caught by a marquee, and a press on it is a press on the plane beneath — a locked background does not get in the way of a marquee drawn over it. It can still be reached from the keyboard, and says that it is locked.
Make one gesture one change. A drag is reported once, when it ends, as the places of every thing it changed before and after, so that one undo takes back the whole of it. Each press of an arrow key is likewise one change. Escape in the middle of a drag puts everything back, and nothing is reported. See Undo and redo.
Carry the view along. A drag held near the edge of the view — of a thing, a handle, a marquee — moves the view that way, faster the further it goes, and what is dragged stays under the pointer. An edge pulls only a drag that has travelled toward it, so that picking up something beside an edge moves nothing. Escape puts the view back with everything else.
Copy by dragging with Alt. A drag with Alt held leaves the originals where they were and makes copies where the drag ends. Mark the originals' places while it lasts.
Leave the content of a thing to itself. A field inside a thing is typed in as anywhere, and a press on it does not drag the thing. Where a thing has a part made to be dragged by — a title bar — only that part drags it, and a press elsewhere on it selects.
Give what is drawn between things a layer of the plane. The lines of a diagram, a grid, a ruler: drawn on the plane itself, beneath the things or over them, so that they move and scale with it and need no moving of their own.
Stay immediate among thousands. Draw only what is in and around the view; move the plane, not each thing on it, when the view moves; find what a marquee touches, and what a drag may align with, by looking near it rather than at everything.
Hold together far from zero. A plane has no edges. Place what is drawn relative to somewhere near the view, so that things a hundred million units away are drawn as exactly as those at the origin.
Conventions
| Intent | Pointer | Keyboard | Touch |
|---|---|---|---|
| Move between things | — | Tab, Shift-Tab; Home, End | — |
| Move to a thing nearby | — | Alt with an arrow key | — |
| Select one thing | Click | Space on the focused thing | Tap |
| Add or remove a thing | Shift-click, Command-click | Space on the focused thing | Tap |
| Select by area | Drag from empty space | — | Hold, then drag, from empty space |
| Select all; clear | Click empty space to clear | Command-A; Escape | Tap empty space to clear |
| Move the selection | Drag a selected thing | Arrow keys; with Shift, by ten | Drag a thing |
| Resize the selection | Drag a handle | Command with the arrow keys | Drag a handle |
| Move freely; one axis | Hold Control or Command; hold Shift | — | — |
| Copy | Drag with Alt | — | — |
| Pan | Scroll; drag with Space or middle button | Arrow keys, when nothing is selected | Drag empty space; drag with two fingers |
| Zoom | Pinch; wheel with Command or Control | Command-Plus, Command-Minus | Pinch |
| Fit; actual size | — | Shift-1, Shift-2; Command-0 | — |
| Act on a thing | Double click | Return | — |
| Cancel a drag | — | Escape | — |
Command is ⌘ on Apple platforms and Ctrl elsewhere.
Platform considerations
Keyboard. Everything can be done without a pointer. Tab enters the canvas on one thing — the one last focused, else the first selected, else the first in reading order — and from there Tab and Shift-Tab go through the things in reading order: in rows from the top, and along each row from the side reading starts at. Past the last thing, and before the first, Tab leaves the canvas, so that it is never a trap. Alt with an arrow key goes to the nearest thing in that direction — the nearest within 45° of it, or failing that the nearest that way at all — as it moves between panes elsewhere; where nothing lies that way focus stays, and the key is still the canvas's, not the browser's to go back with. Moving focus does not change the selection; Space does. The arrow keys move what is selected by one unit, exactly, without snapping — and move the view when nothing is selected. A thing that focus reaches, or that the keyboard moves out of view, is brought into view.
Pointer. A secondary press selects the thing under it unless that thing is already selected, so that a menu opened there acts on what it appears over.
Touch. A tap adds a thing to the selection or takes it out, since a finger has no modifier keys. One finger dragged from a thing moves it; from empty space it moves the view; held still for half a second first, it draws a marquee. Two fingers always move and zoom the view, about the point between them, and a finger added to a drag already under way does not take it over. A handle's target is at least 44 pixels across.
Pen. A pen selects as a finger does, on a tap, and drags as a mouse does: from empty space it draws a marquee.
Right to left. The plane does not mirror: its coordinates are geometry, and an arrow key moves a thing the way the arrow points. What follows the writing direction is the order in which Tab goes through the things.
Motion. Zooming to a named scale and fitting are immediate. Nothing moves that the person did not move.
Forced colours. The frame of a selected thing, the handles, the marquee, the guides and the focus ring are drawn in the system's own highlight and text colours, so that they remain visible when the system replaces the page's.
Assistive technology. The canvas is exposed as an application, because its keys are its own: it has a name, and describes its keys. Each thing is a named group that says when it is selected and when it is locked, and holds focus itself. What cannot be seen happening is said: how many things are selected after the keyboard changed the selection; where a thing is after a move, and how large after a resize.
Behaviour
Semantics
The canvas is an application with a name, and says how its keys work
const screen = await render(<Board />);
const canvas = screen.getByRole("application", { name: "Board" });
await expect.element(canvas).toBeVisible();
await expect.element(canvas).toHaveAccessibleDescription(/Tab and Shift-Tab move between items/);Each item is a group with its name
const screen = await render(<Board />);
for (const name of ["A", "B", "C"]) {
await expect.element(screen.getByRole("group", { name, exact: true })).toBeVisible();
}An item says when it is selected, and when it is locked
const screen = await render(
<Board initial={[box("a", 20, 20), box("b", 140, 20, 80, 60, true)]} defaultValue={["a", "b"]} />,
);
await expect.element(screen.getByRole("group", { name: "A" })).toHaveAccessibleDescription("selected");
await expect
.element(screen.getByRole("group", { name: "B" }))
.toHaveAccessibleDescription("selected locked");
expect(el(item("b")).dataset.locked).toBe("");
await commands.clickSelector(CANVAS, { position: { x: 300, y: 270 } });
await expect.element(screen.getByRole("group", { name: "A" })).toHaveAccessibleDescription("");It has no accessibility violations, with items selected and locked
const screen = await render(
<Board initial={[...large(), box("d", 220, 180, 80, 60, true)]} defaultValue={["a", "d"]} />,
);
await expectAccessible(screen.container);Where the system forces its colours, the selection, its handles and the marquee take the system's highlight
await render(<Board initial={large()} defaultValue={["a"]} />);
await commands.forceColors(true);
try {
const probe = document.createElement("span");
probe.style.color = "Highlight";
document.body.append(probe);
const highlight = getComputedStyle(probe).color;
probe.remove();
expect(getComputedStyle(el(item("a")), "::after").borderTopColor).toBe(highlight);
expect(getComputedStyle(el(`${handle("se")} > div`)).borderTopColor).toBe(highlight);
await commands.mouseDownAt(CANVAS, at(300, 270));
await commands.mouseMoveBy(-20, -20);
expect(getComputedStyle(el("[data-canvas-marquee]")).borderTopColor).toBe(highlight);
await commands.mouseUp();
} finally {
await commands.forceColors(false);
}The view
The wheel scrolls the view, and with Shift scrolls it sidewaysChromium, Firefox, WebKit
Not yet written.
The wheel with Control or Command zooms about the pointer: what is under it stays under itChromium, Firefox, WebKit
Not yet written.
A wheel over the canvas does not scroll or zoom the pageChromium, Firefox, WebKit
Not yet written.
A trackpad's pinch, where it is reported as a gesture of its own, zooms about the pointer
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} defaultViewport={{ x: 0, y: 0, zoom: 2 }} />);
const canvas = el(CANVAS);
const corner = canvas.getBoundingClientRect();
// As WebKit on a Mac reports it: a start, then changes that carry the scale so far.
const pinch = (type: string, scale: number) =>
canvas.dispatchEvent(
Object.assign(new Event(type, { bubbles: true, cancelable: true }), {
scale,
clientX: corner.left + 100,
clientY: corner.top + 50,
}),
);
pinch("gesturestart", 1);
pinch("gesturechange", 1.5);
pinch("gesturechange", 2);
// Twice the scale the gesture began at; the point under the pointer — 50 across, 25 down — still under it.
expect(last<Viewport>(onViewportChange)).toEqual({ x: 25, y: 12.5, zoom: 4 });Zoom stops at its limits, and the view does not creep there
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} zoomLimits={{ minimum: 0.5, maximum: 1.5 }} />);
await clickEmpty();
await press(`{${COMMAND}>}={/${COMMAND}}`);
expect(last<Viewport>(onViewportChange).zoom).toBe(1.5);
await press(`{${COMMAND}>}={/${COMMAND}}`);
expect(onViewportChange).toHaveBeenCalledOnce();
// A pinch asking for far more is held to the limit too.
el(CANVAS).dispatchEvent(new Event("gesturestart", { bubbles: true, cancelable: true }));
el(CANVAS).dispatchEvent(
Object.assign(new Event("gesturechange", { bubbles: true, cancelable: true }), { scale: 0.01 }),
);
expect(last<Viewport>(onViewportChange).zoom).toBe(0.5);Dragging with Space held moves the view by as far as the pointer, and selects nothing
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
// With focus on an item, which Space alone would take out of the selection.
await commands.clickSelector(item("b"));
await commands.keyDown("Space");
// Even from on top of an item.
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(50, 30);
await commands.mouseUp();
await commands.keyUp("Space");
expect(last<Viewport>(onViewportChange).x).toBeCloseTo(-50, 0);
expect(last<Viewport>(onViewportChange).y).toBeCloseTo(-30, 0);
expect(selection()).toEqual(["b"]);
await expect.poll(() => shown(item("a"))).toMatchObject({ x: 70, y: 50 });While Space is held the canvas shows that it can be grabbed, and while it is dragged, that it is
await render(<Board />);
await clickEmpty();
await commands.keyDown("Space");
await expect.poll(() => el(CANVAS).dataset.pan).toBe("ready");
expect(getComputedStyle(el(CANVAS)).cursor).toBe("grab");
await commands.mouseDownAt(CANVAS, at(200, 200));
await commands.mouseMoveBy(20, 0);
await expect.poll(() => el(CANVAS).dataset.pan).toBe("active");
expect(getComputedStyle(el(CANVAS)).cursor).toBe("grabbing");
await commands.mouseUp();
await commands.keyUp("Space");
await expect.poll(() => el(CANVAS).dataset.pan).toBeUndefined();Dragging with the middle button moves the view
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
await commands.mouseDownAt(item("b"), { button: "middle" });
await commands.mouseMoveBy(-40, 25);
await commands.mouseUp("middle");
expect(last<Viewport>(onViewportChange).x).toBeCloseTo(40, 0);
expect(last<Viewport>(onViewportChange).y).toBeCloseTo(-25, 0);
expect(selection()).toEqual([]);Escape during a drag of the view puts the view back
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
await commands.mouseDownAt(CANVAS, { ...at(200, 200), button: "middle" });
await commands.mouseMoveBy(-40, 25);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
await commands.mouseUp("middle");
expect(last<Viewport>(onViewportChange)).toEqual({ x: 0, y: 0, zoom: 1 });Two fingers move the view with the point between them, and zoom it as they part
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
const one = finger(31);
const two = finger(32);
one.down(100, 200);
two.down(200, 200);
// The second finger goes twice as far from the first: the point between them moves 50 across.
two.move(300, 200);
const view = last<Viewport>(onViewportChange);
expect(view.zoom).toBe(2);
// What was between the fingers — 150 across, 200 down — is between them still, at 200 across.
expect(view.x + 200 / view.zoom).toBe(150);
expect(view.y + 200 / view.zoom).toBe(200);
one.up(100, 200);
// The finger left behind does nothing more until it lifts.
two.move(320, 260);
expect(last<Viewport>(onViewportChange)).toBe(view);
two.up(320, 260);
expect(selection()).toEqual([]);
// And once both have lifted, one finger moves the view as before.
one.down(250, 250);
one.move(250, 230);
one.up(250, 230);
expect(last<Viewport>(onViewportChange)).toEqual({ ...view, y: view.y + 10 });A pinch on a touchscreen zooms about the point between the fingersChromium · touch
Not yet written.
Command with Plus and Minus step through the named scales, about the middle of the view
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
await clickEmpty();
await press(`{${COMMAND}>}={/${COMMAND}}`);
expect(last<Viewport>(onViewportChange).zoom).toBe(1.5);
// The middle of the view — 170 across, 150 down — is where it was.
expect(last<Viewport>(onViewportChange).x + 170 / 1.5).toBeCloseTo(170, 6);
await press(`{${COMMAND}>}-{/${COMMAND}}`);
await press(`{${COMMAND}>}-{/${COMMAND}}`);
expect(last<Viewport>(onViewportChange).zoom).toBe(0.75);
expect(last<Viewport>(onViewportChange).y + 150 / 0.75).toBeCloseTo(150, 6);Command-0 returns to actual size
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} defaultViewport={{ x: 0, y: 0, zoom: 4 }} />);
await clickEmpty();
await press(`{${COMMAND}>}0{/${COMMAND}}`);
expect(last<Viewport>(onViewportChange).zoom).toBe(1);Shift-1 fits everything in the view, and Shift-2 fits the selection
const onViewportChange = vi.fn();
await render(
<Board onViewportChange={onViewportChange} defaultViewport={{ x: 900, y: 900, zoom: 3 }} />,
);
const within = (inner: Rect) =>
inner.x >= 0 && inner.y >= 0 && inner.x + inner.width <= 340 && inner.y + inner.height <= 300;
el(CANVAS).focus();
await commands.keyDown("Shift");
await commands.keyDown("Digit1");
await commands.keyUp("Digit1");
await commands.keyUp("Shift");
// Everything lies within 200 by 180, which fits 276 by 236 — the view less its margin — at 131%.
await expect.poll(() => last<Viewport>(onViewportChange)?.zoom).toBeCloseTo(236 / 180, 6);
await expect.poll(() => ["a", "b", "c"].every((id) => within(shown(item(id))))).toBe(true);
await commands.clickSelector(item("c"));
await commands.keyDown("Shift");
await commands.keyDown("Digit2");
await commands.keyUp("Digit2");
await commands.keyUp("Shift");
// C alone, 80 by 60, as large as the view takes it.
await expect.poll(() => last<Viewport>(onViewportChange).zoom).toBeCloseTo(276 / 80, 6);
await expect.poll(() => shown(item("c"))).toEqual({ x: 32, y: 47, width: 276, height: 207 });Zooming and fitting are immediate: nothing animates
await render(<Board />);
await clickEmpty();
await press(`{${COMMAND}>}={/${COMMAND}}`);
await new Promise(requestAnimationFrame);
expect(el(CANVAS).getAnimations({ subtree: true })).toEqual([]);With nothing selected, the arrow keys move the view; with Shift, ten times as far
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
await clickEmpty();
await press("{ArrowRight}");
expect(last<Viewport>(onViewportChange)).toEqual({ x: 48, y: 0, zoom: 1 });
await press("{Shift>}{ArrowUp}{/Shift}");
expect(last<Viewport>(onViewportChange)).toEqual({ x: 48, y: -480, zoom: 1 });The plane is one element: moving the view moves it, and places no item anew
await render(<Board defaultViewport={{ x: 0, y: 0, zoom: 2 }} />);
const plane = el("[data-canvas-plane]");
const before = plane.style.transform;
// An item is placed in the plane's units, whatever the zoom.
expect(el(item("b")).style.left).toBe("140px");
expect(shown(item("b"))).toEqual({ x: 280, y: 40, width: 160, height: 120 });
await clickEmpty();
await press("{ArrowRight}");
expect(plane.style.transform).not.toBe(before);
expect(el(item("b")).style.left).toBe("140px");
expect(shown(item("b")).x).toBe(232);Among ten thousand items
Only the items in and around the view are drawn
await render(<Board initial={field()} />);
const drawn = document.querySelectorAll("[data-canvas-item]");
// The view and 256 pixels around it hold five columns of five rows.
expect(drawn.length).toBe(25);
expect(el(item("n404"))).not.toBeNull();
expect(document.querySelector(item("n505"))).toBeNull();Moving the view draws only the items that come into it
await render(<Board initial={field()} />);
// On the gap between two columns and two rows.
await commands.clickSelector(CANVAS, { position: { x: 100, y: 100 } });
renders = 0;
await press("{ArrowRight}");
await expect.poll(() => document.querySelector(item("n5"))).not.toBeNull();
// One more column of five rows; the twenty-five already there are left as they are.
expect(renders).toBe(5);A marquee selects exactly what it touches
const onValueChange = vi.fn();
await render(<Board initial={field()} onValueChange={onValueChange} />);
// From the gap beside the first item, across the second and third of the row below.
await commands.mouseDownAt(CANVAS, at(100, 70));
await commands.mouseMoveBy(150, 130);
await commands.mouseUp();
expect(last<string[]>(onValueChange)).toEqual(["n101", "n102"]);An item out of view, and so not drawn, is drawn, focused and brought into view when the keyboard reaches it
await render(<Board initial={field()} />);
await userEvent.click(document.querySelector<HTMLElement>('[aria-label="Before"]')!);
await press("{Tab}");
expect(focusedId()).toBe("n0");
await press("{End}");
await expect.poll(focusedId).toBe("n9999");
const place = shown(item("n9999"));
expect(place.x + place.width).toBeLessThanOrEqual(340);
expect(place.y + place.height).toBeLessThanOrEqual(300);Dragging an item takes its lines from what is near the view, not from all ten thousand
const onChanges = vi.fn();
await render(<Board initial={field()} onChanges={onChanges} />);
// 117 across: three short of the next item's left edge, which draws it in.
await commands.mouseDownAt(item("n0"));
await commands.mouseMoveBy(117, 80);
await expect.poll(() => document.querySelector('[data-canvas-guide="x"]')).not.toBeNull();
// The line runs across the column's items near the view — not down all hundred rows, 12,000 pixels of them.
expect(el('[data-canvas-guide="x"]').getBoundingClientRect().height).toBeLessThan(1000);
await commands.mouseUp();
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 120, y: 80 });Selection
A click selects only that item
const onValueChange = vi.fn();
await render(<Board onValueChange={onValueChange} />);
await commands.clickSelector(item("a"));
expect(selection()).toEqual(["a"]);
await commands.clickSelector(item("b"));
expect(selection()).toEqual(["b"]);
expect(onValueChange.mock.calls).toEqual([[["a"]], [["b"]]]);Shift-click and Command-click add an item to the selection, or take it out
await render(<Board />);
await commands.clickSelector(item("a"));
await commands.clickSelector(item("c"), { modifiers: ["Shift"] });
expect(selection()).toEqual(["a", "c"]);
await commands.clickSelector(item("b"), { modifiers: [COMMAND] });
expect(selection()).toEqual(["a", "b", "c"]);
await commands.clickSelector(item("a"), { modifiers: ["Shift"] });
await commands.clickSelector(item("c"), { modifiers: [COMMAND] });
expect(selection()).toEqual(["b"]);A click on empty space clears the selection; with Shift it is kept
await render(<Board defaultValue={["a", "b"]} />);
await commands.clickSelector(CANVAS, { position: { x: 300, y: 270 }, modifiers: ["Shift"] });
expect(selection()).toEqual(["a", "b"]);
await clickEmpty();
expect(selection()).toEqual([]);A press on one of several selected items keeps them all until it ends as a click on that one
await render(<Board defaultValue={["a", "b"]} />);
await commands.mouseDownAt(item("b"));
expect(selection()).toEqual(["a", "b"]);
await commands.mouseUp();
expect(selection()).toEqual(["b"]);A secondary press selects the item it lands on only if that is outside the selectionChromium, Firefox, WebKit
Not yet written.
Command-A selects every item that is not locked
await render(<Board initial={[...three(), box("d", 140, 140, 80, 60, true)]} />);
await clickEmpty();
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(selection()).toEqual(["a", "b", "c"]);An item activated by assistive technology, which presses nothing, is selected or taken out
await render(<Board defaultValue={["b"]} />);
el(item("a")).click();
await expect.poll(selection).toEqual(["a", "b"]);
el(item("b")).click();
await expect.poll(selection).toEqual(["a"]);Escape clears the selection
await render(<Board />);
await commands.clickSelector(item("a"));
await press("{Escape}");
expect(selection()).toEqual([]);Marquee
A drag from empty space draws a marquee, which selects what it touches
await render(<Board />);
// From beside C, a little way into it.
await commands.mouseDownAt(CANVAS, at(120, 150));
await commands.mouseMoveBy(-30, -5);
expect(selection()).toEqual(["c"]);
expect(shown("[data-canvas-marquee]")).toEqual({ x: 90, y: 145, width: 30, height: 5 });
// Carried on up across the corner of A as well.
await commands.mouseMoveBy(0, -75);
expect(selection()).toEqual(["a", "c"]);
await commands.mouseUp();
expect(document.querySelector("[data-canvas-marquee]")).toBeNull();
expect(selection()).toEqual(["a", "c"]);What a marquee no longer touches is no longer selected
await render(<Board />);
await commands.mouseDownAt(CANVAS, at(120, 100));
await commands.mouseMoveBy(-30, -30);
expect(selection()).toEqual(["a"]);
await commands.mouseMoveBy(15, 15);
expect(selection()).toEqual([]);
await commands.mouseUp();With Alt held, a marquee selects only what lies wholly within it
// A stands clear of the view's edge, where a marquee held still would pull the view along.
await render(<Board initial={[box("a", 60, 60, 40, 40)]} />);
await commands.mouseDownAt(CANVAS, at(140, 140));
await commands.keyDown("Alt");
await commands.mouseMoveBy(-50, -50);
expect(selection()).toEqual([]);
// On to beyond A's far corner.
await commands.mouseMoveBy(-40, -40);
expect(selection()).toEqual(["a"]);
// Back down until it only overlaps A's lower edge. Then Alt is let go with the pointer at rest:
// what the marquee touches counts again.
await commands.mouseMoveBy(0, 35);
expect(selection()).toEqual([]);
await commands.keyUp("Alt");
expect(selection()).toEqual(["a"]);
await commands.mouseUp();With Shift held, a marquee adds to what was selected
await render(<Board defaultValue={["b"]} />);
// Clear of B's handles, whose targets under a finger reach beyond its corners.
await commands.mouseDownAt(CANVAS, at(120, 130));
await commands.keyDown("Shift");
await commands.mouseMoveBy(-30, -55);
expect(selection()).toEqual(["a", "b"]);
await commands.keyUp("Shift");
expect(selection()).toEqual(["a"]);
await commands.mouseUp();Escape during a marquee puts the selection back as it was
await render(<Board defaultValue={["b"]} />);
await commands.mouseDownAt(CANVAS, at(120, 130));
await commands.mouseMoveBy(-30, -55);
expect(selection()).toEqual(["a"]);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(selection()).toEqual(["b"]);
expect(document.querySelector("[data-canvas-marquee]")).toBeNull();
await commands.mouseUp();
// Nor does the release that follows clear it, as a click on empty space would.
expect(selection()).toEqual(["b"]);Locked items
A locked item does not answer the pointer: a click on it clears the selection, as on empty space
await render(<Board initial={withFrame()} defaultValue={["a"]} />);
await commands.clickSelector(item("frame"), { position: { x: 300, y: 270 } });
expect(selection()).toEqual([]);A drag from a locked item draws a marquee, which does not catch it
const onChanges = vi.fn();
await render(<Board initial={withFrame()} onChanges={onChanges} />);
await commands.mouseDownAt(item("frame"), at(120, 100));
await commands.mouseMoveBy(-30, -30);
expect(shown("[data-canvas-marquee]")).toEqual({ x: 90, y: 70, width: 30, height: 30 });
await commands.mouseUp();
expect(selection()).toEqual(["a"]);
expect(onChanges).not.toHaveBeenCalled();A locked item can be focused, and selected from the keyboard
await render(<Board initial={[box("frame", 0, 0, 340, 300, true)]} />);
await userEvent.click(document.querySelector<HTMLElement>('[aria-label="Before"]')!);
await press("{Tab}");
expect(focusedId()).toBe("frame");
await press(" ");
expect(selection()).toEqual(["frame"]);A locked item among the selected is not moved, and not resized
const onChanges = vi.fn();
await render(
<Board
initial={[box("a", 20, 20), box("held", 140, 20, 80, 60, true)]}
defaultValue={["a", "held"]}
onChanges={onChanges}
snap={false}
/>,
);
await commands.dragBy(item("a"), 30, 40);
expect(changed(onChanges)).toEqual([
{
id: "a",
rect: { x: 50, y: 60, width: 80, height: 60 },
previous: { x: 20, y: 20, width: 80, height: 60 },
},
]);
await press(`{${COMMAND}>}{ArrowRight}{/${COMMAND}}`);
expect(changed(onChanges).map((change) => change.id)).toEqual(["a"]);
expect(shown(item("held"))).toEqual({ x: 140, y: 20, width: 80, height: 60 });A locked item shows no handles
await render(<Board initial={[box("held", 20, 20, 120, 100, true)]} defaultValue={["held"]} />);
expect(selection()).toEqual(["held"]);
expect(document.querySelector("[data-canvas-handle]")).toBeNull();Touch
A tap adds an item to the selection or takes it out, since a finger has no modifier keysChromium · touch, WebKit · touch
Not yet written.
A tap on empty space clears the selectionChromium · touch, WebKit · touch
Not yet written.
One finger dragged over empty space moves the view
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
const one = finger(21);
one.down(250, 250);
// A moment's rest before it moves is not a hold: that takes half a second.
await new Promise((resolve) => setTimeout(resolve, 150));
expect(document.querySelector("[data-canvas-marquee]")).toBeNull();
one.move(250, 230);
one.move(220, 200);
one.up(220, 200);
expect(last<Viewport>(onViewportChange)).toEqual({ x: 30, y: 50, zoom: 1 });
expect(selection()).toEqual([]);A finger held still on empty space, then dragged, draws a marquee
await render(<Board />);
const one = finger(22);
one.down(120, 100);
// Held for half a second: the marquee shows where it will begin.
await expect
.poll(() => document.querySelector("[data-canvas-marquee]"), { timeout: 2000 })
.not.toBeNull();
one.move(105, 85);
one.move(90, 70);
await expect.poll(selection).toEqual(["a"]);
one.up(90, 70);
await expect.poll(() => document.querySelector("[data-canvas-marquee]")).toBeNull();
expect(selection()).toEqual(["a"]);A finger's hold does not open the system's menu, and is not ended by it
await render(<Board />);
const one = finger(27);
one.down(120, 100);
await expect
.poll(() => document.querySelector("[data-canvas-marquee]"), { timeout: 2000 })
.not.toBeNull();
// What a phone sends when a finger has rested long enough.
const menu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
el(CANVAS).dispatchEvent(menu);
expect(menu.defaultPrevented).toBe(true);
one.move(105, 85);
one.move(90, 70);
await expect.poll(selection).toEqual(["a"]);
one.up(90, 70);A mouse's secondary press still opens its menu
await render(<Board />);
await commands.mouseDownAt(item("a"));
await commands.mouseUp();
const menu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
el(item("a")).dispatchEvent(menu);
expect(menu.defaultPrevented).toBe(false);A pen dragged from empty space draws a marquee, as a mouse does
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
const pen = finger(28, "pen");
pen.down(120, 100);
pen.move(105, 85);
pen.move(90, 70);
await expect.poll(selection).toEqual(["a"]);
pen.up(90, 70);
expect(onViewportChange).not.toHaveBeenCalled();A finger held and lifted without moving draws nothing
await render(<Board defaultValue={["b"]} />);
const one = finger(23);
one.down(120, 130);
await expect
.poll(() => document.querySelector("[data-canvas-marquee]"), { timeout: 2000 })
.not.toBeNull();
el(CANVAS).dispatchEvent(
new PointerEvent("pointerup", { pointerId: 23, pointerType: "touch", bubbles: true }),
);
await expect.poll(() => document.querySelector("[data-canvas-marquee]")).toBeNull();
expect(selection()).toEqual(["b"]);A finger dragged from an item moves that item, selecting it if it was not
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["b"]} snap={false} />);
const one = finger(24);
one.down(60, 50);
one.move(60, 70);
one.move(90, 120);
one.up(90, 120);
await expect.poll(selection).toEqual(["a"]);
expect(changed(onChanges)).toMatchObject([{ id: "a", rect: { x: 50, y: 90 } }]);A second finger does not take over a drag already under way
const onChanges = vi.fn();
const onViewportChange = vi.fn();
await render(<Board onChanges={onChanges} onViewportChange={onViewportChange} snap={false} />);
const one = finger(25);
const two = finger(26);
one.down(60, 50);
one.move(90, 50);
two.down(250, 250);
two.move(300, 280);
two.up(300, 280);
one.move(100, 50);
one.up(100, 50);
expect(onViewportChange).not.toHaveBeenCalled();
expect(changed(onChanges)).toMatchObject([{ id: "a", rect: { x: 60, y: 20 } }]);Moving
Dragging an item moves it by as far as the pointer goes
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(40, 30);
// It is where the pointer has taken it before the pointer lets go.
expect(shown(item("a"))).toEqual({ x: 60, y: 50, width: 80, height: 60 });
expect(el(item("a")).dataset.moving).toBe("");
// Its handles are out of the way while it moves.
expect(document.querySelector("[data-canvas-handle]")).toBeNull();
await commands.mouseUp();
expect(changed(onChanges)).toEqual([
{
id: "a",
rect: { x: 60, y: 50, width: 80, height: 60 },
previous: { x: 20, y: 20, width: 80, height: 60 },
},
]);
expect(shown(item("a"))).toEqual({ x: 60, y: 50, width: 80, height: 60 });Zoomed in, an item still moves as far on the screen as the pointer does
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} defaultViewport={{ x: 0, y: 0, zoom: 2 }} />);
await commands.dragBy(item("a"), 40, 30);
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 40, y: 35 });A press on an unselected item selects it, and the drag moves it alone
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["b", "c"]} snap={false} />);
await commands.dragBy(item("a"), 40, 30);
expect(selection()).toEqual(["a"]);
expect(changed(onChanges).map((change) => change.id)).toEqual(["a"]);Dragging one of several selected items moves them all together, and keeps them selected
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["a", "b"]} snap={false} />);
await commands.dragBy(item("b"), 10, 50);
expect(changed(onChanges).map((change) => [change.id, change.rect.x, change.rect.y])).toEqual([
["a", 30, 70],
["b", 150, 70],
]);
expect(selection()).toEqual(["a", "b"]);A press too short to be a drag moves nothing
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(2, 1, 1);
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
expect(selection()).toEqual(["a"]);Escape during a drag puts everything back, and no change is reported
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["a", "b"]} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(40, 30);
expect(shown(item("b")).x).toBe(180);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(shown(item("a"))).toMatchObject({ x: 20, y: 20 });
expect(shown(item("b"))).toMatchObject({ x: 140, y: 20 });
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
// The Escape that put them back did not also clear the selection.
expect(selection()).toEqual(["a", "b"]);Snapping
What is moved comes to rest in line with a neighbour, and the line is shown for as long as it holds
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} />);
// C's left edge to 137: three short of B's.
await commands.mouseDownAt(item("c"));
await commands.mouseMoveBy(117, 0);
expect(shown(item("c")).x).toBe(140);
// The line runs from the top of B to the bottom of C.
expect(shown('[data-canvas-guide="x"]')).toMatchObject({ x: 140, y: 20, height: 180 });
expect(document.querySelector('[data-canvas-guide="y"]')).toBeNull();
// Carried on out of reach, it lets go, and the line goes.
await commands.mouseMoveBy(20, 0);
expect(shown(item("c")).x).toBe(157);
expect(document.querySelector('[data-canvas-guide="x"]')).toBeNull();
await commands.mouseMoveBy(-20, 0);
await commands.mouseUp();
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 140, y: 140 });
expect(document.querySelector("[data-canvas-guide]")).toBeNull();Each direction snaps by itself, to a different neighbour
await render(<Board initial={[box("a", 20, 20), box("b", 200, 200), box("m", 100, 100, 40, 40)]} />);
// M's left edge to 23, by A's at 20; its bottom to 197, by B's top at 200.
await commands.mouseDownAt(item("m"));
await commands.mouseMoveBy(-77, 57);
expect(shown(item("m"))).toMatchObject({ x: 20, y: 160 });
expect(document.querySelector('[data-canvas-guide="x"]')).not.toBeNull();
expect(shown('[data-canvas-guide="y"]')).toMatchObject({ y: 200, x: 20, width: 260 });
await commands.mouseUp();The reach of a snap is measured on the screen: zoomed in, a thing rests a hair off the line
const onChanges = vi.fn();
const initial = [box("a", 20, 20, 40, 30), box("b", 100, 20, 40, 30), box("c", 20, 80, 40, 30)];
const screen = await render(<Board initial={initial} onChanges={onChanges} />);
// Four units short of B's left edge: within the six pixels a snap reaches.
await commands.dragBy(item("c"), 76, 0);
expect(changed(onChanges)[0]!.rect.x).toBe(100);
await screen.unmount();
// At twice the size four units are eight pixels: out of reach.
await render(
<Board initial={initial} onChanges={onChanges} defaultViewport={{ x: 0, y: 0, zoom: 2 }} />,
);
await commands.dragBy(item("c"), 152, 0);
expect(changed(onChanges)[0]!.rect.x).toBe(96);With Control or Command held, it moves freely; let go, it snaps again
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} />);
await commands.mouseDownAt(item("c"));
await commands.keyDown("Control");
await commands.mouseMoveBy(117, 0);
expect(shown(item("c")).x).toBe(137);
expect(document.querySelector("[data-canvas-guide]")).toBeNull();
await commands.keyUp("Control");
expect(shown(item("c")).x).toBe(140);
await commands.keyDown("Meta");
expect(shown(item("c")).x).toBe(137);
await commands.mouseUp();
await commands.keyUp("Meta");
expect(changed(onChanges)[0]!.rect.x).toBe(137);With Shift held, it keeps to the direction it has moved farther in
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.keyDown("Shift");
await commands.mouseMoveBy(60, 20);
expect(shown(item("a"))).toMatchObject({ x: 80, y: 20 });
await commands.mouseMoveBy(-50, 50);
expect(shown(item("a"))).toMatchObject({ x: 20, y: 90 });
await commands.keyUp("Shift");
expect(shown(item("a"))).toMatchObject({ x: 30, y: 90 });
await commands.mouseUp();Where there is a grid and no neighbour nearer, a corner comes to rest on it
const onChanges = vi.fn();
await render(<Board initial={[box("a", 20, 20)]} onChanges={onChanges} grid={10} />);
await commands.dragBy(item("a"), 43, 27);
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 60, y: 50 });Snapping can be turned off
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} />);
await commands.dragBy(item("c"), 117, 0);
expect(changed(onChanges)[0]!.rect.x).toBe(137);What is moved is not drawn back to where it was
const onChanges = vi.fn();
await render(<Board initial={[box("a", 20, 20), box("b", 200, 200)]} onChanges={onChanges} />);
// Five pixels: within reach of its own old edges, were they among the lines.
await commands.dragBy(item("a"), 5, 5);
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 25, y: 25 });A grid holds where snapping to neighbours is turned off, and Control moves free of it too
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} snap={false} grid={25} />);
// To 137: nearer B's edge at 140 than the grid's line at 125, were neighbours being asked.
await commands.mouseDownAt(item("c"));
await commands.mouseMoveBy(117, 0);
expect(shown(item("c")).x).toBe(125);
await commands.keyDown("Control");
expect(shown(item("c")).x).toBe(137);
await commands.keyUp("Control");
await commands.mouseUp();
expect(changed(onChanges)[0]!.rect.x).toBe(125);By the keyboard
The arrow keys move the selection by one unit, and by ten with Shift, never snapping
const onChanges = vi.fn();
// C's left edge is four short of B's: a drag would be drawn to it.
await render(<Board initial={[box("b", 140, 20), box("c", 136, 140)]} onChanges={onChanges} />);
await commands.clickSelector(item("c"));
await press("{ArrowRight}");
expect(changed(onChanges)).toEqual([
{
id: "c",
rect: { x: 137, y: 140, width: 80, height: 60 },
previous: { x: 136, y: 140, width: 80, height: 60 },
},
]);
await press("{Shift>}{ArrowUp}{/Shift}");
await press("{ArrowLeft}{ArrowDown}");
expect(shown(item("c"))).toMatchObject({ x: 136, y: 131 });
expect(onChanges).toHaveBeenCalledTimes(4);Every selected item moves, and each key is one change
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["a", "c"]} />);
await commands.clickSelector(item("a"), { modifiers: ["Shift"] });
await commands.clickSelector(item("a"), { modifiers: ["Shift"] });
await press("{ArrowDown}");
expect(onChanges).toHaveBeenCalledOnce();
expect(onChanges.mock.lastCall![1]).toBe("move");
expect(changed(onChanges).map((change) => [change.id, change.rect.y])).toEqual([
["a", 21],
["c", 141],
]);A move is announced with where the selection now is
const screen = await render(<Board defaultValue={["a", "c"]} />);
await commands.clickSelector(item("b"));
await press("{ArrowRight}");
await expect.element(screen.getByRole("status")).toHaveTextContent("Moved to 141, 20.");
await commands.clickSelector(item("a"), { modifiers: ["Shift"] });
await press("{Shift>}{ArrowDown}{/Shift}");
await expect.element(screen.getByRole("status")).toHaveTextContent("2 items moved to 20, 30.");What the keyboard moves out of view is brought back into it
await render(<Board />);
await commands.clickSelector(item("a"));
await press("{Shift>}{ArrowLeft}{ArrowLeft}{ArrowLeft}{/Shift}");
// Ten past the left edge on the plane, and a margin's width inside the view.
expect(shown(item("a")).x).toBe(24);Parts of an item
An item that names a part to be dragged by is dragged only by it; a press elsewhere on it still selects
const onChanges = vi.fn();
await render(<Board renderItem={renderNode} onChanges={onChanges} snap={false} />);
// By its lower edge, below the field.
await commands.mouseDownAt(item("a"), { x: 0.9, y: 0.9 });
await commands.mouseMoveBy(40, 30);
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();
expect(selection()).toEqual(["a"]);
await commands.dragBy('[data-testid="grip-a"]', 40, 30);
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 60, y: 50 });A field inside an item is typed in as anywhere, and a drag begun in it moves nothing
const onChanges = vi.fn();
const onValueChange = vi.fn();
await render(<Board renderItem={renderNode} onChanges={onChanges} onValueChange={onValueChange} />);
const field = el('[aria-label="A value"]') as HTMLInputElement;
await commands.clickSelector('[aria-label="A value"]');
// Space, an arrow and Command-A are the field's keys here, not the canvas's.
await press(`a b{ArrowLeft}c{${COMMAND}>}a{/${COMMAND}}`);
expect(field.value).toBe("a cb");
expect(onValueChange).not.toHaveBeenCalled();
await commands.dragBy('[aria-label="A value"]', 40, 30);
expect(onChanges).not.toHaveBeenCalled();Tab goes through what an item holds before going on to the next item
await render(<Board renderItem={renderNode} />);
await userEvent.click(document.querySelector<HTMLElement>('[aria-label="Before"]')!);
await press("{Tab}");
expect(focusedId()).toBe("a");
await press("{Tab}");
expect(document.activeElement?.getAttribute("aria-label")).toBe("A value");
await press("{Tab}");
await expect.poll(focusedId).toBe("b");
await press("{Shift>}{Tab}{/Shift}");
await expect.poll(focusedId).toBe("a");A drag with Alt held leaves the original where it was, and asks for a copy where the drag ended
const onChanges = vi.fn();
const onDuplicate = vi.fn();
await render(<Board onChanges={onChanges} onDuplicate={onDuplicate} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(40, 30);
expect(document.querySelector("[data-canvas-ghost]")).toBeNull();
await commands.keyDown("Alt");
// The original's place is marked while what is dragged shows where the copy will be.
expect(shown("[data-canvas-ghost]")).toEqual({ x: 20, y: 20, width: 80, height: 60 });
expect(el(CANVAS).dataset.mode).toBe("copy");
await commands.mouseUp();
await commands.keyUp("Alt");
expect(onDuplicate).toHaveBeenCalledOnce();
const [copy] = last<Array<{ source: string; rect: Rect }>>(onDuplicate);
expect(copy!.source).toBe("a");
expect(tidy(copy!.rect)).toEqual({ x: 60, y: 50, width: 80, height: 60 });
expect(onChanges).not.toHaveBeenCalled();
expect(shown(item("a"))).toMatchObject({ x: 20, y: 20 });At the edge of the view
A drag held near the edge of the view moves the view along, and what is dragged stays under the pointer
const onChanges = vi.fn();
const onViewportChange = vi.fn();
await render(<Board onChanges={onChanges} onViewportChange={onViewportChange} snap={false} />);
await commands.mouseDownAt(item("a"));
// To ten pixels from the right edge, and held there.
await commands.mouseMoveBy(270, 0);
await expect.poll(() => viewed(onViewportChange).x).toBeGreaterThan(40);
expect(viewed(onViewportChange).y).toBe(0);
const during = shown(item("a"));
expect(Math.abs(during.x - 290)).toBeLessThanOrEqual(1);
await commands.mouseUp();
const view = viewed(onViewportChange);
// As far as the pointer went, and as far again as the view went under it.
expect(Math.abs(changed(onChanges)[0]!.rect.x - (290 + view.x))).toBeLessThanOrEqual(1);
// Once the drag is over the view is still.
await pause(120);
expect(viewed(onViewportChange)).toEqual(view);However late a frame comes, what is dragged is drawn under the pointer in the frame the view moves
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} snap={false} />);
// Every frame as late as a frame is let be, and what is drawn looked at as each ends.
const frame = window.requestAnimationFrame.bind(window);
let time = 0;
const drawn: number[] = [];
const slow = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) =>
frame(() => {
time = Math.max(time, performance.now()) + 64;
callback(time);
if (viewed(onViewportChange).x > 0) drawn.push(shown(item("a")).x);
}),
);
try {
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(270, 0);
await expect.poll(() => viewed(onViewportChange).x).toBeGreaterThan(40);
await commands.mouseUp();
} finally {
slow.mockRestore();
}
expect(drawn.length).toBeGreaterThan(0);
expect(Math.max(...drawn.map((x) => Math.abs(x - 290)))).toBeLessThanOrEqual(1);A drag begun beside an edge does not move the view until it heads for that edge
const onViewportChange = vi.fn();
await render(
<Board initial={[box("a", 290, 20, 40, 60)]} onViewportChange={onViewportChange} snap={false} />,
);
// Thirty pixels from the right edge: within the margin where a drag moves the view.
await commands.mouseDownAt(item("a"));
// Down, along the edge it began beside.
await commands.mouseMoveBy(0, 80);
await pause(150);
expect(onViewportChange).not.toHaveBeenCalled();
await commands.mouseMoveBy(8, 0);
await expect.poll(() => viewed(onViewportChange).x).toBeGreaterThan(0);
await commands.mouseUp();A marquee drawn to the edge moves the view along, and goes on growing over what comes into view
const onViewportChange = vi.fn();
await render(
<Board initial={[...three(), box("far", 420, 190)]} onViewportChange={onViewportChange} />,
);
await commands.mouseDownAt(CANVAS, at(200, 180));
await commands.mouseMoveBy(132, 60);
await expect.poll(selection).toEqual(["far"]);
// Its first corner is a place on the plane: it has gone left with the plane.
expect(shown("[data-canvas-marquee]").x).toBeLessThan(200);
await commands.mouseUp();Escape during a drag that moved the view along puts the view back too
const onChanges = vi.fn();
const onViewportChange = vi.fn();
await render(<Board onChanges={onChanges} onViewportChange={onViewportChange} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(270, 0);
await expect.poll(() => viewed(onViewportChange).x).toBeGreaterThan(20);
await press("{Escape}");
await commands.mouseUp();
expect(viewed(onViewportChange)).toEqual({ x: 0, y: 0, zoom: 1 });
expect(shown(item("a"))).toMatchObject({ x: 20, y: 20 });
expect(onChanges).not.toHaveBeenCalled();Dragging the view itself to an edge moves it no further than the pointer went
const onViewportChange = vi.fn();
await render(<Board onViewportChange={onViewportChange} />);
await commands.mouseDownAt(CANVAS, { ...at(200, 200), button: "middle" });
await commands.mouseMoveBy(135, 0);
await pause(150);
await commands.mouseUp("middle");
expect(Math.abs(viewed(onViewportChange).x + 135)).toBeLessThanOrEqual(1);Resizing
A selected item shows a handle at each corner and in the middle of each side
await render(<Board initial={large()} />);
expect(document.querySelector("[data-canvas-handle]")).toBeNull();
await commands.clickSelector(item("a"));
const centre = (name: string) => {
const seen = shown(`${handle(name)} > div`);
return [seen.x + seen.width / 2, seen.y + seen.height / 2];
};
expect(["nw", "n", "ne", "e", "se", "s", "sw", "w"].map(centre)).toEqual([
[20, 20],
[80, 20],
[140, 20],
[140, 70],
[140, 120],
[80, 120],
[20, 120],
[20, 70],
]);Handles are sized on the screen: the same at any zoom
await render(
<Board initial={large()} defaultValue={["a"]} defaultViewport={{ x: 0, y: 0, zoom: 2 }} />,
);
expect(shown(`${handle("nw")} > div`)).toEqual({ x: 36, y: 36, width: 8, height: 8 });
expect(shown(`${handle("se")} > div`)).toMatchObject({ x: 276, y: 236, width: 8 });A side too short on the screen to carry a handle clear of its corners has none
await render(
<Board initial={large()} defaultValue={["a"]} defaultViewport={{ x: 0, y: 0, zoom: 0.25 }} />,
);
// 30 by 25 on the screen.
expect(
Array.from(
document.querySelectorAll<HTMLElement>("[data-canvas-handle]"),
(each) => each.dataset.canvasHandle,
),
).toEqual(hasTouch ? ["nw", "ne", "se", "sw"] : ["nw", "ne", "se", "sw"]);Under a finger a handle's target is 44 pixels across, and looks no largerChromium · touch, WebKit · touch
Not yet written.
A target reaches no more than a quarter of the way into a small item, which can still be taken by its middleChromium · touch, WebKit · touch
Not yet written.
Dragging a handle moves the edges it sits on, and leaves the opposite ones
const onChanges = vi.fn();
await render(<Board initial={large()} defaultValue={["a"]} onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(handle("se"));
await commands.mouseMoveBy(30, 20);
expect(shown(item("a"))).toEqual({ x: 20, y: 20, width: 150, height: 120 });
await commands.mouseUp();
expect(onChanges).toHaveBeenCalledOnce();
expect(changed(onChanges)).toEqual([
{
id: "a",
rect: { x: 20, y: 20, width: 150, height: 120 },
previous: { x: 20, y: 20, width: 120, height: 100 },
},
]);
expect(onChanges.mock.lastCall![1]).toBe("resize");
await commands.dragBy(handle("nw"), 10, 10);
expect(changed(onChanges)[0]!.rect).toEqual({ x: 30, y: 30, width: 140, height: 110 });
// Up and down as well: a side handle moves its side only.
await commands.dragBy(handle("w"), 10, 55);
expect(changed(onChanges)[0]!.rect).toEqual({ x: 40, y: 30, width: 130, height: 110 });With Alt held, it resizes about its centre
await render(<Board initial={large()} defaultValue={["a"]} snap={false} />);
await commands.mouseDownAt(handle("se"));
await commands.keyDown("Alt");
await commands.mouseMoveBy(15, 10);
expect(shown(item("a"))).toEqual({ x: 5, y: 10, width: 150, height: 120 });
await commands.keyUp("Alt");
expect(shown(item("a"))).toEqual({ x: 20, y: 20, width: 135, height: 110 });
await commands.mouseUp();With Shift held, it keeps its proportion
await render(<Board initial={large()} defaultValue={["a"]} snap={false} />);
await commands.mouseDownAt(handle("se"));
await commands.keyDown("Shift");
await commands.mouseMoveBy(30, 0);
expect(shown(item("a"))).toEqual({ x: 20, y: 20, width: 150, height: 125 });
await commands.keyUp("Shift");
await commands.mouseUp();An edge dragged past the one opposite stops at the least size: the item does not turn inside out
const onChanges = vi.fn();
await render(
<Board
initial={[box("a", 200, 20, 120, 100)]}
defaultValue={["a"]}
onChanges={onChanges}
snap={false}
minSize={10}
/>,
);
await commands.dragBy(handle("e"), -200, 0);
expect(changed(onChanges)[0]!.rect).toEqual({ x: 200, y: 20, width: 10, height: 100 });A resized edge comes to rest on a neighbour's line, and the line is shown
const onChanges = vi.fn();
await render(<Board initial={large()} defaultValue={["a"]} onChanges={onChanges} />);
await commands.mouseDownAt(handle("e"));
// On the way, in line with nothing, no line is shown.
await commands.mouseMoveBy(30, 0);
expect(shown(item("a")).width).toBe(150);
expect(document.querySelector("[data-canvas-guide]")).toBeNull();
// A's right edge to 197: three short of B's left.
await commands.mouseMoveBy(27, 0);
expect(shown(item("a")).width).toBe(180);
expect(shown('[data-canvas-guide="x"]')).toMatchObject({ x: 200, y: 20, height: 100 });
await commands.keyDown("Control");
expect(shown(item("a")).width).toBe(177);
expect(document.querySelector("[data-canvas-guide]")).toBeNull();
await commands.keyUp("Control");
await commands.mouseUp();
expect(changed(onChanges)[0]!.rect.width).toBe(180);Escape during a resize puts the size back
const onChanges = vi.fn();
await render(<Board initial={large()} defaultValue={["a"]} onChanges={onChanges} snap={false} />);
await commands.mouseDownAt(handle("se"));
await commands.mouseMoveBy(30, 20);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(shown(item("a"))).toEqual({ x: 20, y: 20, width: 120, height: 100 });
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();Several selected items show one frame, and resize in proportion within it
const onChanges = vi.fn();
await render(<Board initial={large()} defaultValue={["a", "c"]} onChanges={onChanges} snap={false} />);
expect(shown("[data-canvas-frame]")).toEqual({ x: 20, y: 20, width: 120, height: 240 });
await commands.dragBy(handle("e"), 60, 0);
expect(changed(onChanges).map((change) => change.rect)).toEqual([
{ x: 20, y: 20, width: 180, height: 100 },
{ x: 20, y: 160, width: 150, height: 100 },
]);
expect(shown("[data-canvas-frame]").width).toBe(180);No item of several goes below the least size: the frame stops where the smallest would
const onChanges = vi.fn();
await render(
<Board
initial={[box("a", 200, 20, 120, 100), box("c", 200, 160, 60, 100)]}
defaultValue={["a", "c"]}
onChanges={onChanges}
snap={false}
minSize={30}
/>,
);
await commands.dragBy(handle("e"), -200, 0);
expect(changed(onChanges).map((change) => change.rect.width)).toEqual([60, 30]);Command with the arrow keys resizes the selection from its top-left corner, by one unit or ten with Shift
const onChanges = vi.fn();
const screen = await render(<Board initial={large()} onChanges={onChanges} />);
await commands.clickSelector(item("a"));
await press(`{${COMMAND}>}{ArrowRight}{/${COMMAND}}`);
expect(onChanges.mock.lastCall).toEqual([
[
{
id: "a",
rect: { x: 20, y: 20, width: 121, height: 100 },
previous: { x: 20, y: 20, width: 120, height: 100 },
},
],
"resize",
]);
await expect.element(screen.getByRole("status")).toHaveTextContent("Resized to 121 by 100.");
await press(`{Shift>}{${COMMAND}>}{ArrowUp}{/${COMMAND}}{/Shift}`);
expect(changed(onChanges)[0]!.rect).toEqual({ x: 20, y: 20, width: 121, height: 90 });
// And not below the least size: a key that would is a key that does nothing.
await press(
`{Shift>}{${COMMAND}>}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{ArrowUp}{/${COMMAND}}{/Shift}`,
);
expect(changed(onChanges)[0]!.rect.height).toBe(1);A resize by the pointer is announced with the new size
const screen = await render(<Board initial={large()} defaultValue={["a"]} snap={false} />);
await commands.dragBy(handle("se"), 30, 20);
await expect.element(screen.getByRole("status")).toHaveTextContent("Resized to 150 by 120.");Constraints
An item dragged past a bound stops at it: every place it is shown at has passed the constraint
const onChanges = vi.fn();
const constrain = vi.fn(within);
// A canvas wider than the frame, so that the drag past the bound comes nowhere near the view's edge.
await render(
<Board
onChanges={onChanges}
constrain={constrain}
snap={false}
style={{ width: 480, height: 300 }}
/>,
);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(290, 10);
expect(shown(item("a"))).toMatchObject({ x: 220, y: 30 });
await commands.mouseUp();
expect(changed(onChanges)[0]!.rect).toMatchObject({ x: 220, y: 30 });
expect(constrain.mock.lastCall![0]).toBe("a");
expect(tidy(constrain.mock.lastCall![1])).toEqual({ x: 310, y: 30, width: 80, height: 60 });
expect(constrain.mock.lastCall![2]).toBe("move");A resize is held by the constraint too, and told that it is a resize
const onChanges = vi.fn();
const constrain = vi.fn((_: string, rect: Rect, _kind: string): Rect => ({
...rect,
width: Math.min(rect.width, 150),
}));
await render(
<Board
initial={large()}
defaultValue={["a"]}
onChanges={onChanges}
constrain={constrain}
snap={false}
/>,
);
await commands.dragBy(handle("e"), 100, 0);
expect(changed(onChanges)[0]!.rect.width).toBe(150);
expect(constrain.mock.lastCall![2]).toBe("resize");The keyboard is held by the same constraint, and a key that changes nothing reports nothing
const onChanges = vi.fn();
await render(<Board initial={[box("a", 1, 20)]} onChanges={onChanges} constrain={within} />);
await commands.clickSelector(item("a"));
await press("{ArrowLeft}{ArrowLeft}{ArrowLeft}");
expect(onChanges).toHaveBeenCalledOnce();
expect(shown(item("a")).x).toBe(0);A guide is shown only where the item, as the constraint left it, lies on the line
// The constraint keeps C from reaching the line it would snap to.
const constrain = (_: string, rect: Rect): Rect => ({ ...rect, x: Math.min(rect.x, 130) });
await render(<Board constrain={constrain} />);
await commands.mouseDownAt(item("c"));
await commands.mouseMoveBy(117, 0);
expect(shown(item("c")).x).toBe(130);
expect(document.querySelector("[data-canvas-guide]")).toBeNull();
await commands.mouseUp();Keyboard and focus
Tab into the canvas lands on one item: the first in reading order, wherever it is among the items
await render(<Board initial={three().toReversed()} />);
await before();
await press("{Tab}");
expect(focusedId()).toBe("a");
expect(document.querySelectorAll('[data-canvas-item][tabindex="0"]').length).toBe(1);Tab and Shift-Tab move through the items in reading order, and past either end leave the canvas
await render(<Board initial={three().toReversed()} />);
await before();
const seen: Array<string | null | undefined> = [];
for (let step = 0; step < 4; step += 1) {
await press("{Tab}");
await settled();
seen.push(focusedId() ?? document.activeElement?.getAttribute("aria-label"));
}
expect(seen).toEqual(["a", "b", "c", "After"]);
seen.length = 0;
for (let step = 0; step < 4; step += 1) {
await press("{Shift>}{Tab}{/Shift}");
await settled();
seen.push(focusedId() ?? document.activeElement?.getAttribute("aria-label"));
}
expect(seen).toEqual(["c", "b", "a", "Before"]);Moving focus does not change the selection
await render(<Board defaultValue={["c"]} />);
await before();
await press("{Tab}");
// The tab stop is the selected item, where nothing was focused before.
expect(focusedId()).toBe("c");
await press("{Shift>}{Tab}{/Shift}");
await expect.poll(focusedId).toBe("b");
expect(selection()).toEqual(["c"]);Home and End go to the first and the last item
await render(<Board />);
await before();
await press("{Tab}{End}");
await expect.poll(focusedId).toBe("c");
await press("{Home}");
await expect.poll(focusedId).toBe("a");Space selects the focused item or takes it out of the selection, and says how many are selected
const screen = await render(<Board />);
await before();
await press("{Tab} ");
expect(selection()).toEqual(["a"]);
await press("{Tab}");
await expect.poll(focusedId).toBe("b");
await press(" ");
expect(selection()).toEqual(["a", "b"]);
await expect.element(screen.getByRole("status")).toHaveTextContent("2 selected.");
await press(" ");
expect(selection()).toEqual(["a"]);
await press("{Escape}");
await expect.element(screen.getByRole("status")).toHaveTextContent("Nothing selected.");The keyboard alone moves an item: Tab to it, Space, then the arrows
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} />);
await before();
await press("{Tab}{Tab}");
await expect.poll(focusedId).toBe("b");
await press(" {ArrowDown}");
expect(changed(onChanges)).toMatchObject([{ id: "b", rect: { x: 140, y: 21 } }]);
// Focus is still on what was moved.
expect(focusedId()).toBe("b");An item reached by the keyboard is brought into view, by the least movement
const onViewportChange = vi.fn();
await render(
<Board initial={[box("a", 20, 20), box("far", 900, 700)]} onViewportChange={onViewportChange} />,
);
await before();
await press("{Tab}");
// Already in view: the view stays.
expect(onViewportChange).not.toHaveBeenCalled();
await press("{Tab}");
await expect.poll(focusedId).toBe("far");
// Its far corner a margin's width inside the view's.
await expect.poll(() => shown(item("far"))).toMatchObject({ x: 340 - 24 - 80, y: 300 - 24 - 60 });An item taken by the pointer is not moved under it by being focused
const onViewportChange = vi.fn();
await render(<Board initial={[box("edge", 300, 20)]} onViewportChange={onViewportChange} />);
await commands.clickSelector(item("edge"), { position: { x: 10, y: 10 } });
expect(focusedId()).toBe("edge");
expect(onViewportChange).not.toHaveBeenCalled();Once the press is over, an item given focus by the application is brought into view
await render(<Board initial={[box("a", 20, 20), box("far", 900, 700)]} />);
await commands.clickSelector(item("a"));
el(item("far")).focus();
await expect.poll(() => shown(item("far"))).toMatchObject({ x: 340 - 24 - 80, y: 300 - 24 - 60 });Focus returns to the item it was last on
await render(<Board />);
await before();
await press("{Tab}{Tab}");
await expect.poll(focusedId).toBe("b");
await userEvent.click(document.querySelector<HTMLElement>('[aria-label="After"]')!);
await press("{Shift>}{Tab}{/Shift}");
expect(focusedId()).toBe("b");Right to left, reading order starts from the right; the plane and the arrow keys do not mirror
const onChanges = vi.fn();
await render(
<div dir="rtl">
<Board onChanges={onChanges} />
</div>,
);
// The plane's corner is still the canvas's top-left.
expect(shown(item("a"))).toMatchObject({ x: 20, y: 20 });
await before();
await press("{Tab}");
expect(focusedId()).toBe("b");
await press("{Tab}");
await expect.poll(focusedId).toBe("a");
await press(" {ArrowRight}");
expect(changed(onChanges)).toMatchObject([{ id: "a", rect: { x: 21, y: 20 } }]);When the item that has focus is taken away, focus stays in the canvas
function Fixed({ items }: { items: Box[] }) {
return (
<Canvas<Box>
aria-label="Board"
items={items}
renderItem={renderBox}
style={{ width: 340, height: 300 }}
/>
);
}
const screen = await render(<Fixed items={three()} />);
await commands.clickSelector(item("b"));
expect(focusedId()).toBe("b");
await screen.rerender(<Fixed items={three().filter((each) => each.id !== "b")} />);
await expect.poll(focusedId).toBe("a");
await screen.rerender(<Fixed items={[]} />);
await expect.poll(() => document.activeElement).toBe(el(CANVAS));A canvas with nothing on it takes focus itself
await render(<Board initial={[]} />);
await before();
await press("{Tab}");
expect(document.activeElement).toBe(el(CANVAS));Alt with an arrow key moves focus to the nearest item in that direction, and changes nothing else
const onChanges = vi.fn();
const onValueChange = vi.fn();
const onViewportChange = vi.fn();
await render(
<Board onChanges={onChanges} onValueChange={onValueChange} onViewportChange={onViewportChange} />,
);
await before();
await press("{Tab}");
expect(focusedId()).toBe("a");
await press("{Alt>}{ArrowRight}{/Alt}");
await expect.poll(focusedId).toBe("b");
// C is below B and to its left: the nearest that lies downward.
await press("{Alt>}{ArrowDown}{/Alt}");
await expect.poll(focusedId).toBe("c");
await press("{Alt>}{ArrowUp}{/Alt}");
await expect.poll(focusedId).toBe("a");
expect(onChanges).not.toHaveBeenCalled();
expect(onValueChange).not.toHaveBeenCalled();
expect(onViewportChange).not.toHaveBeenCalled();Where nothing lies that way focus stays, and the key is still the canvas's: the browser does not go back
await render(<Board />);
await before();
await press("{Tab}");
const prevented: boolean[] = [];
const hear = (event: KeyboardEvent) => prevented.push(event.defaultPrevented);
document.addEventListener("keydown", hear);
await press("{Alt>}{ArrowLeft}{/Alt}");
document.removeEventListener("keydown", hear);
await settled();
expect(focusedId()).toBe("a");
// Alt going down, then the arrow.
expect(prevented).toEqual([false, true]);Alt with an arrow key in a field inside an item is the field's
const renderField = (node: Box) => (
<CanvasItem aria-label={node.name}>
<input aria-label={`${node.name} value`} defaultValue="one two" style={{ width: 60 }} />
</CanvasItem>
);
await render(<Board renderItem={renderField} />);
await commands.clickSelector('[aria-label="A value"]');
await press("{Alt>}{ArrowRight}{/Alt}");
await settled();
expect(document.activeElement?.getAttribute("aria-label")).toBe("A value");Layers
What is drawn in a layer moves and scales with the plane: beneath the items, or over them
await render(
<Board below={mark("under")} above={mark("over")} defaultViewport={{ x: -10, y: 5, zoom: 2 }} />,
);
expect(shown(marked("under"))).toEqual(shown(item("a")));
expect(shown(marked("over"))).toEqual({ x: 60, y: 30, width: 160, height: 120 });
const order = (one: string, other: string) =>
el(one).compareDocumentPosition(el(other)) & Node.DOCUMENT_POSITION_FOLLOWING;
expect(order(marked("under"), item("a"))).toBeTruthy();
expect(order(item("c"), marked("over"))).toBeTruthy();A layer is told where what is being dragged is shown, for as long as the drag lasts
const told = (layer: CanvasLayer) => (
<output data-testid="told">
{JSON.stringify([...(layer.moving ?? [])].map(([id, r]) => [id, r.x, r.y]))}
</output>
);
await render(<Board below={told} snap={false} />);
expect(el(marked("told")).textContent).toBe("[]");
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(40, 30);
const [id, x, y] = (JSON.parse(el(marked("told")).textContent) as Array<[string, number, number]>)[0]!;
expect([id, Math.round(x), Math.round(y)]).toEqual(["a", 60, 50]);
await commands.mouseUp();
expect(el(marked("told")).textContent).toBe("[]");A hundred million units from zero, a layer draws from a point near the view, in small numbers
const far = 100_000_000;
let origin = { x: 0, y: 0 };
const farMark = (layer: CanvasLayer) => {
origin = layer.origin;
return (
<div
data-testid="far"
style={{
position: "absolute",
left: far + 20 - layer.origin.x,
top: far + 20 - layer.origin.y,
width: 80,
height: 60,
}}
/>
);
};
await render(<Board below={farMark} defaultViewport={{ x: far, y: far, zoom: 1 }} />);
expect(shown(marked("far"))).toEqual({ x: 20, y: 20, width: 80, height: 60 });
expect(Math.abs(far - origin.x)).toBeLessThanOrEqual(4096);Robustness
An item a hundred million units from zero is drawn where it should be
const far = 100_000_000;
await render(
<Board
initial={[box("a", far + 20, far + 20)]}
defaultViewport={{ x: far, y: far, zoom: 1 }}
snap={false}
/>,
);
expect(shown(item("a"))).toEqual({ x: 20, y: 20, width: 80, height: 60 });
// And nothing placed is a large number: neither the item nor the plane it is on.
expect(Math.abs(Number.parseFloat(el(item("a")).style.left))).toBeLessThan(8192);
expect(el("[data-canvas-plane]").style.transform).toMatch(
/^translate\(-?\d{1,4}(\.\d+)?px, -?\d{1,4}(\.\d+)?px\)/,
);
await commands.dragBy(item("a"), 40, 30);
expect(shown(item("a"))).toEqual({ x: 60, y: 50, width: 80, height: 60 });An item taken away in the middle of a drag is not among the changes
const onItemsChange = vi.fn();
function Fixed({ items }: { items: Box[] }) {
return (
<Canvas<Box>
aria-label="Board"
items={items}
defaultValue={["a", "b"]}
onItemsChange={onItemsChange}
renderItem={renderBox}
snap={false}
style={{ width: 340, height: 300 }}
/>
);
}
const screen = await render(<Fixed items={three()} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(30, 30);
await screen.rerender(
<Fixed items={[...three().filter((each) => each.id !== "b"), box("d", 240, 200)]} />,
);
await commands.mouseMoveBy(10, 0);
expect(shown(item("d"))).toMatchObject({ x: 240, y: 200 });
await commands.mouseUp();
expect(changed(onItemsChange)).toMatchObject([{ id: "a", rect: { x: 60, y: 50 } }]);When its owner turns a change down, the item is shown where its owner says it is
const onItemsChange = vi.fn();
const items = three();
await render(
<Canvas<Box>
aria-label="Board"
items={items}
onItemsChange={onItemsChange}
renderItem={renderBox}
snap={false}
style={{ width: 340, height: 300 }}
/>,
);
await commands.dragBy(item("a"), 40, 30);
expect(onItemsChange).toHaveBeenCalledOnce();
expect(shown(item("a"))).toMatchObject({ x: 20, y: 20 });A canvas with no size draws its items, and its keys do no harm
const onViewportChange = vi.fn();
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await render(<Board style={{ width: 0, height: 0 }} onViewportChange={onViewportChange} />);
expect(document.querySelectorAll("[data-canvas-item]").length).toBe(3);
el(item("a")).focus();
await commands.keyDown("Shift");
await commands.keyDown("Digit1");
await commands.keyUp("Digit1");
await commands.keyUp("Shift");
// There is nothing to fit anything into.
expect(onViewportChange).not.toHaveBeenCalled();
await press(`{${COMMAND}>}={/${COMMAND}}`);
expect(last<Viewport>(onViewportChange)).toEqual({ x: 0, y: 0, zoom: 1.5 });
expect(errors).not.toHaveBeenCalled();
errors.mockRestore();Unmounting in the middle of a drag leaves no listener behind
const onChanges = vi.fn();
const screen = await render(<Board onChanges={onChanges} />);
const selecting = () => {
const event = new Event("selectstart", { bubbles: true, cancelable: true });
document.dispatchEvent(event);
return event.defaultPrevented;
};
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(30, 30);
expect(selecting()).toBe(true);
await screen.unmount();
expect(selecting()).toBe(false);
await commands.mouseMoveBy(30, 30);
await commands.mouseUp();
expect(onChanges).not.toHaveBeenCalled();Unmounting with fingers down leaves no listener behind
const onViewportChange = vi.fn();
const screen = await render(<Board onViewportChange={onViewportChange} />);
const one = finger(41);
const two = finger(42);
one.down(100, 200);
two.down(200, 200);
two.move(220, 200);
const calls = onViewportChange.mock.calls.length;
expect(calls).toBeGreaterThan(0);
await screen.unmount();
document.dispatchEvent(
new PointerEvent("pointermove", { pointerId: 42, pointerType: "touch", clientX: 9, clientY: 9 }),
);
expect(onViewportChange.mock.calls.length).toBe(calls);API
onItemsChange hears one gesture once, when it ends, with each item's place before and after and the kind of change
const onChanges = vi.fn();
await render(<Board onChanges={onChanges} defaultValue={["a", "b"]} snap={false} />);
await commands.mouseDownAt(item("a"));
await commands.mouseMoveBy(10, 0);
await commands.mouseMoveBy(10, 0);
await commands.mouseMoveBy(10, 5);
expect(onChanges).not.toHaveBeenCalled();
await commands.mouseUp();
expect(onChanges).toHaveBeenCalledOnce();
expect(changed(onChanges)).toEqual([
{
id: "a",
rect: { x: 50, y: 25, width: 80, height: 60 },
previous: { x: 20, y: 20, width: 80, height: 60 },
},
{
id: "b",
rect: { x: 170, y: 25, width: 80, height: 60 },
previous: { x: 140, y: 20, width: 80, height: 60 },
},
]);
expect(onChanges.mock.lastCall![1]).toBe("move");applyItemChanges gives the items after changes, sharing those untouched; invertItemChanges puts them back
const items = three();
const changes = [{ id: "b", rect: { x: 1, y: 2, width: 3, height: 4 }, previous: items[1]!.rect }];
const next = applyItemChanges(items, changes);
expect(next.map((each) => each.rect.x)).toEqual([20, 1, 20]);
expect(next[0]).toBe(items[0]);
expect(next[1]!.name).toBe("B");
expect(applyItemChanges(next, invertItemChanges(changes))).toEqual(items);
// Changes that change nothing give back the same list.
expect(applyItemChanges(items, [{ id: "zz", rect: items[0]!.rect, previous: items[0]!.rect }])).toBe(
items,
);Without items, the canvas keeps them itself from defaultItems, and still reports changes
const onItemsChange = vi.fn();
await render(
<Canvas<Box>
aria-label="Board"
defaultItems={three()}
onItemsChange={onItemsChange}
renderItem={renderBox}
snap={false}
style={{ width: 340, height: 300 }}
/>,
);
await commands.dragBy(item("a"), 40, 30);
expect(shown(item("a"))).toMatchObject({ x: 60, y: 50 });
expect(onItemsChange).toHaveBeenCalledOnce();value holds the selection: with it, the canvas only asks, in the items' order
const onValueChange = vi.fn();
await render(<Board value={["c"]} onValueChange={onValueChange} />);
await commands.clickSelector(item("a"), { modifiers: ["Shift"] });
expect(onValueChange).toHaveBeenCalledWith(["a", "c"]);
expect(selection()).toEqual(["c"]);viewport holds the view: with it, the canvas only asks, and believes the answer
const onViewportChange = vi.fn();
await render(<Board viewport={{ x: 10, y: 0, zoom: 1 }} onViewportChange={onViewportChange} />);
await clickEmpty();
await press("{ArrowRight}");
await press("{ArrowRight}");
// Asked twice for the same step: the first was not granted.
expect(onViewportChange.mock.calls).toEqual([[{ x: 58, y: 0, zoom: 1 }], [{ x: 58, y: 0, zoom: 1 }]]);
expect(shown(item("a")).x).toBe(10);onAction hears Enter on an item, and a double click
const onAction = vi.fn();
await render(<Board onAction={onAction} />);
await userEvent.click(document.querySelector<HTMLElement>('[aria-label="Before"]')!);
await press("{Tab}{Enter}");
expect(onAction.mock.calls).toEqual([["a"]]);
await userEvent.dblClick(el(item("b")));
expect(onAction.mock.calls).toEqual([["a"], ["b"]]);messages says everything in other words
const screen = await render(
<Board
defaultValue={["a"]}
messages={{
instructions: "Mit Tab zwischen Elementen wechseln.",
selected: "ausgewählt",
moved: ({ x, y }) => `Verschoben nach ${x}, ${y}.`,
}}
/>,
);
await expect
.element(screen.getByRole("application"))
.toHaveAccessibleDescription("Mit Tab zwischen Elementen wechseln.");
await expect
.element(screen.getByRole("group", { name: "A" }))
.toHaveAccessibleDescription("ausgewählt");
await commands.clickSelector(item("a"));
await press("{ArrowRight}");
await expect.element(screen.getByRole("status")).toHaveTextContent("Verschoben nach 21, 20.");What an application puts on the canvas and marks data-canvas-interactive keeps its own presses
const onPressed = vi.fn();
await render(
<Board defaultValue={["a"]}>
<button
type="button"
data-canvas-interactive=""
onClick={onPressed}
style={{ position: "absolute", right: 8, bottom: 8 }}
>
Tidy
</button>
</Board>,
);
await commands.clickSelector("button");
expect(onPressed).toHaveBeenCalledOnce();
// Not a click on empty space, which would have cleared the selection.
expect(selection()).toEqual(["a"]);A handler given to the canvas runs first, and prevents the canvas's own by preventing the default
const onChanges = vi.fn();
await render(
<Board
onChanges={onChanges}
onKeyDown={(event) => {
if (event.key === "ArrowRight") event.preventDefault();
}}
/>,
);
await commands.clickSelector(item("a"));
await press("{ArrowRight}");
expect(onChanges).not.toHaveBeenCalled();
await press("{ArrowLeft}");
expect(onChanges).toHaveBeenCalledOnce();Without a name, a warning says so while developing
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await render(<Canvas items={three()} renderItem={renderBox} style={{ width: 100, height: 100 }} />);
expect(errors).toHaveBeenCalledWith(expect.stringContaining("give the canvas a name"));
errors.mockClear();
await render(<Canvas aria-label="Named" items={three()} renderItem={renderBox} />);
expect(errors).not.toHaveBeenCalled();
errors.mockRestore();api gives the point of the plane under a place on the screen, brings a part of the plane into view, and says a thing politely
const api = createRef<CanvasApi>();
const onViewportChange = vi.fn();
const screen = await render(
<Board api={api} defaultViewport={{ x: 10, y: 20, zoom: 2 }} onViewportChange={onViewportChange} />,
);
const corner = el(CANVAS).getBoundingClientRect();
expect(api.current!.toPlane(corner.left + 100, corner.top + 50)).toEqual({ x: 60, y: 45 });
api.current!.reveal({ x: 1000, y: 20, width: 10, height: 10 });
const view = last<Viewport>(onViewportChange);
expect(view.x + 340 / 2).toBeGreaterThan(1010);
expect(view.zoom).toBe(2);
api.current!.announce("Joined.");
await expect.element(screen.getByRole("status")).toHaveTextContent("Joined.");api.dragAt moves the view along under a drag that is not the canvas's own, says each time it has, and stops when told the drag is over
const api = createRef<CanvasApi>();
const follow = vi.fn();
const onViewportChange = vi.fn();
await render(<Board api={api} onViewportChange={onViewportChange} />);
const corner = el(CANVAS).getBoundingClientRect();
api.current!.dragAt({ x: corner.left + 170, y: corner.top + 295, dx: 0, dy: 60 }, follow);
await expect.poll(() => follow.mock.calls.length).toBeGreaterThan(2);
expect(last<Viewport>(onViewportChange).y).toBeGreaterThan(0);
expect(last<Viewport>(onViewportChange).x).toBe(0);
api.current!.dragAt(null);
const calls = follow.mock.calls.length;
await new Promise((resolve) => setTimeout(resolve, 120));
expect(follow.mock.calls.length).toBe(calls);A CanvasItem outside a canvas says where it belongs
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(render(<CanvasItem />)).rejects.toThrow("CanvasItem must be rendered by a Canvas");
errors.mockRestore();React
Install
npx shadcn@latest add https://ui.publictheta.com/r/canvas.jsonSource
src/components/canvas/canvas.tsx
"use client";
/**
* A plane that pans and zooms, on which things are placed, selected, moved and
* resized by hand. It is the wiring of its primitives: `viewport` for the view,
* `selection` for what a press selects, `drag` for every gesture, `snap` and
* `resize` for where a gesture leaves things, and the spatial index for what is
* in view and under a marquee.
*
* Parts: `Canvas`, which is given the items and how to draw one; `CanvasItem`,
* what `renderItem` returns; `CanvasItemHandle`, the part of an item it is
* dragged by, where it should not be dragged by all of it. Anything else an
* application puts inside the canvas that takes presses of its own is marked
* `data-canvas-interactive`. What it draws on the plane itself — the lines
* between things — goes in a layer, `below` the items or `above` them.
*
* State, for styles: on the canvas `data-mode` (`idle`, `pan`, `marquee`,
* `move`, `resize`, `copy`) and `data-pan` (`ready`, `active`); on an item
* `data-selected`, `data-locked`, `data-moving`. The plane carries
* `--canvas-zoom`, to divide by for what should keep its size on the screen.
*/
import {
createContext,
memo,
use,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useReducer,
useMemo,
useRef,
useState,
} from "react";
import type {
ComponentProps,
CSSProperties,
FocusEvent,
KeyboardEvent,
MouseEvent,
PointerEvent as ReactPointerEvent,
ReactElement,
ReactNode,
Ref,
} from "react";
import {
nearestInDirection,
readingOrder,
rectContains,
rectFrom,
rectsEqual,
rectTranslate,
rectUnion,
} from "../../algorithms/geometry.ts";
import { flushSync } from "react-dom";
import type { Point, Rect, Size } from "../../algorithms/geometry.ts";
import { spatialIndexFromRects, spatialSearch } from "../../algorithms/spatial-index.ts";
import { cn } from "../../lib/utils.ts";
import { focusRingInsetClassName } from "../../lib/focus-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { isApplePlatform, isCommandKey } from "../../lib/platform.ts";
import { useCoarsePointer } from "../../lib/pointer.ts";
import { useAnnouncer } from "../../primitives/announcements.ts";
import { useDrag } from "../../primitives/drag.ts";
import type { Drag, DragPress } from "../../primitives/drag.ts";
import {
RESIZE_HANDLES,
handleEdges,
handleTargetOffset,
resizeRect,
resizeWithin,
sideHandleFits,
} from "../../primitives/resize.ts";
import type { ResizeHandle } from "../../primitives/resize.ts";
import { applyIntent, inOrder, pointerIntent, sameSelection } from "../../primitives/selection.ts";
import type { SelectionIntent, SelectionState } from "../../primitives/selection.ts";
import { guideExtent, onLine, snapLines, snapMove, snapValue } from "../../primitives/snap.ts";
import type { SnapLines } from "../../primitives/snap.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import {
DEFAULT_ZOOM_LIMITS,
IDENTITY,
edgePan,
fit,
pan,
reveal,
toPlane,
toScreenRect,
viewOrigin,
visibleRect,
wheelIntent,
zoomBy,
zoomStop,
zoomTo,
} from "../../primitives/viewport.ts";
import type { Viewport, ZoomLimits } from "../../primitives/viewport.ts";
export type { Rect, Viewport };
/** Something placed on the plane. An application's own item carries whatever else it needs. */
export interface CanvasItemData {
readonly id: string;
/** Where it is and how large, in the plane's units. */
readonly rect: Rect;
/** Pinned against change: not moved, resized or caught by a marquee; still focusable. */
readonly locked?: boolean | undefined;
}
/** One item's change: which, where it is now, and where it was. */
export interface CanvasItemChange {
readonly id: string;
readonly rect: Rect;
readonly previous: Rect;
}
/** A copy asked for by a drag with Alt: of which item, and where it was dropped. */
export interface CanvasCopy {
readonly source: string;
readonly rect: Rect;
}
export type CanvasChangeKind = "move" | "resize";
/** The items after changes. Items the changes do not touch are the same objects. */
export function applyItemChanges<T extends CanvasItemData>(
items: readonly T[],
changes: readonly CanvasItemChange[],
): readonly T[] {
const rects = new Map(changes.map((change) => [change.id, change.rect]));
let changed = false;
const next = items.map((item) => {
const rect = rects.get(item.id);
if (rect === undefined || rectsEqual(rect, item.rect)) return item;
changed = true;
return { ...item, rect };
});
return changed ? next : items;
}
/** The changes that put back what these changed: all that undo needs. */
export function invertItemChanges(changes: readonly CanvasItemChange[]): CanvasItemChange[] {
return changes.map(({ id, rect, previous }) => ({ id, rect: previous, previous: rect }));
}
/** What a layer on the plane is drawn from. */
export interface CanvasLayer {
/**
* The point of the plane the layer's own corner is at: near the view, so that
* what is drawn at its place less this is drawn in small numbers however far
* from zero it is. It changes only when the view has travelled a long way.
*/
readonly origin: Point;
readonly viewport: Viewport;
/** The size of the view, on the screen. */
readonly size: Size;
/** Where each item being moved or resized is shown, for as long as the gesture lasts. */
readonly moving: ReadonlyMap<string, Rect> | null;
}
/** As much of a drag as says whether the view should move along: where it is, and how far it has come. */
export type CanvasDragPlace = Pick<Drag, "x" | "y" | "dx" | "dy">;
/** What an application that draws on the canvas, or drags over it, asks of it. */
export interface CanvasApi {
/** The point of the plane under a place in the browser's viewport. */
readonly toPlane: (clientX: number, clientY: number) => Point;
/** Bring a part of the plane into view, by the least movement. */
readonly reveal: (rect: Rect) => void;
/** Say something to assistive technology, politely. */
readonly announce: (text: string) => void;
/**
* A drag of the application's own is here: near an edge of the view, the view
* moves along, and `follow` is called each time it has. `null` when the drag
* is over. `stride` is the most the view moves between one `follow` and the
* next, for a drag that looks for something as small as that under it.
*/
readonly dragAt: (place: CanvasDragPlace | null, follow?: () => void, stride?: number) => void;
}
/** What the canvas says in words; pass `messages` to say it in another language. */
export interface CanvasMessages {
/** How the keyboard works here: read with the canvas's name. */
readonly instructions: string;
/** Said of an item that is selected, and of one that is locked. */
readonly selected: string;
readonly locked: string;
/** After a move: where the moved things' top-left corner now is, and how many moved. */
readonly moved: (position: Point, count: number) => string;
/** After a resize: the size the resized things now take up together, and how many. */
readonly resized: (size: Size, count: number) => string;
/** After the keyboard changed the selection: how many are selected. */
readonly selection: (count: number) => string;
}
const round = (value: number) => Math.round(value * 100) / 100;
const MESSAGES: CanvasMessages = {
instructions:
"Tab and Shift-Tab move between items; Alt with an arrow key moves to the nearest item that way. Space selects. Arrow keys move the selection, or the view when nothing is selected; with Shift, by ten. Command or Control with the arrow keys resizes. Escape clears the selection.",
selected: "selected",
locked: "locked",
moved: ({ x, y }, count) =>
count === 1 ? `Moved to ${round(x)}, ${round(y)}.` : `${count} items moved to ${round(x)}, ${round(y)}.`,
resized: ({ width, height }, count) =>
count === 1
? `Resized to ${round(width)} by ${round(height)}.`
: `${count} items resized to ${round(width)} by ${round(height)}.`,
selection: (count) => (count === 0 ? "Nothing selected." : `${count} selected.`),
};
export type CanvasProps<T extends CanvasItemData = CanvasItemData> = Omit<
ComponentProps<"div">,
"defaultValue" | "onChange"
> & {
/** The items. Given, the owner keeps them and applies `onItemsChange`; else they start at `defaultItems`. */
items?: readonly T[] | undefined;
defaultItems?: readonly T[] | undefined;
/** One gesture's changes, once, when it ends: every item moved or resized, with where it was. */
onItemsChange?: ((changes: readonly CanvasItemChange[], kind: CanvasChangeKind) => void) | undefined;
/** What an item looks like: a `CanvasItem` around its content. Keep it the same function between renders. */
renderItem: (item: T) => ReactNode;
/** What is drawn on the plane beneath the items, and over them: it moves and scales with them. */
below?: ((layer: CanvasLayer) => ReactNode) | undefined;
above?: ((layer: CanvasLayer) => ReactNode) | undefined;
api?: Ref<CanvasApi> | undefined;
/** The selected items' ids. */
value?: readonly string[] | undefined;
defaultValue?: readonly string[] | undefined;
onValueChange?: ((value: string[]) => void) | undefined;
viewport?: Viewport | undefined;
defaultViewport?: Viewport | undefined;
onViewportChange?: ((viewport: Viewport) => void) | undefined;
zoomLimits?: ZoomLimits | undefined;
/** Whether what is moved or resized is drawn to the edges and middles of what is around it. */
snap?: boolean | undefined;
/** A grid, in the plane's units, that corners come to rest on where nothing nearer draws them. */
grid?: number | undefined;
/** The least width and height a resize leaves, in the plane's units. */
minSize?: number | undefined;
/** Every place an item is about to be shown at passes through this: bounds, lanes, a grid. */
constrain?: ((id: string, rect: Rect, kind: CanvasChangeKind) => Rect) | undefined;
/** A drag with Alt held asks for copies where it ends, and leaves the originals where they were. */
onDuplicate?: ((copies: readonly CanvasCopy[]) => void) | undefined;
/** An item's primary action: Enter, or a double click. */
onAction?: ((id: string) => void) | undefined;
messages?: Partial<CanvasMessages> | undefined;
};
// Past this many items, only those in and around the view are drawn.
const CULL_ABOVE = 500;
// How far beyond the view items are still drawn, on the screen: a scroll's worth, so they are there before they are seen.
const CULL_MARGIN = 256;
// Sizes on the screen, whatever the zoom.
const SNAP_REACH = 6;
const REVEAL_MARGIN = 24;
const FIT_PADDING = 32;
const PAN_STEP = 48;
const HANDLE_SIZE = 8;
const TARGET_FINE = 16;
const TARGET_COARSE = 44;
const LONG_PRESS = 500;
const NO_ITEMS: readonly never[] = [];
const NO_IDS: readonly string[] = [];
const BUTTONS: readonly number[] = [0, 1];
const INTERACTIVE =
'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [data-canvas-interactive]';
const TABBABLE =
'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])';
const CURSORS: Record<ResizeHandle, string> = {
nw: "nwse-resize",
n: "ns-resize",
ne: "nesw-resize",
e: "ew-resize",
se: "nwse-resize",
s: "ns-resize",
sw: "nesw-resize",
w: "ew-resize",
};
interface CanvasContextValue {
readonly scope: string;
readonly selectedId: string;
readonly lockedId: string;
/** An item that holds focus is being taken out. */
readonly onFocusedItemGone: () => void;
}
interface ItemContextValue {
readonly item: CanvasItemData;
readonly left: number;
readonly top: number;
readonly width: number;
readonly height: number;
readonly selected: boolean;
readonly tabbable: boolean;
readonly moving: boolean;
}
const CanvasContext = createContext<CanvasContextValue | null>(null);
const ItemContext = createContext<ItemContextValue | null>(null);
interface SlotProps<T extends CanvasItemData> {
item: T;
rect: Rect;
originX: number;
originY: number;
selected: boolean;
tabbable: boolean;
moving: boolean;
renderItem: (item: T) => ReactNode;
}
/** One item's place among the drawn: drawn again only when something of its own changes. */
function SlotInner<T extends CanvasItemData>({
item,
rect,
originX,
originY,
selected,
tabbable,
moving,
renderItem,
}: SlotProps<T>): ReactElement {
const context = useMemo<ItemContextValue>(
() => ({
item,
left: rect.x - originX,
top: rect.y - originY,
width: rect.width,
height: rect.height,
selected,
tabbable,
moving,
}),
[item, rect, originX, originY, selected, tabbable, moving],
);
return <ItemContext value={context}>{renderItem(item)}</ItemContext>;
}
const Slot = memo(SlotInner) as typeof SlotInner;
type Pressed =
| { readonly on: "pan" }
| { readonly on: "handle"; readonly handle: ResizeHandle }
| { readonly on: "item"; readonly id: string; readonly draggable: boolean }
| { readonly on: "empty" };
interface Transform {
readonly kind: CanvasChangeKind;
readonly handle: ResizeHandle | null;
readonly ids: readonly string[];
readonly origin: ReadonlyMap<string, Rect>;
readonly bounds: Rect;
/** Where the press was, on the plane. */
readonly start: Point;
readonly lines: SnapLines;
/** What the lines were gathered from: where a guide may run to. */
readonly around: readonly Rect[];
readonly minWidth: number;
readonly minHeight: number;
/** Where each item is being shown, as of the last move. */
result: ReadonlyMap<string, Rect>;
copying: boolean;
}
type Gesture =
| { readonly kind: "none" }
| { readonly kind: "pan"; readonly view: Viewport; x: number; y: number }
| { readonly kind: "marquee"; readonly start: Point; readonly base: ReadonlySet<string> }
| Transform;
interface Guide {
readonly at: number;
readonly from: number;
readonly to: number;
}
interface Guides {
readonly x: Guide | null;
readonly y: Guide | null;
}
const NO_GUIDES: Guides = { x: null, y: null };
function isEditable(target: EventTarget | null, root: HTMLElement | null): boolean {
if (!(target instanceof Element) || root === null) return false;
const interactive = target.closest(INTERACTIVE);
return interactive !== null && interactive !== root && root.contains(interactive);
}
/** A guide along a line, running across what lies on it and the thing just placed there. */
function guideAt(around: readonly Rect[], axis: "x" | "y", at: number, placed: Rect): Guide {
const extent = guideExtent(around, axis, at);
const start = axis === "x" ? placed.y : placed.x;
const end = start + (axis === "x" ? placed.height : placed.width);
return { at, from: Math.min(extent?.[0] ?? start, start), to: Math.max(extent?.[1] ?? end, end) };
}
export function Canvas<T extends CanvasItemData = CanvasItemData>({
items: itemsProp,
defaultItems,
onItemsChange,
renderItem,
below,
above,
api,
value: valueProp,
defaultValue,
onValueChange,
viewport: viewportProp,
defaultViewport,
onViewportChange,
zoomLimits = DEFAULT_ZOOM_LIMITS,
snap = true,
grid = 0,
minSize = 1,
constrain,
onDuplicate,
onAction,
messages: messagesProp,
className,
children,
ref,
onKeyDown,
onKeyUp,
onFocus,
onBlur,
onClick,
onDoubleClick,
onPointerDown,
onPointerDownCapture,
onPointerUp,
onLostPointerCapture,
onMouseDown,
onContextMenu,
...props
}: CanvasProps<T>): ReactElement {
const scope = useId();
const selectedId = useId();
const lockedId = useId();
const instructionsId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const mergedRef = useMemo(() => mergeRefs(rootRef, ref), [ref]);
const messages = useMemo(() => ({ ...MESSAGES, ...messagesProp }), [messagesProp]);
const coarse = useCoarsePointer();
const label = props["aria-label"] ?? props["aria-labelledby"];
useEffect(() => {
if (process.env.NODE_ENV !== "production" && label === undefined) {
console.error(
"Canvas: give the canvas a name with `aria-label` or `aria-labelledby`; without one, assistive technology announces an application with no name.",
);
}
}, [label]);
// Items: the owner's, or kept here.
const [ownItems, setOwnItems] = useState<readonly T[]>(defaultItems ?? NO_ITEMS);
const items = itemsProp ?? ownItems;
const { ids, byId, rects, index } = useMemo(() => {
const each = items.map((item) => item.rect);
return {
ids: items.map((item) => item.id),
byId: new Map(items.map((item, at) => [item.id, at])),
rects: each,
index: spatialIndexFromRects(each),
};
}, [items]);
// Reading order runs from the side reading starts at; the plane itself does not mirror.
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
useLayoutEffect(() => {
const root = rootRef.current;
if (root !== null) setDirection(getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr");
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- the direction is the DOM's, read whenever `dir` may have changed it
}, [props.dir]);
const order = useMemo(() => readingOrder(rects, direction), [rects, direction]);
const [value, setValue] = useControllableState<readonly string[]>(
valueProp,
defaultValue ?? NO_IDS,
onValueChange as ((value: readonly string[]) => void) | undefined,
);
const selected = useMemo<ReadonlySet<string>>(() => new Set(value), [value]);
const range = useRef<Pick<SelectionState, "anchor" | "base">>({ anchor: null, base: new Set() });
const [view, setView] = useControllableState<Viewport>(
viewportProp,
defaultViewport ?? IDENTITY,
onViewportChange,
);
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useLayoutEffect(() => {
const root = rootRef.current;
if (root === null) return undefined;
const measure = () =>
setSize((current) =>
current.width === root.clientWidth && current.height === root.clientHeight
? current
: { width: root.clientWidth, height: root.clientHeight },
);
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, []);
const [focusedId, setFocusedId] = useState<string | null>(null);
// An item to give focus to once it is drawn: it may be out of view, and so not drawn yet.
const [wanted, setWanted] = useState<string | null>(null);
const [draft, setDraft] = useState<ReadonlyMap<string, Rect> | null>(null);
const [guides, setGuides] = useState<Guides>(NO_GUIDES);
const [marquee, setMarquee] = useState<Rect | null>(null);
const [mode, setMode] = useState<"idle" | "pan" | "marquee" | "move" | "resize" | "copy">("idle");
const [spaceHeld, setSpaceHeld] = useState(false);
const { announce, regionProps: announcementProps } = useAnnouncer();
// One item is the tab stop: the one last focused, else the first selected, else the first in reading order.
const tabbableId = useMemo(() => {
if (focusedId !== null && byId.has(focusedId)) return focusedId;
for (const id of value) if (byId.has(id)) return id;
const first = order[0];
return first === undefined ? null : items[first]!.id;
}, [focusedId, byId, value, order, items]);
// Everything a handler reads, as it stands now: handlers outlive the render they were made in.
const standing = {
items,
ids,
byId,
index,
size,
snap,
grid,
minSize,
constrain,
onDuplicate,
onItemsChange,
messages,
zoomLimits,
setValue,
setView,
controlled: itemsProp !== undefined,
};
const latest = useRef(standing);
const viewRef = useRef(view);
const selectedRef = useRef(selected);
useLayoutEffect(() => {
latest.current = standing;
// An owner that turned a change down is believed over what was asked of it.
viewRef.current = view;
selectedRef.current = selected;
});
// Drawn again after every request, so that what an owner turned down is read back from it.
const [, resync] = useReducer((count: number) => count + 1, 0);
const changeView = useCallback((next: Viewport) => {
if (next === viewRef.current) return;
// Kept at once: several wheel events may arrive before the next render.
viewRef.current = next;
latest.current.setView(next);
resync();
}, []);
/** Bring a part of the plane into view, by the least movement — and leave the view be if it is all there. */
const bring = useCallback(
(rect: Rect) => {
const { size: extent } = latest.current;
if (extent.width === 0 || extent.height === 0) return;
if (rectContains(visibleRect(viewRef.current, extent), rect)) return;
changeView(reveal(viewRef.current, rect, extent, REVEAL_MARGIN));
},
[changeView],
);
// A drag held near an edge of the view moves the view along, a frame at a time.
const carried = useRef<{
at: CanvasDragPlace | null;
follow: (() => void) | null;
stride: number;
frame: number;
time: number;
}>({ at: null, follow: null, stride: Infinity, frame: 0, time: 0 });
const dragAt = useCallback(
(place: CanvasDragPlace | null, follow?: () => void, stride = Infinity) => {
const state = carried.current;
state.at = place;
state.follow = follow ?? null;
state.stride = stride;
if (place === null) {
cancelAnimationFrame(state.frame);
state.frame = 0;
return;
}
if (state.frame !== 0) return;
const step = (time: number) => {
const root = rootRef.current;
state.frame = 0;
if (state.at === null || root === null) return;
const box = root.getBoundingClientRect();
const speed = edgePan(
{ x: state.at.x - box.left - root.clientLeft, y: state.at.y - box.top - root.clientTop },
latest.current.size,
{ x: state.at.dx, y: state.at.dy },
);
// A frame that was long in coming moves no further than a slow one would.
const elapsed = Math.min(Math.max(time - state.time, 0), 64) / 1000;
state.time = time;
if (speed.x !== 0 || speed.y !== 0) {
// Drawn before it is followed: what follows looks at what is under the pointer now,
// and a frame late it would be looking at where things were.
const within = (distance: number) => Math.max(-state.stride, Math.min(state.stride, distance));
flushSync(() =>
changeView(pan(viewRef.current, within(-speed.x * elapsed), within(-speed.y * elapsed))),
);
// What is dragged stays under the pointer, which has not moved — and is drawn there
// in the same frame as the view, not the one after.
flushSync(() => state.follow?.());
}
state.frame = requestAnimationFrame(step);
};
state.time = performance.now();
state.frame = requestAnimationFrame(step);
},
[changeView],
);
useEffect(() => {
const state = carried.current;
return () => cancelAnimationFrame(state.frame);
}, []);
const setSelection = useCallback((next: ReadonlySet<string>) => {
if (sameSelection(next, selectedRef.current)) return;
selectedRef.current = next;
resync();
const { ids: all, byId: known, setValue: set } = latest.current;
// In the items' order, then whatever is selected but no longer among them.
const report = inOrder(next, all);
for (const id of next) if (!known.has(id)) report.push(id);
set(report);
}, []);
const select = useCallback(
(intent: SelectionIntent, id: string): ReadonlySet<string> => {
const next = applyIntent({ selected: selectedRef.current, ...range.current }, intent, id, {
mode: "multiple",
order: latest.current.ids,
});
range.current = { anchor: next.anchor, base: next.base };
setSelection(next.selected);
return next.selected;
},
[setSelection],
);
/** The selected items that may be changed, in the items' order. */
const movable = useCallback((among: ReadonlySet<string> = selectedRef.current): string[] => {
const { items: all, byId: known } = latest.current;
const found: number[] = [];
for (const id of among) {
const at = known.get(id);
if (at !== undefined && all[at]!.locked !== true) found.push(at);
}
// In place: the array was made here.
// oxlint-disable-next-line unicorn/no-array-sort
return found.sort((a, b) => a - b).map((at) => all[at]!.id);
}, []);
const commit = useCallback(
(changes: readonly CanvasItemChange[], kind: CanvasChangeKind) => {
if (changes.length === 0) return;
const { controlled, onItemsChange: tell, messages: words } = latest.current;
if (!controlled) setOwnItems((current) => applyItemChanges(current, changes));
tell?.(changes, kind);
const bounds = rectUnion(changes.map((change) => change.rect))!;
announce(kind === "move" ? words.moved(bounds, changes.length) : words.resized(bounds, changes.length));
},
[announce],
);
const elementOf = useCallback(
(id: string) =>
rootRef.current?.querySelector<HTMLElement>(
`[data-canvas-item="${scope}"][data-id="${CSS.escape(id)}"]`,
) ?? null,
[scope],
);
const itemAt = useCallback(
(target: EventTarget | null): { id: string; element: HTMLElement } | null => {
if (!(target instanceof Element)) return null;
const element = target.closest<HTMLElement>(`[data-canvas-item="${scope}"]`);
const id = element?.dataset.id;
return element === null || id === undefined ? null : { id, element };
},
[scope],
);
/** The point of the plane under a place in the browser's viewport. */
const planeAt = useCallback((clientX: number, clientY: number): Point => {
const root = rootRef.current!;
const box = root.getBoundingClientRect();
return toPlane(viewRef.current, {
x: clientX - box.left - root.clientLeft,
y: clientY - box.top - root.clientTop,
});
}, []);
/** The lines a drag may come to rest on: of everything not being changed, near the view where there is a great deal. */
const gather = useCallback((moving: ReadonlySet<string>): Rect[] => {
const { items: all, index: tree, size: extent } = latest.current;
const around: Rect[] = [];
if (all.length <= CULL_ABOVE) {
for (const item of all) if (!moving.has(item.id)) around.push(item.rect);
return around;
}
// One can only align with what one can see: the view, and as much again on every side.
const shown = visibleRect(viewRef.current, extent);
const near = spatialSearch(
tree,
shown.x - shown.width,
shown.y - shown.height,
shown.x + shown.width * 2,
shown.y + shown.height * 2,
);
for (const at of near) if (!moving.has(all[at]!.id)) around.push(all[at]!.rect);
return around;
}, []);
const begin = useCallback(
(
kind: CanvasChangeKind,
handle: ResizeHandle | null,
among: ReadonlySet<string>,
drag: Drag,
): Gesture => {
const { items: all, byId: known, minSize: least, snap: toNeighbours } = latest.current;
const changing = movable(among);
if (changing.length === 0) return { kind: "none" };
const origin = new Map(changing.map((id) => [id, all[known.get(id)!]!.rect]));
const bounds = rectUnion(origin.values())!;
// With no neighbours' lines to come to rest on, a grid still holds.
const around = toNeighbours ? gather(new Set(changing)) : [];
// No item of several may go below the least size: the whole stops where the smallest would.
let minWidth = least;
let minHeight = least;
for (const rect of origin.values()) {
if (rect.width > 0) minWidth = Math.max(minWidth, (bounds.width * least) / rect.width);
if (rect.height > 0) minHeight = Math.max(minHeight, (bounds.height * least) / rect.height);
}
return {
kind,
handle,
ids: changing,
origin,
bounds,
start: planeAt(drag.startX, drag.startY),
lines: snapLines(around),
around,
minWidth,
minHeight,
result: origin,
copying: false,
};
},
[movable, gather, planeAt],
);
const transform = useCallback(
(gesture: Transform, drag: Drag) => {
const { grid: step, constrain: hold, onDuplicate: copy } = latest.current;
const { zoom } = viewRef.current;
const at = planeAt(drag.x, drag.y);
let dx = at.x - gesture.start.x;
let dy = at.y - gesture.start.y;
// A reach on the screen, in the plane's units.
const reach = SNAP_REACH / zoom;
// Control, or the command key, moves freely.
const drawn = !drag.ctrlKey && !drag.metaKey;
const result = new Map<string, Rect>();
let lineX: number | null = null;
let lineY: number | null = null;
if (gesture.kind === "move") {
// Shift keeps to the axis of greater movement.
const across = !drag.shiftKey || Math.abs(dx) >= Math.abs(dy);
const down = !drag.shiftKey || !across;
if (!across) dx = 0;
if (!down) dy = 0;
if (drawn) {
const snapped = snapMove(rectTranslate(gesture.bounds, dx, dy), gesture.lines, reach, step);
if (across) {
dx += snapped.dx;
lineX = snapped.x;
}
if (down) {
dy += snapped.dy;
lineY = snapped.y;
}
}
for (const id of gesture.ids) {
const moved = rectTranslate(gesture.origin.get(id)!, dx, dy);
result.set(id, hold === undefined ? moved : hold(id, moved, "move"));
}
} else {
const handle = gesture.handle!;
const edges = handleEdges(handle);
const { bounds } = gesture;
if (drawn) {
if (edges.x !== 0) {
const edge = (edges.x === 1 ? bounds.x + bounds.width : bounds.x) + dx;
dx += snapValue(edge, gesture.lines.xs, reach, step) - edge;
}
if (edges.y !== 0) {
const edge = (edges.y === 1 ? bounds.y + bounds.height : bounds.y) + dy;
dy += snapValue(edge, gesture.lines.ys, reach, step) - edge;
}
}
const next = resizeRect(bounds, handle, dx, dy, {
fromCentre: drag.altKey,
keepRatio: drag.shiftKey,
minWidth: gesture.minWidth,
minHeight: gesture.minHeight,
});
for (const id of gesture.ids) {
const resized = resizeWithin(gesture.origin.get(id)!, bounds, next);
result.set(id, hold === undefined ? resized : hold(id, resized, "resize"));
}
if (drawn) {
// A guide for each edge the handle moves, where that edge has come to rest on a line.
if (edges.x !== 0) lineX = edges.x === 1 ? next.x + next.width : next.x;
if (edges.y !== 0) lineY = edges.y === 1 ? next.y + next.height : next.y;
}
}
// A guide is shown only where the thing, as it is finally placed, lies on the line.
const placed = rectUnion(result.values())!;
const rests = (line: number | null, start: number, extent: number, lines: ArrayLike<number>) =>
line !== null &&
onLine(lines, line) &&
(Math.abs(start - line) < 1e-6 ||
Math.abs(start + extent / 2 - line) < 1e-6 ||
Math.abs(start + extent - line) < 1e-6);
gesture.result = result;
gesture.copying = gesture.kind === "move" && drag.altKey && copy !== undefined;
setDraft(result);
setMode(gesture.copying ? "copy" : gesture.kind);
setGuides({
x: rests(lineX, placed.x, placed.width, gesture.lines.xs)
? guideAt(gesture.around, "x", lineX!, placed)
: null,
y: rests(lineY, placed.y, placed.height, gesture.lines.ys)
? guideAt(gesture.around, "y", lineY!, placed)
: null,
});
},
[planeAt],
);
const pressed = useRef<Pressed>({ on: "empty" });
const gesture = useRef<Gesture>({ kind: "none" });
// A press on one of several selected items: it becomes the only one when the
// press ends as a click, so that a drag can begin from it with them all.
const deferred = useRef<string | null>(null);
const pointerType = useRef("mouse");
// Focus that a press brings must not move the view from under the pointer.
const pressing = useRef(false);
const space = useRef({ held: false, used: false });
// A finger held still on empty space arms a marquee.
const hold = useRef<{ timer: ReturnType<typeof setTimeout> | null; armed: boolean }>({
timer: null,
armed: false,
});
const disarm = useCallback(() => {
if (hold.current.timer !== null) clearTimeout(hold.current.timer);
hold.current = { timer: null, armed: false };
}, []);
const settle = useCallback(() => {
gesture.current = { kind: "none" };
dragAt(null);
disarm();
setDraft(null);
setGuides(NO_GUIDES);
setMarquee(null);
setMode("idle");
}, [disarm, dragAt]);
// The view as a drag found it: where Escape puts it back, if the drag moved it along.
const viewBefore = useRef<Viewport | null>(null);
/** What a drag shows at one moment of it — also when the view has moved under a pointer that has not. */
function advance(move: Drag): void {
const current = gesture.current;
if (current.kind === "pan") {
changeView(pan(viewRef.current, move.dx - current.x, move.dy - current.y));
current.x = move.dx;
current.y = move.dy;
} else if (current.kind === "marquee") {
const { items: all, index: tree } = latest.current;
const box = rectFrom(current.start, planeAt(move.x, move.y));
// With Shift, what the marquee catches is added to what was selected.
const next = new Set(move.shiftKey ? current.base : undefined);
for (const at of spatialSearch(tree, box.x, box.y, box.x + box.width, box.y + box.height)) {
const item = all[at]!;
if (item.locked === true) continue;
// With Alt, only what lies wholly within it.
if (!move.altKey || rectContains(box, item.rect)) next.add(item.id);
}
setSelection(next);
setMarquee(box);
} else if (current.kind !== "none") {
transform(current, move);
}
}
const drag = useDrag({
buttons: BUTTONS,
onPress(press: DragPress) {
const root = rootRef.current;
if (root === null || isEditable(press.target, root)) return false;
pointerType.current = press.pointerType;
deferred.current = null;
disarm();
const target = press.target as Element;
if (press.button === 1 || space.current.held) {
pressed.current = { on: "pan" };
return true;
}
const handle = target.closest<HTMLElement>("[data-canvas-handle]")?.dataset.canvasHandle;
if (handle !== undefined && root.contains(target)) {
pressed.current = { on: "handle", handle: handle as ResizeHandle };
return true;
}
const hit = itemAt(target);
const item = hit === null ? undefined : latest.current.items[latest.current.byId.get(hit.id) ?? -1];
// A locked item does not answer the pointer: a press on it is a press on the plane beneath.
if (hit === null || item === undefined || item.locked === true) {
pressed.current = { on: "empty" };
if (press.pointerType === "touch") {
const at = planeAt(press.x, press.y);
hold.current.timer = setTimeout(() => {
hold.current = { timer: null, armed: true };
setMarquee({ ...at, width: 0, height: 0 });
}, LONG_PRESS);
}
return true;
}
// Where an item names a part to be dragged by, only that part drags it.
const grip = hit.element.querySelector("[data-canvas-item-handle]");
const draggable = grip === null || target.closest("[data-canvas-item-handle]") !== null;
pressed.current = { on: "item", id: hit.id, draggable };
// A mouse selects as it presses; a finger or a pen when the press ends as a tap.
if (press.pointerType !== "mouse") return true;
const intent = pointerIntent(
{ shiftKey: false, commandKey: press.shiftKey || isCommandKey(press), pointerType: "mouse" },
"multiple",
"replace",
);
if (intent === "replace" && selectedRef.current.size > 1 && selectedRef.current.has(hit.id)) {
deferred.current = hit.id;
} else {
select(intent, hit.id);
}
return true;
},
onStart(start) {
const press = pressed.current;
deferred.current = null;
viewBefore.current = viewRef.current;
if (press.on === "pan") {
space.current.used = true;
gesture.current = { kind: "pan", view: viewRef.current, x: 0, y: 0 };
setMode("pan");
} else if (press.on === "handle") {
gesture.current = begin("resize", press.handle, selectedRef.current, start);
} else if (press.on === "item") {
let among = selectedRef.current;
// A finger's drag takes the item it began on, alone, when that was not already selected.
if (!among.has(press.id) && start.pointerType !== "mouse") among = select("replace", press.id);
gesture.current =
press.draggable && among.has(press.id) ? begin("move", null, among, start) : { kind: "none" };
} else if (start.pointerType === "touch" && !hold.current.armed) {
// One finger on empty space moves the view; held first, it draws a marquee.
gesture.current = { kind: "pan", view: viewRef.current, x: 0, y: 0 };
setMode("pan");
} else {
gesture.current = {
kind: "marquee",
start: planeAt(start.startX, start.startY),
base: selectedRef.current,
};
setMode("marquee");
}
disarm();
},
onMove(move) {
advance(move);
const { kind } = gesture.current;
// Dragging the view is itself the way to move it; every other drag moves it along at an edge.
if (kind !== "pan" && kind !== "none") dragAt(move, () => advance(move));
},
onEnd() {
const current = gesture.current;
if (current.kind === "move" || current.kind === "resize") {
const { byId: known, items: all, onDuplicate: copy } = latest.current;
// Items taken away while they were being dragged are not among the changes.
const present = current.ids.filter((id) => known.has(id));
if (current.copying && copy !== undefined) {
copy(present.map((id) => ({ source: id, rect: current.result.get(id)! })));
} else {
commit(
present
.map((id) => ({ id, rect: current.result.get(id)!, previous: all[known.get(id)!]!.rect }))
.filter((change) => !rectsEqual(change.rect, change.previous)),
current.kind,
);
}
}
settle();
},
onCancel() {
const current = gesture.current;
if (current.kind === "pan") changeView(current.view);
else if (current.kind !== "none" && viewBefore.current !== null) changeView(viewBefore.current);
if (current.kind === "marquee") setSelection(current.base);
settle();
},
});
// The wheel is heard natively: React's listener is passive, and could not keep the page from zooming.
useEffect(() => {
const root = rootRef.current;
if (root === null) return undefined;
const stop = new AbortController();
const anchor = (event: { clientX: number; clientY: number }) => {
const box = root.getBoundingClientRect();
return { x: event.clientX - box.left - root.clientLeft, y: event.clientY - box.top - root.clientTop };
};
root.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const { size: extent, zoomLimits: limits } = latest.current;
const intent = wheelIntent(event, extent);
changeView(
intent.kind === "zoom"
? zoomBy(viewRef.current, intent.factor, anchor(event), limits)
: pan(viewRef.current, intent.dx, intent.dy),
);
},
{ passive: false, signal: stop.signal },
);
// WebKit on a Mac reports a trackpad's pinch as a gesture of its own, not as a wheel.
let zoomAtStart = 1;
const onGesture = (event: Event) => {
event.preventDefault();
const pinch = event as Event & { scale?: number; clientX?: number; clientY?: number };
if (event.type === "gesturestart") zoomAtStart = viewRef.current.zoom;
else if (typeof pinch.scale === "number") {
changeView(
zoomTo(
viewRef.current,
zoomAtStart * pinch.scale,
anchor({ clientX: pinch.clientX ?? 0, clientY: pinch.clientY ?? 0 }),
latest.current.zoomLimits,
),
);
}
};
root.addEventListener("gesturestart", onGesture, { signal: stop.signal });
root.addEventListener("gesturechange", onGesture, { signal: stop.signal });
return () => stop.abort();
}, [changeView]);
// Two fingers: the plane moves with the point between them, and grows as they part.
const fingers = useRef<{
points: Map<number, Point>;
pinch: { x: number; y: number; apart: number } | null;
stop: AbortController | null;
}>({ points: new Map(), pinch: null, stop: null });
useEffect(() => () => fingers.current.stop?.abort(), []);
/** Whether a finger's press was taken as the second of a pinch, and so is not a press of its own. */
function trackFinger(event: ReactPointerEvent<HTMLDivElement>): boolean {
const state = fingers.current;
const root = event.currentTarget;
state.points.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (state.stop === null) {
const stop = new AbortController();
state.stop = stop;
const measure = () => {
const [a, b] = [...state.points.values()];
return { x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2, apart: Math.hypot(a!.x - b!.x, a!.y - b!.y) };
};
const document = root.ownerDocument;
document.addEventListener(
"pointermove",
(move) => {
if (!state.points.has(move.pointerId)) return;
state.points.set(move.pointerId, { x: move.clientX, y: move.clientY });
if (state.pinch === null || state.points.size < 2) return;
const now = measure();
const box = root.getBoundingClientRect();
const moved = pan(viewRef.current, now.x - state.pinch.x, now.y - state.pinch.y);
changeView(
zoomBy(
moved,
state.pinch.apart > 0 ? now.apart / state.pinch.apart : 1,
{ x: now.x - box.left - root.clientLeft, y: now.y - box.top - root.clientTop },
latest.current.zoomLimits,
),
);
state.pinch = now;
},
{ signal: stop.signal },
);
const lift = (up: PointerEvent) => {
if (!state.points.delete(up.pointerId)) return;
// A finger left behind by a pinch does nothing more until it lifts.
if (state.points.size < 2) state.pinch = null;
if (state.points.size === 0) {
stop.abort();
state.stop = null;
}
};
document.addEventListener("pointerup", lift, { signal: stop.signal });
document.addEventListener("pointercancel", lift, { signal: stop.signal });
}
// A drag already under way is not taken over by a second finger.
if (state.points.size !== 2 || drag.dragging()) return state.pinch !== null;
drag.cancel();
disarm();
setMarquee(null);
const [a, b] = [...state.points.values()];
state.pinch = { x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2, apart: Math.hypot(a!.x - b!.x, a!.y - b!.y) };
return true;
}
const focusItem = useCallback((id: string) => setWanted(id), []);
useLayoutEffect(() => {
if (wanted === null) return;
elementOf(wanted)?.focus({ preventScroll: true });
// oxlint-disable-next-line react/set-state-in-effect -- the request is spent once the element has focus
setWanted(null);
}, [wanted, elementOf]);
// Focus was on an item that has gone: it is given to the one that is now the tab stop.
const restoreFocus = useRef(false);
useLayoutEffect(() => {
if (!restoreFocus.current) return;
restoreFocus.current = false;
const root = rootRef.current;
if (root === null || root.contains(document.activeElement)) return;
((tabbableId === null ? null : elementOf(tabbableId)) ?? root).focus({ preventScroll: true });
});
const context = useMemo<CanvasContextValue>(
() => ({
scope,
selectedId,
lockedId,
onFocusedItemGone() {
restoreFocus.current = true;
},
}),
[scope, selectedId, lockedId],
);
/** The next item in reading order from one, or `null` past either end. */
function neighbour(id: string, by: 1 | -1 | "first" | "last"): string | null {
if (by === "first" || by === "last") {
const at = by === "first" ? order[0] : order.at(-1);
return at === undefined ? null : items[at]!.id;
}
const at = byId.get(id);
const place = at === undefined ? -1 : order.indexOf(at);
const next = place === -1 ? undefined : order[place + by];
return next === undefined ? null : items[next]!.id;
}
/** Move or resize the selection by the keyboard: one change for each press, never snapped. */
function nudge(kind: CanvasChangeKind, dx: number, dy: number): void {
const changes: CanvasItemChange[] = [];
for (const id of movable()) {
const previous = items[byId.get(id)!]!.rect;
const asked =
kind === "move"
? rectTranslate(previous, dx, dy)
: resizeRect(previous, "se", dx, dy, { minWidth: minSize, minHeight: minSize });
const rect = constrain === undefined ? asked : constrain(id, asked, kind);
if (!rectsEqual(rect, previous)) changes.push({ id, rect, previous });
}
if (changes.length === 0) return;
commit(changes, kind);
// What the keyboard moves out of view is brought back into it.
bring(rectUnion(changes.map((change) => change.rect))!);
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
// What is typed into a field inside an item is the field's.
if (isEditable(event.target, event.currentTarget) && event.key !== "Tab") return;
const command = isCommandKey(event);
const focused = itemAt(document.activeElement);
const onItem = focused !== null && focused.element === document.activeElement;
const centre = { x: size.width / 2, y: size.height / 2 };
if (event.key === "Tab") {
if (event.altKey || event.ctrlKey || event.metaKey || focused === null) return;
if (!onItem || !event.shiftKey) {
// Within an item, Tab goes through what it holds before going on to the next item.
const inside = [...focused.element.querySelectorAll<HTMLElement>(TABBABLE)];
const last = inside.at(-1);
if (onItem ? inside.length > 0 : event.shiftKey || document.activeElement !== last) return;
}
const next = neighbour(focused.id, event.shiftKey ? -1 : 1);
// Past the last item, and before the first, Tab leaves the canvas.
if (next === null) return;
event.preventDefault();
focusItem(next);
return;
}
if (event.key === "Home" || event.key === "End") {
const next = neighbour("", event.key === "Home" ? "first" : "last");
if (next === null) return;
event.preventDefault();
focusItem(next);
return;
}
if (event.key === " ") {
// Not the page's scroll. What Space means is known when it is released: a tap of it selects, held it pans.
event.preventDefault();
if (!event.repeat) {
space.current = { held: true, used: false };
setSpaceHeld(true);
}
return;
}
if (event.key === "Enter") {
if (!onItem || onAction === undefined) return;
event.preventDefault();
// A key held down acts once.
if (!event.repeat) onAction(focused.id);
return;
}
if (event.key === "Escape") {
if (selected.size === 0) return;
event.preventDefault();
setSelection(new Set());
announce(messages.selection(0));
return;
}
if (command && event.key.toLowerCase() === "a") {
event.preventDefault();
const all = new Set(selected);
for (const item of items) if (item.locked !== true) all.add(item.id);
setSelection(all);
announce(messages.selection(all.size));
return;
}
// The arrows are the plane's own directions: they do not mirror with the writing direction.
const arrow =
event.key === "ArrowLeft"
? { x: -1, y: 0 }
: event.key === "ArrowRight"
? { x: 1, y: 0 }
: event.key === "ArrowUp"
? { x: 0, y: -1 }
: event.key === "ArrowDown"
? { x: 0, y: 1 }
: null;
if (arrow !== null) {
if (event.altKey) {
// With Alt an arrow is focus's: to the nearest item that way, and nothing else changes.
const from = focused === null ? undefined : byId.get(focused.id);
if (command || event.shiftKey || from === undefined) return;
// Also where nothing lies that way: Alt with an arrow would otherwise be the browser's, to go back.
event.preventDefault();
const next = nearestInDirection(
rects,
from,
arrow.x === 1 ? "right" : arrow.x === -1 ? "left" : arrow.y === 1 ? "down" : "up",
);
if (next !== -1) focusItem(items[next]!.id);
return;
}
event.preventDefault();
const step = event.shiftKey ? 10 : 1;
if (command) nudge("resize", arrow.x * step, arrow.y * step);
else if (selected.size > 0) nudge("move", arrow.x * step, arrow.y * step);
// With nothing selected there is nothing to move but the view.
else changeView(pan(viewRef.current, -arrow.x * PAN_STEP * step, -arrow.y * PAN_STEP * step));
return;
}
if (command && !event.altKey && (event.key === "=" || event.key === "+" || event.key === "-")) {
event.preventDefault();
const stop = zoomStop(viewRef.current.zoom, event.key === "-" ? -1 : 1);
changeView(zoomTo(viewRef.current, stop, centre, zoomLimits));
return;
}
if (command && !event.altKey && event.key === "0") {
event.preventDefault();
changeView(zoomTo(viewRef.current, 1, centre, zoomLimits));
return;
}
// By the key's place, not its character: Shift makes a 1 an exclamation mark, or something else again.
if (event.shiftKey && !command && !event.altKey && (event.code === "Digit1" || event.code === "Digit2")) {
const all = event.code === "Digit1";
const bounds = rectUnion(
all ? rects : value.flatMap((id) => (byId.has(id) ? [rects[byId.get(id)!]!] : [])),
);
if (bounds === null || size.width === 0 || size.height === 0) return;
event.preventDefault();
changeView(fit(bounds, size, { padding: FIT_PADDING, limits: zoomLimits, zoom: viewRef.current.zoom }));
}
}
function handleKeyUp(event: KeyboardEvent<HTMLDivElement>): void {
if (event.key !== " " || !space.current.held) return;
const used = space.current.used;
space.current = { held: false, used: false };
setSpaceHeld(false);
const focused = itemAt(document.activeElement);
// Space that moved the view selected nothing.
if (used || focused === null || focused.element !== document.activeElement) return;
const next = select("toggle", focused.id);
announce(messages.selection(next.size));
}
function handleFocus(event: FocusEvent<HTMLDivElement>): void {
const root = event.currentTarget;
if (event.target === root) {
// Focus given to the canvas itself goes to its tab stop, unless a press put it there.
if (!pressing.current && tabbableId !== null) focusItem(tabbableId);
return;
}
const focused = itemAt(event.target);
if (focused === null) return;
setFocusedId(focused.id);
// An item reached by the keyboard is brought into view, by the least movement.
if (pressing.current || focused.element !== event.target) return;
const at = byId.get(focused.id);
if (at !== undefined) bring(draft?.get(focused.id) ?? rects[at]!);
}
function handleClick(event: MouseEvent<HTMLDivElement>): void {
if (isEditable(event.target, event.currentTarget)) return;
if ((event.target as Element).closest("[data-canvas-handle]") !== null) return;
const hit = itemAt(event.target);
const item = hit === null ? undefined : items[byId.get(hit.id) ?? -1];
// A click that no press came before is a key's, or assistive technology's.
if (event.detail === 0) {
if (hit !== null) select("toggle", hit.id);
return;
}
if (hit === null || item === undefined || item.locked === true) {
// A click on empty space clears the selection; with a modifier it is the start of adding, and keeps it.
if (!event.shiftKey && !isCommandKey(event)) setSelection(new Set());
return;
}
if (pointerType.current === "mouse") {
if (deferred.current === hit.id) select("replace", hit.id);
deferred.current = null;
return;
}
select(
pointerIntent(
{ shiftKey: false, commandKey: false, pointerType: pointerType.current },
"multiple",
"replace",
),
hit.id,
);
}
// What is drawn: everything, until there is a great deal; then what the view shows, and a little around it.
const origin = viewOrigin(view);
const drawn = useMemo(() => {
const found: number[] = [];
if (items.length <= CULL_ABOVE) {
for (let at = 0; at < items.length; at += 1) found.push(at);
return found;
}
const shown = visibleRect(view, size);
const margin = CULL_MARGIN / view.zoom;
if (size.width > 0 && size.height > 0) {
spatialSearch(
index,
shown.x - margin,
shown.y - margin,
shown.x + shown.width + margin,
shown.y + shown.height + margin,
found,
);
}
// Whatever holds focus, is about to, or is being dragged is drawn wherever it is.
for (const id of [tabbableId, wanted]) {
const at = id === null ? undefined : byId.get(id);
if (at !== undefined) found.push(at);
}
if (draft !== null) {
for (const id of draft.keys()) {
const at = byId.get(id);
if (at !== undefined) found.push(at);
}
}
// In the items' order, which is the order they are painted in; each once.
// oxlint-disable-next-line unicorn/no-array-sort
found.sort((a, b) => a - b);
return found.filter((at, place) => place === 0 || found[place - 1] !== at);
}, [items, index, view, size, tabbableId, wanted, draft, byId]);
const layer = useMemo<CanvasLayer>(
() => ({ origin: { x: origin.x, y: origin.y }, viewport: view, size, moving: draft }),
[origin.x, origin.y, view, size, draft],
);
useImperativeHandle(api, () => ({ toPlane: planeAt, reveal: bring, announce, dragAt }), [
planeAt,
bring,
announce,
dragAt,
]);
// The frame around what is selected and may be changed, and its handles, on the screen.
const changing = useMemo(
() => items.filter((item) => item.locked !== true && selected.has(item.id)).map((item) => item.id),
[items, selected],
);
const frame = useMemo(() => {
const bounds = rectUnion(
changing.flatMap((id) => {
const at = byId.get(id);
return at === undefined ? [] : [draft?.get(id) ?? rects[at]!];
}),
);
return bounds === null ? null : toScreenRect(view, bounds);
}, [changing, byId, draft, rects, view]);
const target = coarse ? TARGET_COARSE : TARGET_FINE;
const showHandles = frame !== null && (mode === "idle" || mode === "resize");
const line = (guide: Guide, axis: "x" | "y"): CSSProperties => {
const at = (guide.at - (axis === "x" ? view.x : view.y)) * view.zoom;
const from = (guide.from - (axis === "x" ? view.y : view.x)) * view.zoom;
const length = (guide.to - guide.from) * view.zoom;
return axis === "x"
? { left: at, top: from, height: length, width: 0 }
: { top: at, left: from, width: length, height: 0 };
};
return (
<CanvasContext value={context}>
{/* An application takes keys and presses, though no native element is one. */}
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
ref={mergedRef}
role="application"
aria-describedby={instructionsId}
// Focusable itself only until it has an item to hand focus to; an application is a thing that takes keys.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={tabbableId === null ? 0 : -1}
data-canvas=""
data-mode={mode}
data-pan={mode === "pan" ? "active" : spaceHeld ? "ready" : undefined}
{...props}
{...drag.props}
onPointerDownCapture={(event) => {
onPointerDownCapture?.(event);
pressing.current = true;
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented) return;
if (event.pointerType === "touch" && trackFinger(event)) return;
// A secondary press — on a Mac, Ctrl-click too — is for a context menu: it
// selects what it lands on only if that is outside the selection.
if (event.button === 2 || (event.button === 0 && event.ctrlKey && isApplePlatform())) {
const hit = isEditable(event.target, event.currentTarget) ? null : itemAt(event.target);
if (hit !== null && items[byId.get(hit.id) ?? -1]?.locked !== true && !selected.has(hit.id)) {
select("replace", hit.id);
}
return;
}
drag.props.onPointerDown(event);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
pressing.current = false;
// A finger held and lifted without moving drew nothing.
if (!drag.dragging()) {
disarm();
setMarquee(null);
}
}}
onLostPointerCapture={(event) => {
onLostPointerCapture?.(event);
drag.props.onLostPointerCapture(event);
}}
onContextMenu={(event) => {
onContextMenu?.(event);
// A finger held still arms a marquee here; it does not also open the system's menu.
if (pointerType.current === "touch") event.preventDefault();
}}
onMouseDown={(event) => {
onMouseDown?.(event);
// The middle button moves the view; it does not begin the browser's own scrolling.
if (event.button === 1) event.preventDefault();
}}
onKeyDown={(event) => {
onKeyDown?.(event);
pressing.current = false;
if (!event.defaultPrevented) handleKeyDown(event);
}}
onKeyUp={(event) => {
onKeyUp?.(event);
handleKeyUp(event);
}}
onFocus={(event) => {
onFocus?.(event);
if (!event.defaultPrevented) handleFocus(event);
}}
onBlur={(event) => {
onBlur?.(event);
if (event.currentTarget.contains(event.relatedTarget)) return;
// Space released elsewhere is not heard here.
space.current = { held: false, used: false };
setSpaceHeld(false);
}}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) handleClick(event);
}}
onDoubleClick={(event) => {
onDoubleClick?.(event);
const hit = isEditable(event.target, event.currentTarget) ? null : itemAt(event.target);
if (!event.defaultPrevented && hit !== null) onAction?.(hit.id);
}}
className={cn(
"relative touch-none overflow-clip bg-surface-subtle select-none [-webkit-touch-callout:none]",
"data-[pan=ready]:cursor-grab data-[pan=active]:cursor-grabbing data-[mode=copy]:cursor-copy",
focusRingInsetClassName,
className,
)}
>
<div
data-canvas-plane=""
className="absolute top-0 left-0 origin-top-left"
style={
{
// One transformed element, so that panning and zooming move no item: only this.
transform: `translate(${(origin.x - view.x) * view.zoom}px, ${(origin.y - view.y) * view.zoom}px) scale(${view.zoom})`,
"--canvas-zoom": view.zoom,
} as CSSProperties
}
>
{below !== undefined && (
<div data-canvas-layer="below" className="absolute top-0 left-0">
{below(layer)}
</div>
)}
{drawn.map((at) => {
const item = items[at]!;
return (
<Slot
key={item.id}
item={item}
rect={draft?.get(item.id) ?? item.rect}
originX={origin.x}
originY={origin.y}
selected={selected.has(item.id)}
tabbable={item.id === tabbableId}
moving={draft?.has(item.id) === true}
renderItem={renderItem}
/>
);
})}
{above !== undefined && (
<div data-canvas-layer="above" className="absolute top-0 left-0">
{above(layer)}
</div>
)}
</div>
{/* Drawn on the screen, not the plane: a handle and a line are the same size at any zoom. */}
<div aria-hidden className="pointer-events-none absolute inset-0">
{mode === "copy" &&
draft !== null &&
// Where the originals stay, while what is dragged shows where the copies will be.
[...draft.keys()].map((id) => {
const at = byId.get(id);
if (at === undefined) return null;
const box = toScreenRect(view, rects[at]!);
return (
<div
key={id}
data-canvas-ghost=""
className="absolute border border-dashed border-border forced-colors:border-[GrayText]"
style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
/>
);
})}
{frame !== null && changing.length > 1 && (
<div
data-canvas-frame=""
className="absolute border border-selected forced-colors:border-[Highlight]"
style={{ left: frame.x, top: frame.y, width: frame.width, height: frame.height }}
/>
)}
{guides.x !== null && (
<div
data-canvas-guide="x"
className="absolute border-s border-guide forced-colors:border-[Highlight]"
style={line(guides.x, "x")}
/>
)}
{guides.y !== null && (
<div
data-canvas-guide="y"
className="absolute border-t border-guide forced-colors:border-[Highlight]"
style={line(guides.y, "y")}
/>
)}
{marquee !== null &&
(() => {
const box = toScreenRect(view, marquee);
return (
<div
data-canvas-marquee=""
className="absolute border border-selected bg-selected/10 forced-colors:border-[Highlight] forced-colors:bg-transparent"
style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
/>
);
})()}
{showHandles &&
RESIZE_HANDLES.map((handle) => {
const edges = handleEdges(handle);
// A side too short to carry a handle clear of its corners' has none.
if (edges.y === 0 && !sideHandleFits(frame.height, target)) return null;
if (edges.x === 0 && !sideHandleFits(frame.width, target)) return null;
const offsetX = handleTargetOffset(edges.x, frame.width, target);
const offsetY = handleTargetOffset(edges.y, frame.height, target);
return (
<div
key={handle}
data-canvas-handle={handle}
data-drag-handle=""
className="pointer-events-auto absolute"
style={{
left: frame.x + (frame.width * (edges.x + 1)) / 2 + offsetX,
top: frame.y + (frame.height * (edges.y + 1)) / 2 + offsetY,
width: target,
height: target,
cursor: CURSORS[handle],
}}
>
{/* The square that is seen sits on the handle's own point, wherever its target had to go. */}
<div
className="absolute border border-selected bg-surface forced-colors:border-[Highlight] forced-colors:bg-[Canvas]"
style={{
left: -offsetX - HANDLE_SIZE / 2,
top: -offsetY - HANDLE_SIZE / 2,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
}}
/>
</div>
);
})}
</div>
{children}
<div id={instructionsId} hidden>
{messages.instructions}
</div>
<span id={selectedId} hidden>
{messages.selected}
</span>
<span id={lockedId} hidden>
{messages.locked}
</span>
<div {...announcementProps} />
</div>
</CanvasContext>
);
}
export type CanvasItemProps = Omit<ComponentProps<"div">, "id">;
/** An item on the plane: placed and sized by the canvas, focusable, and described as selected or locked. */
export function CanvasItem({
className,
style,
children,
ref,
"aria-describedby": describedBy,
...props
}: CanvasItemProps): ReactElement {
const canvas = use(CanvasContext);
const slot = use(ItemContext);
if (canvas === null || slot === null) {
throw new Error("CanvasItem must be rendered by a Canvas's `renderItem`.");
}
const { item, selected } = slot;
const locked = item.locked === true;
const element = useRef<HTMLDivElement>(null);
const { onFocusedItemGone } = canvas;
// Told while the element still holds focus: before it is taken out.
useLayoutEffect(() => {
const node = element.current;
return () => {
if (node !== null && node.contains(document.activeElement)) onFocusedItemGone();
};
}, [onFocusedItemGone]);
return (
<div
ref={mergeRefs(element, ref)}
role="group"
aria-describedby={
[describedBy, selected ? canvas.selectedId : null, locked ? canvas.lockedId : null]
.filter(Boolean)
.join(" ") || undefined
}
// A thing on a canvas takes focus, as the canvas's one tab stop or by a press.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={slot.tabbable ? 0 : -1}
data-canvas-item={canvas.scope}
data-id={item.id}
data-selected={selected ? "" : undefined}
data-locked={locked ? "" : undefined}
data-moving={slot.moving ? "" : undefined}
style={{ left: slot.left, top: slot.top, width: slot.width, height: slot.height, ...style }}
className={cn(
"absolute outline-none [-webkit-tap-highlight-color:transparent]",
// The frame of a selected item, a hair wide on the screen at any zoom; dashed on one that is locked.
"after:pointer-events-none after:absolute after:inset-0 after:border-selected data-selected:after:border-solid data-locked:data-selected:after:border-dashed",
"after:hidden after:border-[length:calc(1.5px/var(--canvas-zoom))] data-selected:after:block",
"forced-colors:after:border-[Highlight]",
// The focus ring of `lib/focus-class.ts`, measured on the screen: the plane is scaled, and it would be too.
"focus-visible:outline-solid focus-visible:outline-focus-ring focus-visible:outline-[length:calc(2px/var(--canvas-zoom))] focus-visible:outline-offset-[calc(2px/var(--canvas-zoom))]",
className,
)}
{...props}
>
{children}
</div>
);
}
export type CanvasItemHandleProps = ComponentProps<"div">;
/**
* The part of an item it is dragged by — a node's title bar. An item that has
* one is dragged only from it; a press elsewhere on the item still selects.
*/
export function CanvasItemHandle({ className, ...props }: CanvasItemHandleProps): ReactElement {
return <div data-canvas-item-handle="" className={cn("cursor-grab touch-none", className)} {...props} />;
}