gpucanvas 0.4.0 → 0.4.2

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.
package/README.md CHANGED
@@ -29,12 +29,22 @@ The same session on both sides: 5,000 live React screens, the same camera path,
29
29
  Marks from an agent, a wipe and a dissolve, the glass lens and shockwaves, all on live
30
30
  components. Recorded in Chrome from the demo.
31
31
 
32
+ How it works, with the benchmarks and what Chrome's HTML-in-Canvas does differently from the
33
+ explainer: [gpucanvas: 5,000 Live React Screens on a WebGPU Infinite Canvas](https://berkant.me/blog/gpucanvas-webgpu-infinite-canvas).
34
+
32
35
  ## Try it
33
36
 
34
37
  Open **[berkantay.github.io/gpucanvas](https://berkantay.github.io/gpucanvas/)** in Chrome 157 or
35
38
  later. Thirty agent cursors build the first view, then the camera pulls out to all 5,000 screens.
36
39
 
37
- - Click a screen to select it, double-click to go deeper, drag to move it.
40
+ - Click a screen to select it, double-click to go deeper, drag to move it. Double-click a text
41
+ field to type in it, or switch the toolbar to Interact and use the screens like a normal page.
42
+ - In Interact, every button does something. Continue, Sign in and Back move the camera to the
43
+ next screen like a clickable prototype, Notifications and More open panels, Export saves a
44
+ CSV, Send adds the message and Share copies the link.
45
+ - The toolbar holds every setting: renderer, number of screens, Edit or Interact, what a click
46
+ selects, the shader effect, the agent cursors and the theme. They apply live, and the URL
47
+ remembers them.
38
48
  - Pick a shader effect in the toolbar, or open one directly:
39
49
  [glass](https://berkantay.github.io/gpucanvas/?effect=glass),
40
50
  [shockwave](https://berkantay.github.io/gpucanvas/?effect=shockwave) (click anywhere),
@@ -42,7 +52,8 @@ later. Thirty agent cursors build the first view, then the camera pulls out to a
42
52
  [glitch](https://berkantay.github.io/gpucanvas/?effect=glitch).
43
53
  - Select a screen and press `D` to dissolve it, `W` to wipe it, or `S` to save it as a PNG.
44
54
  - In the console, `agentView.describe()` lists what is in view, and `marks` draws on it.
45
- - Press `b` to run the benchmark tour.
55
+ - Press `b` to run the benchmark tour, `f` to fit every screen in view, `t` to switch the theme.
56
+ - On a phone: pinch to zoom, drag to pan, tap to use a screen.
46
57
 
47
58
  The GPU path needs HTML-in-Canvas, which is behind a flag:
48
59
 
@@ -50,7 +61,10 @@ The GPU path needs HTML-in-Canvas, which is behind a flag:
50
61
  2. Set it to Enabled and relaunch Chrome
51
62
  3. Reload the demo
52
63
 
53
- Without the flag the demo runs the DOM fallback with 1,000 screens and says so.
64
+ Without the flag the demo runs the DOM fallback with 1,000 screens and says so. Phones start
65
+ smaller so the tab is not killed for memory: 1,000 screens on the GPU path (Chrome on Android with
66
+ the flag) and 250 as DOM. iPhone and iPad browsers have no HTML-in-Canvas yet, so they run the DOM
67
+ version and say so.
54
68
 
55
69
  Status: early (0.4). The API can still change.
56
70
 
@@ -100,10 +114,10 @@ so every double-click visibly changes something.
100
114
 
101
115
  | Input | Does |
102
116
  | ------------------ | ---------------------------------------------------------------------------- |
103
- | Double-click | One layer deeper |
117
+ | Double-click | One layer deeper; on a text field, starts typing in it |
104
118
  | Enter, Shift+Enter | First child, parent |
105
119
  | Tab, Shift+Tab | Next and previous sibling |
106
- | Escape | Cancels a drag, then goes up a layer, then clears |
120
+ | Escape | Ends typing, cancels a drag, then goes up a layer, then clears |
107
121
  | Drag | Moves, snapping to the edges and centers of neighbors (Shift locks the axis) |
108
122
  | Handles | Resize (Shift on a corner keeps the ratio) |
109
123
  | Drag on open space | A box that selects the layers it touches, inside the layer you are in |
@@ -143,9 +157,8 @@ canvas.setHover(el); // a lighter outline; null clears
143
157
  canvas.screenToWorld(point); // surface pixels to world units, and worldToScreen back
144
158
  ```
145
159
 
146
- Colors are the `selectionColor` and `hoverColor` options. The demo picks React components in the edit mode;
147
- `?pick=element` or `?pick=node` picks elements or whole screens, and `?edit=0` leaves clicks to
148
- the components.
160
+ Colors are the `selectionColor` and `hoverColor` options. In the demo, the toolbar switches between
161
+ Edit and Interact (clicks reach the components) and picks components, elements or whole screens.
149
162
 
150
163
  ## Shader effects
151
164
 
@@ -327,7 +340,9 @@ to npm from GitHub Actions, with provenance, through npm trusted publishing. See
327
340
  under 200 screen pixels refreshes at most four times a second.
328
341
  7. **Input.** Only the page under the pointer is registered for hit testing, and a CSS transform
329
342
  puts the node under the pointer exactly where the GPU drew it. Clicks, typing, selection and
330
- hover all go to the real element. A focused node keeps its page live.
343
+ hover all go to the real element. A focused node keeps its page live. Touch has no hover, so a
344
+ tap makes the node under the finger live and hands the click to the element it landed on. A
345
+ two-finger pinch zooms and pans the camera wherever the fingers land.
331
346
 
332
347
  ## Measured (M-series Mac, Chrome 157, 1600 x 900 window, DPR 1)
333
348
 
@@ -56,7 +56,25 @@ function bindCameraInput(element, getCamera, onChange) {
56
56
  }
57
57
  };
58
58
  let drag = null;
59
+ const touches = /* @__PURE__ */ new Map();
60
+ const pinch = () => {
61
+ const [a, b] = [...touches.values()];
62
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, distance: Math.hypot(a.x - b.x, a.y - b.y) };
63
+ };
59
64
  const onPointerDown = (event) => {
65
+ if (event.pointerType === "touch") {
66
+ touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
67
+ if (touches.size === 2) {
68
+ drag = null;
69
+ for (const id of touches.keys()) {
70
+ try {
71
+ element.setPointerCapture(id);
72
+ } catch {
73
+ }
74
+ }
75
+ return;
76
+ }
77
+ }
60
78
  if (event.button !== 0 && event.button !== 1 || event.shiftKey) return;
61
79
  if (event.target !== element && !event.target.hasAttribute?.("data-gpucanvas-surface"))
62
80
  return;
@@ -64,6 +82,31 @@ function bindCameraInput(element, getCamera, onChange) {
64
82
  element.setPointerCapture(event.pointerId);
65
83
  };
66
84
  const onPointerMove = (event) => {
85
+ const touch = touches.get(event.pointerId);
86
+ if (touch && touches.size === 2) {
87
+ const before = pinch();
88
+ touch.x = event.clientX;
89
+ touch.y = event.clientY;
90
+ const after = pinch();
91
+ if (!before.distance) return;
92
+ const bounds = element.getBoundingClientRect();
93
+ const zoomed = zoomAt(
94
+ getCamera(),
95
+ before.x - bounds.left,
96
+ before.y - bounds.top,
97
+ after.distance / before.distance
98
+ );
99
+ onChange({
100
+ ...zoomed,
101
+ x: zoomed.x - (after.x - before.x) / zoomed.zoom,
102
+ y: zoomed.y - (after.y - before.y) / zoomed.zoom
103
+ });
104
+ return;
105
+ }
106
+ if (touch) {
107
+ touch.x = event.clientX;
108
+ touch.y = event.clientY;
109
+ }
67
110
  if (!drag || event.pointerId !== drag.pointerId) return;
68
111
  const camera = getCamera();
69
112
  onChange({
@@ -75,6 +118,7 @@ function bindCameraInput(element, getCamera, onChange) {
75
118
  drag.y = event.clientY;
76
119
  };
77
120
  const onPointerUp = (event) => {
121
+ touches.delete(event.pointerId);
78
122
  if (drag?.pointerId === event.pointerId) drag = null;
79
123
  };
80
124
  element.addEventListener("wheel", onWheel, { passive: false });
@@ -2181,14 +2225,44 @@ async function createGpuCanvas(host, options) {
2181
2225
  };
2182
2226
  let press = FAR_AWAY;
2183
2227
  let pressedAt = -Infinity;
2228
+ let tapped = false;
2184
2229
  const onPointerDown = (event) => {
2185
2230
  const bounds = canvas.getBoundingClientRect();
2186
- press = screenToWorld(camera, {
2231
+ const at = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
2232
+ press = screenToWorld(camera, at);
2233
+ pressedAt = performance.now();
2234
+ if (event.pointerType !== "mouse") {
2235
+ pointer = at;
2236
+ pickLive();
2237
+ tapped = true;
2238
+ }
2239
+ if (effects.size) schedule();
2240
+ };
2241
+ const onClick = (event) => {
2242
+ if (!tapped) return;
2243
+ tapped = false;
2244
+ const bounds = canvas.getBoundingClientRect();
2245
+ const point = screenToWorld(camera, {
2187
2246
  x: event.clientX - bounds.left,
2188
2247
  y: event.clientY - bounds.top
2189
2248
  });
2190
- pressedAt = performance.now();
2191
- if (effects.size) schedule();
2249
+ let top = null;
2250
+ for (const node of nodes.values()) if (containsPoint(node.rect, point)) top = node;
2251
+ if (!top || top.element.contains(event.target)) return;
2252
+ let target = elementInNode(top.element, top.rect, point);
2253
+ while (target && !(target instanceof HTMLElement)) target = target.parentElement;
2254
+ if (!(target instanceof HTMLElement)) return;
2255
+ target.focus({ preventScroll: true });
2256
+ target.dispatchEvent(
2257
+ new MouseEvent("click", {
2258
+ bubbles: true,
2259
+ cancelable: true,
2260
+ composed: true,
2261
+ view: window,
2262
+ clientX: event.clientX,
2263
+ clientY: event.clientY
2264
+ })
2265
+ );
2192
2266
  };
2193
2267
  const onFocusIn = (event) => {
2194
2268
  const node = nodeOf(event.target) ?? null;
@@ -2229,6 +2303,7 @@ async function createGpuCanvas(host, options) {
2229
2303
  surface.addEventListener("pointermove", onPointerMove);
2230
2304
  surface.addEventListener("pointerleave", onPointerLeave);
2231
2305
  surface.addEventListener("pointerdown", onPointerDown, true);
2306
+ surface.addEventListener("click", onClick);
2232
2307
  canvas.addEventListener("focusin", onFocusIn);
2233
2308
  canvas.addEventListener("focusout", onFocusOut);
2234
2309
  const usedAt = /* @__PURE__ */ new WeakMap();
@@ -2507,6 +2582,7 @@ async function createGpuCanvas(host, options) {
2507
2582
  surface.removeEventListener("pointermove", onPointerMove);
2508
2583
  surface.removeEventListener("pointerleave", onPointerLeave);
2509
2584
  surface.removeEventListener("pointerdown", onPointerDown, true);
2585
+ surface.removeEventListener("click", onClick);
2510
2586
  canvas.removeEventListener("focusin", onFocusIn);
2511
2587
  canvas.removeEventListener("focusout", onFocusOut);
2512
2588
  canvas.removeEventListener("transitionrun", onTransitionRun);
@@ -3016,6 +3092,7 @@ function bindEditor(canvas, options = {}) {
3016
3092
  let pointer = null;
3017
3093
  let shift = false;
3018
3094
  let swallowClick = false;
3095
+ let keepFocus = false;
3019
3096
  const commit = (next, nextScopes = scopes) => {
3020
3097
  selection = next.filter((layer, i) => next.findIndex((other) => sameLayer(layer, other)) === i);
3021
3098
  scopes = selection.length ? nextScopes : [];
@@ -3034,6 +3111,15 @@ function bindEditor(canvas, options = {}) {
3034
3111
  return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
3035
3112
  };
3036
3113
  const chainAt = (point) => layers.chainAt(canvas.screenToWorld(point));
3114
+ const fieldAt = (point) => {
3115
+ const element = canvas.getElementAt(canvas.screenToWorld(point));
3116
+ const field = element?.closest("input, textarea, select, [contenteditable]") ?? null;
3117
+ return isEditable(field) ? field : null;
3118
+ };
3119
+ const typingIn = () => {
3120
+ const active = document.activeElement;
3121
+ return isEditable(active) && canvas.getNodeOf(active) ? active : null;
3122
+ };
3037
3123
  const targetIn = (chain) => {
3038
3124
  if (!chain.length) return null;
3039
3125
  for (let i = scopes.length - 1; i >= 0; i--) {
@@ -3187,8 +3273,15 @@ function bindEditor(canvas, options = {}) {
3187
3273
  swallowClick = false;
3188
3274
  if (event.button !== 0) return;
3189
3275
  const point = at(event);
3276
+ const field = typingIn();
3277
+ keepFocus = false;
3278
+ if (field && field.contains(fieldAt(point))) {
3279
+ keepFocus = !field.contains(event.target);
3280
+ return;
3281
+ }
3190
3282
  const take = () => {
3191
3283
  event.stopPropagation();
3284
+ field?.blur();
3192
3285
  swallowClick = true;
3193
3286
  surface.setPointerCapture(event.pointerId);
3194
3287
  };
@@ -3261,7 +3354,20 @@ function bindEditor(canvas, options = {}) {
3261
3354
  if (!selecting(event.shiftKey)) return;
3262
3355
  event.preventDefault();
3263
3356
  event.stopPropagation();
3264
- const chain = chainAt(at(event));
3357
+ const point = at(event);
3358
+ const field = trigger === "click" ? fieldAt(point) : null;
3359
+ if (field) {
3360
+ if (selection.length) commit([], []);
3361
+ field.focus();
3362
+ if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
3363
+ try {
3364
+ field.setSelectionRange(field.value.length, field.value.length);
3365
+ } catch {
3366
+ }
3367
+ }
3368
+ return;
3369
+ }
3370
+ const chain = chainAt(point);
3265
3371
  let depth = -1;
3266
3372
  chain.forEach((layer, i) => {
3267
3373
  if (layerIn(layer, selection)) depth = i;
@@ -3273,6 +3379,11 @@ function bindEditor(canvas, options = {}) {
3273
3379
  setHovered(null);
3274
3380
  };
3275
3381
  const onMouseDown = (event) => {
3382
+ if (keepFocus) {
3383
+ keepFocus = false;
3384
+ event.preventDefault();
3385
+ return;
3386
+ }
3276
3387
  if (!swallowClick) return;
3277
3388
  event.preventDefault();
3278
3389
  event.stopPropagation();
@@ -3292,7 +3403,11 @@ function bindEditor(canvas, options = {}) {
3292
3403
  shift = event.type === "keydown";
3293
3404
  return updateHover();
3294
3405
  }
3295
- if (event.type !== "keydown" || isEditable(document.activeElement)) return;
3406
+ if (event.type !== "keydown") return;
3407
+ if (isEditable(document.activeElement)) {
3408
+ if (event.key === "Escape" && trigger === "click") typingIn()?.blur();
3409
+ return;
3410
+ }
3296
3411
  if (event.key === "Escape") {
3297
3412
  if (gesture?.kind === "move" || gesture?.kind === "resize") {
3298
3413
  emit("cancel");
@@ -3773,4 +3888,4 @@ export {
3773
3888
  swapNodes,
3774
3889
  createGpuCanvas2 as createGpuCanvas
3775
3890
  };
3776
- //# sourceMappingURL=chunk-BQQVSR4B.js.map
3891
+ //# sourceMappingURL=chunk-TC6XBFOY.js.map