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 +24 -9
- package/dist/{chunk-BQQVSR4B.js → chunk-TC6XBFOY.js} +121 -6
- package/dist/chunk-TC6XBFOY.js.map +1 -0
- package/dist/{editor-D87kaZBM.d.ts → editor-CuH4tLHg.d.ts} +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-BQQVSR4B.js.map +0 -1
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 |
|
|
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.
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2191
|
-
|
|
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
|
|
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"
|
|
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-
|
|
3891
|
+
//# sourceMappingURL=chunk-TC6XBFOY.js.map
|