@canvist/canvas 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Multiplayer between tabs of one browser, over a `BroadcastChannel`.
3
+ *
4
+ * @module
5
+ */
6
+ import type { SyncMessage, Transport } from "./transport.ts";
7
+ /** Syncs every tab of this origin that joins room `name`. */
8
+ export declare class BroadcastTransport implements Transport {
9
+ #private;
10
+ constructor(name: string);
11
+ connect(_client: number, receive: (message: SyncMessage) => void): void;
12
+ send(message: SyncMessage): void;
13
+ close(): void;
14
+ }
@@ -0,0 +1,36 @@
1
+ /** Whether `value` is a {@link SyncMessage}. */
2
+ function isSyncMessage(value) {
3
+ if (typeof value !== "object" || value === null)
4
+ return false;
5
+ const message = value;
6
+ if (typeof message.from !== "number")
7
+ return false;
8
+ switch (message.type) {
9
+ case "hello":
10
+ case "welcome":
11
+ return message.stateVector instanceof Uint8Array;
12
+ case "update":
13
+ return message.update instanceof Uint8Array;
14
+ default:
15
+ return false;
16
+ }
17
+ }
18
+ /** Syncs every tab of this origin that joins room `name`. */
19
+ export class BroadcastTransport {
20
+ #channel;
21
+ constructor(name) {
22
+ this.#channel = new BroadcastChannel(`canvist-canvas:${name}`);
23
+ }
24
+ connect(_client, receive) {
25
+ this.#channel.onmessage = (event) => {
26
+ if (isSyncMessage(event.data))
27
+ receive(event.data);
28
+ };
29
+ }
30
+ send(message) {
31
+ this.#channel.postMessage(message);
32
+ }
33
+ close() {
34
+ this.#channel.close();
35
+ }
36
+ }
package/dist/mod.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The canvist canvas in the browser.
3
+ *
4
+ * `mountCanvas` puts the canvas on a `<canvas>` element: vello on WebGPU
5
+ * where the browser has it, `vello_cpu` and Canvas2D everywhere else. It
6
+ * starts from a scene you pass as scene JSON (one empty page by default),
7
+ * feeds DOM events to the canvas, paints on animation frames, and hands
8
+ * every collaboration update to a {@link Transport}.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { BroadcastTransport, mountCanvas } from "@canvist/canvas";
13
+ *
14
+ * const canvas = document.querySelector("canvas")!;
15
+ * const handle = await mountCanvas(canvas, {
16
+ * scene: { type: "canvist-scene", version: 1, nodes: [{ type: "page" }] },
17
+ * transport: new BroadcastTransport("my-room"),
18
+ * });
19
+ * handle.setTool("rectangle");
20
+ * const saved = handle.toJson();
21
+ * ```
22
+ *
23
+ * @module
24
+ */
25
+ import type { SceneJson } from "@canvist/headless/scene";
26
+ import { CanvasApp } from "../wasm/canvist_canvas_wasm.js";
27
+ export { CanvasApp };
28
+ export type { SceneJson } from "@canvist/headless/scene";
29
+ export { BroadcastTransport } from "./broadcast.ts";
30
+ export type { SyncMessage, Transport } from "./transport.ts";
31
+ import type { Transport } from "./transport.ts";
32
+ /** How to mount the canvas. */
33
+ export interface MountOptions {
34
+ /**
35
+ * The scene to start from, as scene JSON (nested or flat), such as one
36
+ * saved from {@link CanvasHandle.toJson}. Defaults to one empty page.
37
+ *
38
+ * Peers that start from the same JSON start from the same shared state,
39
+ * so give every peer in a room the same scene and they merge without
40
+ * duplicating anything.
41
+ */
42
+ scene?: SceneJson;
43
+ /** Start from canvist's demo scene instead of `scene`. */
44
+ demo?: boolean;
45
+ /** Where collaboration updates go and come from. Omit to edit alone. */
46
+ transport?: Transport;
47
+ /** Use WebGPU when the browser has it. Defaults to `true`. */
48
+ preferGpu?: boolean;
49
+ /** The wasm module's URL, when it is not next to the JS glue. */
50
+ wasmUrl?: URL | string;
51
+ /** Add this many extra rectangles, for measuring frame times. */
52
+ stress?: number;
53
+ /** Called after the canvas changes, for host UI such as a toolbar. */
54
+ onChange?: (handle: CanvasHandle) => void;
55
+ }
56
+ /** One painted frame's timing, in milliseconds. */
57
+ export interface FrameTime {
58
+ /** When the frame started, from `performance.now()`. */
59
+ at: number;
60
+ /** Time spent painting and presenting it. */
61
+ work: number;
62
+ }
63
+ /** A mounted canvas. */
64
+ export interface CanvasHandle {
65
+ /** The wasm app, for anything this handle does not wrap. */
66
+ readonly app: CanvasApp;
67
+ /** This peer's client id. */
68
+ readonly client: number;
69
+ /** `"webgpu"` or `"cpu"`. */
70
+ readonly backend: string;
71
+ /** The registered tools' names. */
72
+ readonly tools: readonly string[];
73
+ /** Switch tools by name. Returns whether one exists. */
74
+ setTool(name: string): boolean;
75
+ /** The active tool's name. */
76
+ tool(): string;
77
+ /** Recent frame timings, oldest first. */
78
+ frameTimes(): readonly FrameTime[];
79
+ /** Forget recorded frame timings. */
80
+ resetFrameTimes(): void;
81
+ /** The scene as scene JSON, to store or to start another canvas from. */
82
+ toJson(): SceneJson;
83
+ /** The whole shared scene as one v1 Yjs update, to store or to send a joining peer. */
84
+ encodeState(): Uint8Array;
85
+ /** Merge a v1 Yjs update, such as stored state; returns whether the scene changed. */
86
+ applyUpdate(update: Uint8Array): boolean;
87
+ /** Paint on the next animation frame. */
88
+ requestFrame(): void;
89
+ /** Stop listening and painting. */
90
+ destroy(): void;
91
+ }
92
+ /** Mount the canvas on `canvas`. */
93
+ export declare function mountCanvas(canvas: HTMLCanvasElement, options?: MountOptions): Promise<CanvasHandle>;
package/dist/mod.js ADDED
@@ -0,0 +1,313 @@
1
+ import init, { CanvasApp } from "../wasm/canvist_canvas_wasm.js";
2
+ export { CanvasApp };
3
+ export { BroadcastTransport } from "./broadcast.js";
4
+ /** The Shift bit in a modifier mask. */
5
+ const SHIFT = 1;
6
+ /** The Alt bit. */
7
+ const ALT = 2;
8
+ /** The primary bit: Cmd on Apple platforms, Ctrl elsewhere. */
9
+ const PRIMARY = 4;
10
+ /** The client id starting scenes are built under, which peers must not use. */
11
+ const SEED_CLIENT = 1;
12
+ /** One empty page: the scene a canvas starts from by default. */
13
+ const EMPTY_SCENE = {
14
+ type: "canvist-scene",
15
+ version: 1,
16
+ nodes: [{ type: "page" }],
17
+ };
18
+ /** Frame timings kept for {@link CanvasHandle.frameTimes}. */
19
+ const FRAME_HISTORY = 600;
20
+ /** Single-key tool shortcuts, as in most design tools. */
21
+ const TOOL_KEYS = {
22
+ v: "select",
23
+ h: "hand",
24
+ r: "rectangle",
25
+ o: "ellipse",
26
+ y: "polygon",
27
+ p: "pen",
28
+ f: "frame",
29
+ t: "text",
30
+ };
31
+ /** A random client id other than the seed id. */
32
+ function randomClient() {
33
+ const [value = 0] = crypto.getRandomValues(new Uint32Array(1));
34
+ return value <= SEED_CLIENT ? value + SEED_CLIENT + 1 : value;
35
+ }
36
+ function modifierMask(event) {
37
+ const apple = /Mac|iPhone|iPad/.test(navigator.platform);
38
+ const primary = apple ? event.metaKey : event.ctrlKey;
39
+ return (event.shiftKey ? SHIFT : 0) | (event.altKey ? ALT : 0) |
40
+ (primary ? PRIMARY : 0);
41
+ }
42
+ let initialized;
43
+ /** Mount the canvas on `canvas`. */
44
+ export async function mountCanvas(canvas, options = {}) {
45
+ initialized ??= init(options.wasmUrl ? { module_or_path: options.wasmUrl } : undefined);
46
+ await initialized;
47
+ const client = randomClient();
48
+ const app = await CanvasApp.create(canvas, client, options.preferGpu ?? true, options.demo === true
49
+ ? undefined
50
+ : JSON.stringify(options.scene ?? EMPTY_SCENE));
51
+ if (options.stress)
52
+ app.add_stress(options.stress);
53
+ return new Mounted(canvas, app, client, options);
54
+ }
55
+ /** The mounted canvas: DOM events in, frames out. */
56
+ class Mounted {
57
+ backend;
58
+ tools;
59
+ #frame = 0;
60
+ #lastFrame = 0;
61
+ #times = [];
62
+ #controller = new AbortController();
63
+ #resize;
64
+ #text = hiddenTextBox();
65
+ canvas;
66
+ app;
67
+ client;
68
+ options;
69
+ constructor(canvas, app, client, options) {
70
+ this.canvas = canvas;
71
+ this.app = app;
72
+ this.client = client;
73
+ this.options = options;
74
+ this.backend = app.backend();
75
+ this.tools = app.tools();
76
+ canvas.tabIndex = 0;
77
+ canvas.style.touchAction = "none";
78
+ canvas.after(this.#text);
79
+ this.#resize = new ResizeObserver(() => this.#fit());
80
+ this.#resize.observe(canvas);
81
+ this.#fit();
82
+ this.#listen();
83
+ options.transport?.connect(client, (message) => this.#receive(message));
84
+ options.transport?.send({
85
+ type: "hello",
86
+ from: client,
87
+ stateVector: app.state_vector(),
88
+ });
89
+ }
90
+ setTool(name) {
91
+ const found = this.app.set_tool(name);
92
+ this.#changed(found);
93
+ return found;
94
+ }
95
+ tool() {
96
+ return this.app.tool();
97
+ }
98
+ frameTimes() {
99
+ return this.#times;
100
+ }
101
+ resetFrameTimes() {
102
+ this.#times = [];
103
+ }
104
+ toJson() {
105
+ return JSON.parse(this.app.scene_json());
106
+ }
107
+ encodeState() {
108
+ return this.app.encode_state();
109
+ }
110
+ applyUpdate(update) {
111
+ const changed = this.app.apply_update(update);
112
+ if (changed) {
113
+ this.requestFrame();
114
+ this.options.onChange?.(this);
115
+ }
116
+ return changed;
117
+ }
118
+ requestFrame() {
119
+ if (this.#frame === 0) {
120
+ this.#frame = requestAnimationFrame((at) => this.#paint(at));
121
+ }
122
+ }
123
+ destroy() {
124
+ this.#controller.abort();
125
+ this.#resize.disconnect();
126
+ cancelAnimationFrame(this.#frame);
127
+ this.options.transport?.close();
128
+ this.#text.remove();
129
+ this.app.free();
130
+ }
131
+ #fit() {
132
+ const rect = this.canvas.getBoundingClientRect();
133
+ this.app.resize(rect.width, rect.height, devicePixelRatio);
134
+ this.requestFrame();
135
+ }
136
+ #paint(at) {
137
+ this.#frame = 0;
138
+ const elapsed = this.#lastFrame ? (at - this.#lastFrame) / 1000 : 0;
139
+ this.#lastFrame = at;
140
+ const coasting = this.app.frame(elapsed);
141
+ const start = performance.now();
142
+ this.app.render();
143
+ this.#times.push({ at: start, work: performance.now() - start });
144
+ if (this.#times.length > FRAME_HISTORY)
145
+ this.#times.shift();
146
+ this.canvas.style.cursor = this.app.cursor();
147
+ if (coasting)
148
+ this.requestFrame();
149
+ else
150
+ this.#lastFrame = 0;
151
+ }
152
+ /** Repaint and broadcast after an event the canvas handled. */
153
+ #changed(redraw) {
154
+ this.#focus();
155
+ for (const update of this.app.take_updates()) {
156
+ this.options.transport?.send({
157
+ type: "update",
158
+ from: this.client,
159
+ update,
160
+ });
161
+ }
162
+ if (redraw) {
163
+ this.requestFrame();
164
+ this.options.onChange?.(this);
165
+ }
166
+ }
167
+ #receive(message) {
168
+ if (message.from === this.client)
169
+ return;
170
+ switch (message.type) {
171
+ case "hello":
172
+ this.#sendMissing(message.stateVector);
173
+ // The newcomer may have changes of its own: ask for them.
174
+ this.options.transport?.send({
175
+ type: "welcome",
176
+ from: this.client,
177
+ stateVector: this.app.state_vector(),
178
+ });
179
+ break;
180
+ case "welcome":
181
+ this.#sendMissing(message.stateVector);
182
+ break;
183
+ case "update":
184
+ this.#integrate(message.update);
185
+ break;
186
+ }
187
+ }
188
+ /** Send the peer with `stateVector` everything it lacks. */
189
+ #sendMissing(stateVector) {
190
+ this.options.transport?.send({
191
+ type: "update",
192
+ from: this.client,
193
+ update: this.app.encode_diff(stateVector),
194
+ });
195
+ }
196
+ /** Apply a collaborator's update, repainting if anything changed. */
197
+ #integrate(update) {
198
+ this.applyUpdate(update);
199
+ }
200
+ #listen() {
201
+ const signal = this.#controller.signal;
202
+ const canvas = this.canvas;
203
+ const position = (event) => {
204
+ const rect = canvas.getBoundingClientRect();
205
+ return [event.clientX - rect.left, event.clientY - rect.top];
206
+ };
207
+ const pointer = (phase) => (event) => {
208
+ if (phase === "down") {
209
+ canvas.focus();
210
+ canvas.setPointerCapture(event.pointerId);
211
+ }
212
+ const [x, y] = position(event);
213
+ this.#changed(this.app.pointer(phase, event.pointerId, event.pointerType, x, y, event.buttons, modifierMask(event), event.timeStamp));
214
+ };
215
+ canvas.addEventListener("pointerdown", pointer("down"), { signal });
216
+ canvas.addEventListener("pointermove", pointer("move"), { signal });
217
+ canvas.addEventListener("pointerup", pointer("up"), { signal });
218
+ canvas.addEventListener("pointercancel", pointer("cancel"), { signal });
219
+ canvas.addEventListener("wheel", (event) => {
220
+ event.preventDefault();
221
+ const [x, y] = position(event);
222
+ this.#changed(this.app.wheel(event.deltaX, event.deltaY, x, y, modifierMask(event), event.ctrlKey, event.timeStamp));
223
+ }, { signal, passive: false });
224
+ // Keys reach the canvas while it has focus, and the hidden text box
225
+ // while a text frame is open, so IMEs, dictation, and autocorrect
226
+ // deliver text the way they do to any text field.
227
+ const targets = [canvas, this.#text];
228
+ for (const target of targets) {
229
+ target.addEventListener("keydown", (event) => this.#key(event, true), {
230
+ signal,
231
+ });
232
+ target.addEventListener("keyup", (event) => this.#key(event, false), {
233
+ signal,
234
+ });
235
+ target.addEventListener("paste", (event) => this.#paste(event), {
236
+ signal,
237
+ });
238
+ }
239
+ this.#text.addEventListener("beforeinput", (event) => {
240
+ if (event.inputType === "insertText" && event.data) {
241
+ event.preventDefault();
242
+ this.#changed(this.app.type_text(event.data, event.timeStamp));
243
+ }
244
+ }, { signal });
245
+ this.#text.addEventListener("compositionend", (event) => {
246
+ this.#text.value = "";
247
+ if (event.data) {
248
+ this.#changed(this.app.type_text(event.data, event.timeStamp));
249
+ }
250
+ }, { signal });
251
+ }
252
+ /** Pasted plain text goes into the open text frame. */
253
+ #paste(event) {
254
+ const text = event.clipboardData?.getData("text/plain");
255
+ if (!text)
256
+ return;
257
+ event.preventDefault();
258
+ this.#changed(this.app.type_text(text, event.timeStamp));
259
+ }
260
+ #key(event, down) {
261
+ const mask = modifierMask(event);
262
+ const editing = this.app.editing();
263
+ if (event.isComposing)
264
+ return;
265
+ const shortcut = !editing && down && mask === 0
266
+ ? TOOL_KEYS[event.key.toLowerCase()]
267
+ : undefined;
268
+ if (shortcut) {
269
+ event.preventDefault();
270
+ this.setTool(shortcut);
271
+ return;
272
+ }
273
+ // Characters arrive through the text box's input events instead.
274
+ if (editing && isCharacter(event, mask))
275
+ return;
276
+ const used = this.app.key(event.key, down, mask, event.timeStamp);
277
+ if (used)
278
+ event.preventDefault();
279
+ this.#changed(used);
280
+ }
281
+ /** Keep focus on the text box while a text frame is open. */
282
+ #focus() {
283
+ const editing = this.app.editing();
284
+ const active = document.activeElement;
285
+ if (editing && active !== this.#text) {
286
+ this.#text.focus({ preventScroll: true });
287
+ }
288
+ else if (!editing && active === this.#text) {
289
+ this.canvas.focus({ preventScroll: true });
290
+ }
291
+ }
292
+ }
293
+ /** Whether `event` types a character rather than running a command. */
294
+ function isCharacter(event, mask) {
295
+ return (mask & PRIMARY) === 0 && [...event.key].length === 1;
296
+ }
297
+ /** A text box the user never sees, which receives typed text. */
298
+ function hiddenTextBox() {
299
+ const text = document.createElement("textarea");
300
+ text.setAttribute("aria-label", "Text frame");
301
+ text.autocapitalize = "off";
302
+ text.spellcheck = false;
303
+ Object.assign(text.style, {
304
+ position: "fixed",
305
+ top: "0",
306
+ left: "0",
307
+ width: "1px",
308
+ height: "1px",
309
+ opacity: "0",
310
+ pointerEvents: "none",
311
+ });
312
+ return text;
313
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How peers exchange the shared scene.
3
+ *
4
+ * Every peer starts from the same scene's state, so syncing is a state-vector
5
+ * handshake: a new peer says `hello` with its state vector, each existing
6
+ * peer answers with what the newcomer lacks and a `welcome` carrying its
7
+ * own state vector, and the newcomer answers that in turn. After that,
8
+ * every local change goes out as an `update`.
9
+ *
10
+ * @module
11
+ */
12
+ /** A message between peers. `from` is the sender's client id. */
13
+ export type SyncMessage = {
14
+ type: "hello";
15
+ from: number;
16
+ stateVector: Uint8Array;
17
+ } | {
18
+ type: "welcome";
19
+ from: number;
20
+ stateVector: Uint8Array;
21
+ } | {
22
+ type: "update";
23
+ from: number;
24
+ update: Uint8Array;
25
+ };
26
+ /** Carries {@link SyncMessage}s between peers. */
27
+ export interface Transport {
28
+ /** Start delivering other peers' messages to `receive`. */
29
+ connect(client: number, receive: (message: SyncMessage) => void): void;
30
+ /** Send `message` to every other peer. */
31
+ send(message: SyncMessage): void;
32
+ /** Stop sending and receiving. */
33
+ close(): void;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1 +1,43 @@
1
- {"description":"Placeholder package for @canvist/canvas","name":"@canvist/canvas","repository":"https://github.com/canvist/canvist","version":"0.0.0"}
1
+ {
2
+ "name": "@canvist/canvas",
3
+ "version": "0.2.0",
4
+ "description": "canvist's design canvas for the browser: shapes, frames, images, vector paths, and text frames on the real canvist editor, rendered by vello on WebGPU with a CPU fallback, and collaborative.",
5
+ "license": "Unlicense",
6
+ "homepage": "https://github.com/canvist/canvist/tree/main/packages/canvist-canvas#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/canvist/canvist.git",
10
+ "directory": "packages/canvist-canvas"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/canvist/canvist/issues",
14
+ "email": "support@canvist.dev"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/mod.d.ts",
20
+ "default": "./dist/mod.js"
21
+ },
22
+ "./wasm/canvist_canvas_wasm_bg.wasm": "./wasm/canvist_canvas_wasm_bg.wasm"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "wasm/*.js",
27
+ "wasm/*.d.ts",
28
+ "wasm/*.wasm"
29
+ ],
30
+ "sideEffects": false,
31
+ "scripts": {
32
+ "build:wasm": "cd ../.. && wasm-pack build crates/canvist_canvas_wasm --target web --release --out-dir ../../packages/canvist-canvas/wasm --no-pack && rm packages/canvist-canvas/wasm/.gitignore",
33
+ "check:types": "tsc --noEmit",
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "prepack": "npm run build"
36
+ },
37
+ "dependencies": {
38
+ "@canvist/headless": "^0.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^7.0.2"
42
+ }
43
+ }
package/readme.md ADDED
@@ -0,0 +1,86 @@
1
+ <p align="center">
2
+ <a href="https://github.com/canvist/canvist">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/canvist/canvist/main/assets/brand/vector/lockup-dark.svg">
5
+ <img width="220" src="https://raw.githubusercontent.com/canvist/canvist/main/assets/brand/vector/lockup.svg" alt="canvist" />
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/canvist/canvist/blob/main/docs/src/readme.md"><strong>Docs</strong></a> ·
12
+ <a href="https://github.com/canvist/canvist/blob/main/docs/src/reference/contributing.md"><strong>Contributing</strong></a> ·
13
+ <a href="https://github.com/canvist/canvist"><strong>canvist</strong></a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://github.com/canvist/canvist/actions/workflows/canvas-web.yml"><img src="https://github.com/canvist/canvist/actions/workflows/canvas-web.yml/badge.svg?branch=main" alt="canvas-web status" /></a>
18
+ <a href="https://unlicense.org"><img src="https://img.shields.io/badge/license-Unlicense-3B2CF5" alt="Unlicense" /></a>
19
+ </p>
20
+
21
+ # @canvist/canvas
22
+
23
+ canvist's design canvas in the browser: shapes, frames, images, vector paths, and text frames edited with the real canvist text editor. It renders with vello on WebGPU and falls back to a CPU rasteriser where WebGPU is missing. A pluggable transport handles collaboration.
24
+
25
+ ```sh
26
+ npm install @canvist/canvas
27
+ ```
28
+
29
+ ```ts
30
+ import { BroadcastTransport, mountCanvas } from "@canvist/canvas";
31
+
32
+ const canvas = await mountCanvas(document.querySelector("canvas")!, {
33
+ scene: { type: "canvist-scene", version: 1, nodes: [{ type: "page" }] },
34
+ transport: new BroadcastTransport("my-room"),
35
+ });
36
+
37
+ canvas.setTool("rectangle");
38
+ console.log(canvas.backend); // "webgpu" or "cpu"
39
+ const saved = canvas.toJson(); // scene JSON, to store or start from later
40
+ ```
41
+
42
+ - **`scene`** is scene JSON, nested or flat: the same format `@canvist/headless/scene` reads and agents write. It defaults to one empty page, and `demo: true` starts from canvist's demo scene instead.
43
+ - **Collaboration.** A `Transport` carries three messages: `hello`, `welcome`, and `update` (see `src/transport.ts`). `BroadcastTransport` syncs the tabs of one browser, and a WebSocket transport is a few lines on the same interface. Give every peer the same starting scene: each builds it the same way, so merging peers duplicates nothing.
44
+ - **State.** `encodeState()` is the shared scene as one Yjs update, and `applyUpdate(update)` merges one, such as stored state.
45
+
46
+ [The design canvas quick start](../../docs/src/quick-start/canvas.md) walks through it.
47
+
48
+ ## The demo
49
+
50
+ ```bash
51
+ deno task build && deno task demo
52
+ ```
53
+
54
+ Open <http://127.0.0.1:8787/>, then open it in a second tab. The scene has rectangles, ellipses, a star, a vector path, an image, a frame that clips its children, and a text frame. Double-click the text to edit it.
55
+
56
+ | Key | Tool |
57
+ | --- | --------- |
58
+ | V | Select |
59
+ | H | Hand |
60
+ | R | Rectangle |
61
+ | O | Ellipse |
62
+ | Y | Star |
63
+ | P | Pen |
64
+ | F | Frame |
65
+ | T | Text |
66
+
67
+ The wheel pans, and a trackpad pinch (or Ctrl with the wheel) zooms. Cmd/Ctrl+Z undoes only your own changes. Query parameters:
68
+
69
+ - `?cpu` forces the CPU renderer.
70
+ - `?stress=2000` adds 2,000 rectangles.
71
+ - `?room=name` picks a room.
72
+ - `?scene=` starts from that scene JSON instead of the demo scene.
73
+
74
+ ## Tests
75
+
76
+ ```bash
77
+ deno task test
78
+ ```
79
+
80
+ This needs Chromium from `npx playwright@1.58 install chromium`. The suite has four tests:
81
+
82
+ - Two record frame times during a scripted pan and drag over a 2,000-node scene, one on WebGPU and one on the CPU fallback. They print `CANVAS_PERF` lines and fail when the 95th-percentile frame goes over budget: 8 ms on WebGPU, 33 ms on the CPU. Set `CANVIST_PERF_SLACK` to scale the budgets on slow machines.
83
+ - One drives two tabs editing one scene: drawing a rectangle and typing in a text frame.
84
+ - One starts two tabs from the same scene JSON and checks they share it without duplicates, and that it exports.
85
+
86
+ For npm, `npm run build:wasm && npm run build` builds the module and `dist/`.