gpucanvas 0.1.0 → 0.3.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.
@@ -1,4 +1,12 @@
1
1
  // src/camera.ts
2
+ var screenToWorld = (camera, point) => ({
3
+ x: camera.x + point.x / camera.zoom,
4
+ y: camera.y + point.y / camera.zoom
5
+ });
6
+ var worldToScreen = (camera, point) => ({
7
+ x: (point.x - camera.x) * camera.zoom,
8
+ y: (point.y - camera.y) * camera.zoom
9
+ });
2
10
  var MIN_ZOOM = 0.02;
3
11
  var MAX_ZOOM = 16;
4
12
  var clampZoom = (zoom) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
@@ -16,6 +24,7 @@ function visibleWorldRect(camera, viewportWidth, viewportHeight) {
16
24
  height: viewportHeight / camera.zoom
17
25
  };
18
26
  }
27
+ var containsPoint = (rect, point) => point.x >= rect.x && point.x < rect.x + rect.width && point.y >= rect.y && point.y < rect.y + rect.height;
19
28
  var intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
20
29
  function fitCamera(bounds, viewportWidth, viewportHeight, padding = 40) {
21
30
  const zoom = clampZoom(
@@ -48,7 +57,7 @@ function bindCameraInput(element, getCamera, onChange) {
48
57
  };
49
58
  let drag = null;
50
59
  const onPointerDown = (event) => {
51
- if (event.button !== 0 && event.button !== 1) return;
60
+ if (event.button !== 0 && event.button !== 1 || event.shiftKey) return;
52
61
  if (event.target !== element && !event.target.hasAttribute?.("data-gpucanvas-surface"))
53
62
  return;
54
63
  drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
@@ -82,6 +91,392 @@ function bindCameraInput(element, getCamera, onChange) {
82
91
  };
83
92
  }
84
93
 
94
+ // src/effects.ts
95
+ var defineEffect = (definition) => Object.freeze({
96
+ animated: false,
97
+ params: [],
98
+ pad: 0,
99
+ ...definition
100
+ });
101
+ var effectParams = (effect, params) => [0, 1, 2, 3].map((i) => params?.[i] ?? effect.params[i] ?? 0);
102
+ var effectPad = (effect, params) => typeof effect.pad === "function" ? effect.pad(params) : effect.pad;
103
+ var HUE_ROTATE = (
104
+ /* wgsl */
105
+ `
106
+ fn hueRotate(color: vec3f, degrees: f32) -> vec3f {
107
+ let c = cos(radians(degrees));
108
+ let s = sin(radians(degrees));
109
+ let m = mat3x3f(
110
+ vec3f(0.213 + 0.787 * c - 0.213 * s, 0.213 - 0.213 * c + 0.143 * s, 0.213 - 0.213 * c - 0.787 * s),
111
+ vec3f(0.715 - 0.715 * c - 0.715 * s, 0.715 + 0.285 * c + 0.140 * s, 0.715 - 0.715 * c + 0.715 * s),
112
+ vec3f(0.072 - 0.072 * c + 0.928 * s, 0.072 - 0.072 * c - 0.283 * s, 0.072 + 0.928 * c + 0.072 * s),
113
+ );
114
+ return m * color;
115
+ }
116
+ `
117
+ );
118
+ var hue = defineEffect({
119
+ name: "hue",
120
+ animated: true,
121
+ params: [90],
122
+ wgsl: (
123
+ /* wgsl */
124
+ `${HUE_ROTATE}
125
+ fn effect(input: EffectInput) -> vec4f {
126
+ let color = source(input.uv);
127
+ return vec4f(clamp(hueRotate(color.rgb, input.time * input.params.x), vec3f(0.0), vec3f(color.a)), color.a);
128
+ }`
129
+ ),
130
+ css: ([speed], time) => `hue-rotate(${(time * speed % 360).toFixed(1)}deg)`
131
+ });
132
+ var grayscale = defineEffect({
133
+ name: "grayscale",
134
+ params: [1],
135
+ wgsl: (
136
+ /* wgsl */
137
+ `
138
+ fn effect(input: EffectInput) -> vec4f {
139
+ let color = source(input.uv);
140
+ let gray = dot(color.rgb, vec3f(0.2126, 0.7152, 0.0722));
141
+ return vec4f(mix(color.rgb, vec3f(gray), input.params.x), color.a);
142
+ }`
143
+ ),
144
+ css: ([amount]) => `grayscale(${amount})`
145
+ });
146
+ var blur = defineEffect({
147
+ name: "blur",
148
+ params: [6],
149
+ pad: ([radius]) => radius,
150
+ wgsl: (
151
+ /* wgsl */
152
+ `
153
+ fn effect(input: EffectInput) -> vec4f {
154
+ let radius = input.params.x / input.size;
155
+ var color = vec4f(0.0);
156
+ var weight = 0.0;
157
+ // A Vogel spiral: even coverage of the disk with few taps.
158
+ for (var i = 0u; i < 32u; i++) {
159
+ let r = sqrt((f32(i) + 0.5) / 32.0);
160
+ let a = f32(i) * 2.39996;
161
+ let w = exp(-2.0 * r * r);
162
+ color += source(input.uv + vec2f(cos(a), sin(a)) * r * radius) * w;
163
+ weight += w;
164
+ }
165
+ return color / weight;
166
+ }`
167
+ ),
168
+ css: ([radius]) => `blur(${radius / 2}px)`
169
+ });
170
+ var glow = defineEffect({
171
+ name: "glow",
172
+ animated: true,
173
+ params: [0.31, 0.55, 1, 24],
174
+ pad: (params) => params[3],
175
+ period: Math.PI,
176
+ wgsl: (
177
+ /* wgsl */
178
+ `
179
+ fn effect(input: EffectInput) -> vec4f {
180
+ let color = source(input.uv);
181
+ let reach = input.params.w * (0.65 + 0.35 * sin(input.time * 2.0));
182
+ // How much of the shape lies within reach, sampled on two rings.
183
+ var cover = 0.0;
184
+ for (var i = 0u; i < 24u; i++) {
185
+ let a = f32(i) * 0.2618;
186
+ let r = select(0.5, 1.0, i % 2u == 0u);
187
+ cover += source(input.uv + vec2f(cos(a), sin(a)) * r * reach / input.size).a;
188
+ }
189
+ let alpha = smoothstep(0.0, 1.0, cover / 24.0) * 0.9;
190
+ return color + vec4f(input.params.rgb * alpha, alpha) * (1.0 - color.a);
191
+ }`
192
+ ),
193
+ css: ([r, g, b, reach], time) => {
194
+ const radius = reach * (0.65 + 0.35 * Math.sin(time * 2));
195
+ const rgb = [r, g, b].map((v) => Math.round(v * 255)).join(" ");
196
+ return `drop-shadow(0 0 ${(radius / 2).toFixed(1)}px rgb(${rgb} / 0.9))`;
197
+ }
198
+ });
199
+ var ripple = defineEffect({
200
+ name: "ripple",
201
+ animated: true,
202
+ params: [6, 90],
203
+ pad: ([amplitude]) => amplitude,
204
+ wgsl: (
205
+ /* wgsl */
206
+ `
207
+ fn effect(input: EffectInput) -> vec4f {
208
+ let p = (input.uv - 0.5) * input.size;
209
+ let d = length(p);
210
+ let wave = sin(d / input.params.y * 6.2832 - input.time * 4.0);
211
+ let push = select(vec2f(0.0), p / d, d > 0.001) * wave * input.params.x;
212
+ let color = source(input.uv + push / input.size);
213
+ return vec4f(color.rgb * (1.0 + 0.06 * wave), color.a);
214
+ }`
215
+ )
216
+ });
217
+ var pixelate = defineEffect({
218
+ name: "pixelate",
219
+ params: [8],
220
+ wgsl: (
221
+ /* wgsl */
222
+ `
223
+ fn effect(input: EffectInput) -> vec4f {
224
+ let block = max(input.params.x, 1.0);
225
+ let cell = (floor(input.uv * input.size / block) + 0.5) * block;
226
+ return source(cell / input.size);
227
+ }`
228
+ )
229
+ });
230
+ var dissolve = defineEffect({
231
+ name: "dissolve",
232
+ params: [0.5],
233
+ wgsl: (
234
+ /* wgsl */
235
+ `
236
+ fn effect(input: EffectInput) -> vec4f {
237
+ let color = source(input.uv);
238
+ let p = input.uv * input.size / 24.0;
239
+ let n = noise(p) * 0.65 + noise(p * 2.7) * 0.35;
240
+ let edge = n - (input.params.x * 1.2 - 0.1);
241
+ let keep = smoothstep(0.0, 0.02, edge);
242
+ let burn = (1.0 - smoothstep(0.0, 0.08, edge)) * keep;
243
+ return color * keep + vec4f(1.0, 0.45, 0.1, 1.0) * burn * color.a;
244
+ }`
245
+ ),
246
+ css: ([progress]) => `opacity(${1 - progress})`
247
+ });
248
+ var glass = defineEffect({
249
+ name: "glass",
250
+ animated: true,
251
+ params: [110, 1.8],
252
+ wgsl: (
253
+ /* wgsl */
254
+ `
255
+ fn effect(input: EffectInput) -> vec4f {
256
+ let d = (input.uv - input.pointer) * input.size;
257
+ let radius = input.params.x;
258
+ let t = length(d) / radius;
259
+ if (t >= 1.0) {
260
+ let shadow = (1.0 - smoothstep(1.0, 1.12, t)) * 0.18;
261
+ let under = source(input.uv);
262
+ return vec4f(under.rgb * (1.0 - shadow), under.a);
263
+ }
264
+ // A dome: strongest at the center, flattening to no change at the rim.
265
+ let scale = mix(1.0 / input.params.y, 1.0, t * t * t);
266
+ let bent = input.pointer + d * scale / input.size;
267
+ let fringe = d / radius * (t * t * 6.0) / input.size;
268
+ let middle = source(bent);
269
+ var color = vec4f(source(bent + fringe).r, middle.g, source(bent - fringe).b, middle.a);
270
+ // A bright rim and a highlight up and to the left.
271
+ let rim = smoothstep(0.86, 0.97, t) * (1.0 - smoothstep(0.985, 1.0, t));
272
+ let shine = pow(max(0.0, 1.0 - length(d / radius - vec2f(-0.38, -0.42)) / 0.42), 2.0);
273
+ // Light catches the upper left of the rim, and the lower right falls into shade.
274
+ let side = dot(normalize(d + vec2f(0.0001)), vec2f(-0.7071, -0.7071));
275
+ let light = rim * (0.25 + 0.35 * side) + shine * 0.3;
276
+ let shade = rim * max(0.0, -side) * 0.25;
277
+ color = vec4f(color.rgb * (1.0 - shade), color.a);
278
+ color = color + vec4f(light, light, light, light) * (1.0 - color.a * 0.4);
279
+ return min(color, vec4f(1.0));
280
+ }`
281
+ )
282
+ });
283
+ var shockwave = defineEffect({
284
+ name: "shockwave",
285
+ animated: true,
286
+ params: [900, 60, 18],
287
+ wgsl: (
288
+ /* wgsl */
289
+ `
290
+ fn effect(input: EffectInput) -> vec4f {
291
+ let fade = exp(-input.sincePress * 1.6);
292
+ if (fade < 0.01) {
293
+ return source(input.uv);
294
+ }
295
+ let d = (input.uv - input.press) * input.size;
296
+ let dist = length(d);
297
+ let x = (dist - input.sincePress * input.params.x) / input.params.y;
298
+ let ring = exp(-x * x);
299
+ let push = select(vec2f(0.0), d / dist, dist > 0.001) * sin(x * 2.5) * ring * input.params.z * fade;
300
+ let offset = push / input.size;
301
+ let middle = source(input.uv - offset);
302
+ let color = vec4f(source(input.uv - offset * 1.4).r, middle.g, source(input.uv - offset * 0.6).b, middle.a);
303
+ return color + vec4f(0.45, 0.65, 1.0, 1.0) * ring * fade * 0.25 * middle.a;
304
+ }`
305
+ )
306
+ });
307
+ var glitch = defineEffect({
308
+ name: "glitch",
309
+ animated: true,
310
+ wgsl: (
311
+ /* wgsl */
312
+ `
313
+ fn effect(input: EffectInput) -> vec4f {
314
+ // Every node glitches on its own schedule.
315
+ let own = hash(input.size);
316
+ let tick = floor(input.time * 12.0 + own * 50.0);
317
+ let burst = step(0.82, hash(vec2f(floor(tick / 3.0), own)));
318
+ if (burst == 0.0) {
319
+ return source(input.uv);
320
+ }
321
+ let row = floor(input.uv.y * mix(12.0, 40.0, hash(vec2f(tick, 1.0))));
322
+ let tear = step(0.6, hash(vec2f(row, tick))) * (hash(vec2f(row, tick + 7.0)) - 0.5) * 0.06;
323
+ let uv = vec2f(input.uv.x + tear, input.uv.y);
324
+ let split = vec2f(0.012 * hash(vec2f(tick, 3.0)), 0.0);
325
+ let middle = source(uv);
326
+ var color = vec4f(source(uv + split).r, middle.g, source(uv - split).b, middle.a);
327
+ // A few blocks show pixels from elsewhere, with their channels swapped.
328
+ let block = floor(input.uv * vec2f(14.0, 8.0));
329
+ if (hash(block + vec2f(tick)) > 0.975) {
330
+ let moved = source(fract(input.uv + vec2f(hash(block), hash(block + 9.0)) * 0.3));
331
+ color = vec4f(moved.brg, moved.a);
332
+ }
333
+ return color;
334
+ }`
335
+ )
336
+ });
337
+ var vhs = defineEffect({
338
+ name: "vhs",
339
+ animated: true,
340
+ wgsl: (
341
+ /* wgsl */
342
+ `
343
+ fn effect(input: EffectInput) -> vec4f {
344
+ let y = input.uv.y;
345
+ let t = input.time;
346
+ var wave = sin(y * 38.0 + t * 3.0) * 0.0012 + sin(y * 6.0 - t * 1.3) * 0.002;
347
+ // Head-switching noise along the bottom.
348
+ let bottom = smoothstep(0.93, 1.0, y);
349
+ wave += bottom * (hash(vec2f(floor(y * 180.0), floor(t * 30.0))) - 0.5) * 0.04;
350
+ let uv = vec2f(input.uv.x + wave, y);
351
+ let bleed = vec2f(3.0 / input.size.x, 0.0);
352
+ let middle = source(uv);
353
+ var color = vec4f(source(uv + bleed).r, middle.g, source(uv - bleed * 1.5).b, middle.a);
354
+ // Softer, warmer and a little washed out.
355
+ let gray = dot(color.rgb, vec3f(0.299, 0.587, 0.114));
356
+ color = vec4f(mix(vec3f(gray), color.rgb, 0.8) * vec3f(1.04, 1.0, 0.92), color.a);
357
+ let band = smoothstep(0.08, 0.0, abs(fract(y * 0.6 - t * 0.12) - 0.5));
358
+ let lines = 0.94 + 0.06 * sin(y * input.size.y * 3.1416);
359
+ let grain = (hash(input.uv * input.size + vec2f(t * 97.0, t * 61.0)) - 0.5) * 0.12;
360
+ return vec4f(clamp(color.rgb * lines + (band * 0.08 + grain) * color.a, vec3f(0.0), vec3f(color.a)), color.a);
361
+ }`
362
+ )
363
+ });
364
+ var frost = defineEffect({
365
+ name: "frost",
366
+ animated: true,
367
+ params: [140],
368
+ wgsl: (
369
+ /* wgsl */
370
+ `
371
+ fn effect(input: EffectInput) -> vec4f {
372
+ let p = input.uv * input.size;
373
+ let dist = length((input.uv - input.pointer) * input.size);
374
+ let edge = noise(p / 40.0) * 50.0;
375
+ let ice = smoothstep(input.params.x * 0.55, input.params.x * 1.2, dist + edge);
376
+ if (ice <= 0.0) {
377
+ return source(input.uv);
378
+ }
379
+ // Frosted glass: the node seen blurred and bent through uneven ice.
380
+ let thick = noise(p / 90.0) * 0.6 + noise(p / 23.0) * 0.4;
381
+ let bend = (vec2f(noise(p / 14.0), noise(p / 14.0 + 31.0)) - 0.5) * 6.0 * ice;
382
+ var color = vec4f(0.0);
383
+ for (var i = 0u; i < 8u; i++) {
384
+ let a = f32(i) * 0.7854 + 0.4;
385
+ let r = select(3.0, 6.0, i % 2u == 0u) * ice;
386
+ color += source((p + bend + vec2f(cos(a), sin(a)) * r) / input.size);
387
+ }
388
+ color /= 8.0;
389
+ let white = vec4f(0.9, 0.95, 1.0, 1.0) * color.a;
390
+ // Feathery crystals and a few glints.
391
+ let crystals = smoothstep(0.7, 0.95, noise(p / 12.0 + noise(p / 40.0) * 2.0)) * 0.1;
392
+ let glint = step(0.997, hash(floor(p / 1.5))) * 0.8;
393
+ let frosted = mix(color, white, (0.3 + thick * 0.25) * ice);
394
+ return min(frosted + vec4f((crystals + glint) * ice) * color.a, vec4f(color.a));
395
+ }`
396
+ )
397
+ });
398
+ var dither = defineEffect({
399
+ name: "dither",
400
+ animated: true,
401
+ params: [130, 4],
402
+ wgsl: (
403
+ /* wgsl */
404
+ `
405
+ fn effect(input: EffectInput) -> vec4f {
406
+ let p = input.uv * input.size;
407
+ let dist = length((input.uv - input.pointer) * input.size);
408
+ let radius = input.params.x;
409
+ if (dist > radius + 1.5) {
410
+ return source(input.uv);
411
+ }
412
+ let block = max(input.params.y, 1.0);
413
+ let cell = floor(p / block);
414
+ let base = source((cell + 0.5) * block / input.size);
415
+ var bayer = array<f32, 16>(0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0);
416
+ let index = u32(cell.x) % 4u + (u32(cell.y) % 4u) * 4u;
417
+ let threshold = (bayer[index] + 0.5) / 16.0 - 0.5;
418
+ // Two levels per channel: eight colors, mixed by the pattern.
419
+ let straight = base.rgb / max(base.a, 0.0001);
420
+ let levels = floor(clamp(straight + threshold * 0.6, vec3f(0.0), vec3f(1.0)) + 0.5);
421
+ let dithered = vec4f(levels * base.a, base.a);
422
+ let ring = 1.0 - smoothstep(0.0, 1.5, abs(dist - radius));
423
+ return mix(dithered, vec4f(0.1, 0.1, 0.12, 1.0), ring * 0.8);
424
+ }`
425
+ )
426
+ });
427
+ var EFFECTS = {
428
+ glass,
429
+ shockwave,
430
+ glitch,
431
+ vhs,
432
+ frost,
433
+ dither,
434
+ glow,
435
+ hue,
436
+ ripple,
437
+ blur,
438
+ pixelate,
439
+ dissolve,
440
+ grayscale
441
+ };
442
+
443
+ // src/elements.ts
444
+ function rectInNode(node, nodeRect, element) {
445
+ if (element === node) return { ...nodeRect };
446
+ const outer = node.getBoundingClientRect();
447
+ const inner = element.getBoundingClientRect();
448
+ const scale = outer.width > 0 ? nodeRect.width / outer.width : 1;
449
+ return {
450
+ x: nodeRect.x + (inner.left - outer.left) * scale,
451
+ y: nodeRect.y + (inner.top - outer.top) * scale,
452
+ width: inner.width * scale,
453
+ height: inner.height * scale
454
+ };
455
+ }
456
+ function elementInNode(node, nodeRect, point) {
457
+ const outer = node.getBoundingClientRect();
458
+ const scale = nodeRect.width > 0 ? outer.width / nodeRect.width : 1;
459
+ const x = outer.left + (point.x - nodeRect.x) * scale;
460
+ const y = outer.top + (point.y - nodeRect.y) * scale;
461
+ let hit = node;
462
+ descend: for (; ; ) {
463
+ for (let child = hit.lastElementChild; child; child = child.previousElementSibling) {
464
+ const box = child.getBoundingClientRect();
465
+ if (box.width > 0 && box.height > 0 && x >= box.left && x < box.right && y >= box.top && y < box.bottom) {
466
+ hit = child;
467
+ continue descend;
468
+ }
469
+ }
470
+ return hit;
471
+ }
472
+ }
473
+ function nodeOfElement(nodes, element) {
474
+ for (let at = element; at; at = at.parentElement) {
475
+ if (nodes.has(at)) return at;
476
+ }
477
+ return null;
478
+ }
479
+
85
480
  // src/node-style.ts
86
481
  function applyNodeSize(element, rect) {
87
482
  element.style.width = `${rect.width}px`;
@@ -105,6 +500,9 @@ function boundsOf(rects) {
105
500
 
106
501
  // src/types.ts
107
502
  var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
503
+ var DEFAULT_SELECTION_COLOR = "#4f8cff";
504
+ var SELECTION_WIDTH = 2;
505
+ var HOVER_WIDTH = 1;
108
506
 
109
507
  // src/dom-backend.ts
110
508
  function createDomCanvas(host, options) {
@@ -127,14 +525,102 @@ function createDomCanvas(host, options) {
127
525
  transformOrigin: "0 0",
128
526
  willChange: "transform"
129
527
  });
528
+ const overlay = document.createElement("div");
529
+ Object.assign(overlay.style, { position: "absolute", left: "0", top: "0", zIndex: "1" });
530
+ world.append(overlay);
130
531
  surface.append(world);
131
532
  host.append(surface);
132
533
  const nodes = /* @__PURE__ */ new Map();
534
+ const ring = (color, width) => `calc(${width}px / var(--gpucanvas-zoom)) solid ${color}`;
535
+ const selectionColor = options.selectionColor ?? DEFAULT_SELECTION_COLOR;
536
+ const selectionRing = ring(selectionColor, SELECTION_WIDTH);
537
+ const hoverRing = ring(options.hoverColor ?? selectionColor, HOVER_WIDTH);
538
+ let selection = /* @__PURE__ */ new Set();
539
+ let hovered = null;
540
+ let outlinedNodes = /* @__PURE__ */ new Set();
541
+ const boxes = /* @__PURE__ */ new Map();
542
+ const elementRect = (element) => {
543
+ const node = element.isConnected ? nodeOfElement(nodes, element) : null;
544
+ return node ? rectInNode(node, nodes.get(node), element) : null;
545
+ };
546
+ let boxFrame = 0;
547
+ const placeBoxes = () => {
548
+ boxFrame = boxes.size ? requestAnimationFrame(placeBoxes) : 0;
549
+ for (const [element, box] of boxes) {
550
+ const rect = elementRect(element);
551
+ box.style.display = rect ? "block" : "none";
552
+ if (rect) {
553
+ box.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
554
+ box.style.width = `${rect.width}px`;
555
+ box.style.height = `${rect.height}px`;
556
+ }
557
+ }
558
+ };
559
+ const drawOutlines = () => {
560
+ const wanted = /* @__PURE__ */ new Map();
561
+ for (const element of selection) wanted.set(element, selectionRing);
562
+ if (hovered && !wanted.has(hovered)) wanted.set(hovered, hoverRing);
563
+ const nextNodes = /* @__PURE__ */ new Set();
564
+ for (const [element, outline] of wanted) {
565
+ if (!nodes.has(element)) continue;
566
+ element.style.outline = outline;
567
+ nextNodes.add(element);
568
+ }
569
+ for (const node of outlinedNodes) if (!nextNodes.has(node)) node.style.outline = "";
570
+ outlinedNodes = nextNodes;
571
+ for (const [element, box] of boxes) {
572
+ if (wanted.has(element)) continue;
573
+ box.remove();
574
+ boxes.delete(element);
575
+ }
576
+ for (const [element, outline] of wanted) {
577
+ if (nodes.has(element)) continue;
578
+ let box = boxes.get(element);
579
+ if (!box) {
580
+ box = document.createElement("div");
581
+ Object.assign(box.style, {
582
+ position: "absolute",
583
+ left: "0",
584
+ top: "0",
585
+ pointerEvents: "none"
586
+ });
587
+ overlay.append(box);
588
+ boxes.set(element, box);
589
+ }
590
+ box.style.outline = outline;
591
+ }
592
+ if (boxes.size && !boxFrame) placeBoxes();
593
+ };
594
+ const animations = /* @__PURE__ */ new Map();
595
+ const KEYFRAMES = 24;
596
+ const clearEffect = (element) => {
597
+ animations.get(element)?.cancel();
598
+ animations.delete(element);
599
+ element.style.filter = "";
600
+ };
601
+ const applyEffect = (element, effect, params) => {
602
+ clearEffect(element);
603
+ if (!effect?.css) return;
604
+ const values = effectParams(effect, params);
605
+ if (!effect.animated) {
606
+ element.style.filter = effect.css(values, 0);
607
+ return;
608
+ }
609
+ const period = effect.period ?? 4;
610
+ const keyframes = Array.from({ length: KEYFRAMES + 1 }, (_, i) => ({
611
+ filter: effect.css(values, i / KEYFRAMES * period)
612
+ }));
613
+ animations.set(
614
+ element,
615
+ element.animate(keyframes, { duration: period * 1e3, iterations: Infinity })
616
+ );
617
+ };
133
618
  let camera = options.camera ?? DEFAULT_CAMERA;
134
619
  let frameMs = 0;
135
620
  const apply = () => {
136
621
  const start = performance.now();
137
622
  world.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
623
+ world.style.setProperty("--gpucanvas-zoom", String(camera.zoom));
138
624
  frameMs = performance.now() - start;
139
625
  };
140
626
  const setCamera = (next) => {
@@ -161,12 +647,39 @@ function createDomCanvas(host, options) {
161
647
  applyNodeSize(element, rect);
162
648
  },
163
649
  removeNode(element) {
650
+ clearEffect(element);
164
651
  nodes.delete(element);
652
+ outlinedNodes.delete(element);
653
+ element.style.outline = "";
165
654
  element.remove();
166
655
  },
167
656
  getNodeRect: (element) => nodes.get(element) ?? null,
657
+ getNodesIn(rect) {
658
+ return [...nodes].filter(([, node]) => intersects(node, rect)).map(([element]) => element);
659
+ },
660
+ getNodeOf: (element) => nodeOfElement(nodes, element),
661
+ getElementRect: elementRect,
662
+ getElementAt(point) {
663
+ let top = null;
664
+ for (const [element, rect] of nodes) if (containsPoint(rect, point)) top = element;
665
+ return top ? elementInNode(top, nodes.get(top), point) : null;
666
+ },
667
+ setSelection(elements) {
668
+ selection = new Set(elements);
669
+ drawOutlines();
670
+ },
671
+ setHover(element) {
672
+ if (element === hovered) return;
673
+ hovered = element;
674
+ drawOutlines();
675
+ },
676
+ setEffect(element, effect, params) {
677
+ if (nodes.has(element)) applyEffect(element, effect, params);
678
+ },
168
679
  getCamera: () => camera,
169
680
  setCamera,
681
+ screenToWorld: (point) => screenToWorld(camera, point),
682
+ worldToScreen: (point) => worldToScreen(camera, point),
170
683
  // The browser repaints DOM nodes itself.
171
684
  invalidate() {
172
685
  },
@@ -193,6 +706,7 @@ function createDomCanvas(host, options) {
193
706
  };
194
707
  },
195
708
  destroy() {
709
+ cancelAnimationFrame(boxFrame);
196
710
  unbindInput();
197
711
  surface.remove();
198
712
  }
@@ -350,7 +864,7 @@ var Atlas = class {
350
864
  };
351
865
 
352
866
  // src/composite-shader.ts
353
- var compositeShader = (
867
+ var prelude = (
354
868
  /* wgsl */
355
869
  `
356
870
  struct View {
@@ -359,8 +873,10 @@ struct View {
359
873
  zoom: f32,
360
874
  dpr: f32,
361
875
  atlasSize: f32,
362
- _pad: f32,
876
+ time: f32, // seconds since the canvas started
363
877
  placeholder: vec4f, // premultiplied color for nodes without a snapshot
878
+ pointer: vec4f, // pointer and last press in world units; far away when there is none
879
+ pressTime: f32, // seconds since the last press
364
880
  }
365
881
 
366
882
  @group(0) @binding(0) var<uniform> view: View;
@@ -369,8 +885,9 @@ struct View {
369
885
 
370
886
  struct Instance {
371
887
  @location(0) rect: vec4f, // world x, y, width, height
372
- @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1
373
- @location(2) info: vec4f, // layer, placeholder flag
888
+ @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1; for a ring, its premultiplied color
889
+ @location(2) info: vec4f, // layer, kind (0 snapshot, 1 placeholder, 2 ring), ring width or effect pad
890
+ @location(3) params: vec4f, // an effect's parameters
374
891
  }
375
892
 
376
893
  struct Varyings {
@@ -378,31 +895,63 @@ struct Varyings {
378
895
  @location(0) texel: vec2f,
379
896
  @location(1) @interpolate(flat) bounds: vec4f,
380
897
  @location(2) @interpolate(flat) info: vec2f,
898
+ @location(3) @interpolate(flat) color: vec4f,
899
+ @location(4) uv: vec2f, // 0 to 1 across the node, past that in an effect's padding
900
+ @location(5) @interpolate(flat) size: vec2f,
901
+ @location(6) @interpolate(flat) params: vec4f,
902
+ @location(7) @interpolate(flat) corner: vec2f, // the node's top left in world units
381
903
  }
382
904
 
383
905
  @vertex
384
906
  fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
385
907
  let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
908
+ // A ring's quad reaches past its element by the ring's width, an effect's by its padding.
909
+ let ring = instance.info.y > 1.5;
910
+ let grow = select(instance.info.z, instance.info.z / view.zoom, ring);
911
+ let start = instance.rect.xy - grow;
912
+ let end = instance.rect.xy + instance.rect.zw + grow;
386
913
  // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
387
914
  let scale = view.zoom * view.dpr;
388
- let topLeft = round((instance.rect.xy - view.origin) * scale) / view.dpr;
389
- let bottomRight = round((instance.rect.xy + instance.rect.zw - view.origin) * scale) / view.dpr;
915
+ let topLeft = round((start - view.origin) * scale) / view.dpr;
916
+ let bottomRight = round((end - view.origin) * scale) / view.dpr;
390
917
  let point = mix(topLeft, bottomRight, corner);
391
918
  var out: Varyings;
392
919
  out.position = vec4f(point.x / view.size.x * 2.0 - 1.0, 1.0 - point.y / view.size.y * 2.0, 0.0, 1.0);
393
- out.texel = mix(instance.uv.xy, instance.uv.zw, corner);
394
- // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
395
- // with whatever sits next to it in the atlas.
396
- out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
920
+ if (ring) {
921
+ // World units from the element's top left, and the element's own edges.
922
+ out.texel = mix(vec2f(-grow), instance.rect.zw + grow, corner);
923
+ out.bounds = vec4f(0.0, 0.0, instance.rect.zw);
924
+ out.color = instance.uv;
925
+ } else {
926
+ let pad = grow * (instance.uv.zw - instance.uv.xy) / instance.rect.zw;
927
+ out.texel = mix(instance.uv.xy - pad, instance.uv.zw + pad, corner);
928
+ out.color = instance.uv;
929
+ // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
930
+ // with whatever sits next to it in the atlas.
931
+ out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
932
+ }
397
933
  out.info = instance.info.xy;
934
+ out.uv = mix(-grow / instance.rect.zw, 1.0 + grow / instance.rect.zw, corner);
935
+ out.size = instance.rect.zw;
936
+ out.corner = instance.rect.xy;
937
+ out.params = instance.params;
398
938
  return out;
399
939
  }
400
-
940
+ `
941
+ );
942
+ var compositeShader = (
943
+ /* wgsl */
944
+ `${prelude}
401
945
  @fragment
402
946
  fn fs(in: Varyings) -> @location(0) vec4f {
403
947
  // Derivatives first: WGSL only allows them in uniform control flow.
404
948
  let dx = dpdx(in.texel);
405
949
  let dy = dpdy(in.texel);
950
+ if (in.info.y > 1.5) {
951
+ // The ring covers what lies outside the element; the element itself shows through.
952
+ let outside = max(in.bounds.xy - in.texel, in.texel - in.bounds.zw);
953
+ return select(vec4f(0.0), in.color, max(outside.x, outside.y) > 0.0);
954
+ }
406
955
  if (in.info.y > 0.5) {
407
956
  return view.placeholder;
408
957
  }
@@ -422,9 +971,63 @@ fn fs(in: Varyings) -> @location(0) vec4f {
422
971
  }
423
972
  `
424
973
  );
974
+ var effectShader = (wgsl) => (
975
+ /* wgsl */
976
+ `${prelude}
977
+ struct EffectInput {
978
+ uv: vec2f,
979
+ size: vec2f,
980
+ time: f32,
981
+ zoom: f32,
982
+ params: vec4f,
983
+ pointer: vec2f,
984
+ press: vec2f,
985
+ sincePress: f32,
986
+ }
987
+
988
+ var<private> sourceRect: vec4f;
989
+ var<private> sourceLayer: i32;
990
+
991
+ fn source(uv: vec2f) -> vec4f {
992
+ if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) {
993
+ return vec4f(0.0);
994
+ }
995
+ let texel = clamp(mix(sourceRect.xy, sourceRect.zw, uv), sourceRect.xy + 0.5, sourceRect.zw - 0.5);
996
+ return textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, sourceLayer, 0.0);
997
+ }
998
+
999
+ fn hash(p: vec2f) -> f32 {
1000
+ return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
1001
+ }
1002
+
1003
+ fn noise(p: vec2f) -> f32 {
1004
+ let i = floor(p);
1005
+ let f = fract(p);
1006
+ let u = f * f * (3.0 - 2.0 * f);
1007
+ return mix(
1008
+ mix(hash(i), hash(i + vec2f(1.0, 0.0)), u.x),
1009
+ mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x),
1010
+ u.y,
1011
+ );
1012
+ }
1013
+
1014
+ ${wgsl}
1015
+
1016
+ @fragment
1017
+ fn fs(in: Varyings) -> @location(0) vec4f {
1018
+ sourceRect = in.color;
1019
+ sourceLayer = i32(in.info.x);
1020
+ let pointer = (view.pointer.xy - in.corner) / in.size;
1021
+ let press = (view.pointer.zw - in.corner) / in.size;
1022
+ return effect(
1023
+ EffectInput(in.uv, in.size, view.time, view.zoom, in.params, pointer, press, view.pressTime),
1024
+ );
1025
+ }
1026
+ `
1027
+ );
425
1028
 
426
1029
  // src/gpu-renderer.ts
427
- var FLOATS_PER_INSTANCE = 12;
1030
+ var FLOATS_PER_INSTANCE = 16;
428
1031
  var CompositeRenderer = class {
429
1032
  constructor(device, context, format, atlas, background, placeholder) {
430
1033
  this.device = device;
@@ -432,13 +1035,60 @@ var CompositeRenderer = class {
432
1035
  this.atlas = atlas;
433
1036
  this.background = background;
434
1037
  this.placeholder = placeholder;
435
- const module = device.createShaderModule({
436
- label: "gpucanvas composite",
437
- code: compositeShader
1038
+ this.bindGroupLayout = device.createBindGroupLayout({
1039
+ entries: [
1040
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: {} },
1041
+ {
1042
+ binding: 1,
1043
+ visibility: GPUShaderStage.FRAGMENT,
1044
+ texture: { viewDimension: "2d-array" }
1045
+ },
1046
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }
1047
+ ]
438
1048
  });
439
- this.pipeline = device.createRenderPipeline({
440
- label: "gpucanvas composite",
441
- layout: "auto",
1049
+ this.sampler = device.createSampler({ magFilter: "linear", minFilter: "linear" });
1050
+ this.pipeline = device.createRenderPipeline(
1051
+ this.pipelineDescriptor("gpucanvas composite", compositeShader, format)
1052
+ );
1053
+ this.format = format;
1054
+ this.uniforms = device.createBuffer({
1055
+ size: this.uniformData.byteLength,
1056
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1057
+ });
1058
+ this.bindGroup = device.createBindGroup({
1059
+ layout: this.bindGroupLayout,
1060
+ entries: [
1061
+ { binding: 0, resource: { buffer: this.uniforms } },
1062
+ { binding: 1, resource: atlas.view },
1063
+ { binding: 2, resource: this.sampler }
1064
+ ]
1065
+ });
1066
+ this.instanceBuffer = this.createInstanceBuffer();
1067
+ }
1068
+ device;
1069
+ context;
1070
+ atlas;
1071
+ background;
1072
+ placeholder;
1073
+ instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
1074
+ instanceBuffer;
1075
+ uniforms;
1076
+ uniformData = new Float32Array(20);
1077
+ pipeline;
1078
+ bindGroup;
1079
+ bindGroupLayout;
1080
+ sampler;
1081
+ /** Effect pipelines, null while one compiles. Compiling never blocks a frame. */
1082
+ effects = /* @__PURE__ */ new Map();
1083
+ /** Called when an effect pipeline is ready, so the caller can draw it. */
1084
+ onEffectReady = () => {
1085
+ };
1086
+ format;
1087
+ pipelineDescriptor(label, code, format) {
1088
+ const module = this.device.createShaderModule({ label, code });
1089
+ return {
1090
+ label,
1091
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
442
1092
  vertex: {
443
1093
  module,
444
1094
  entryPoint: "vs",
@@ -449,7 +1099,8 @@ var CompositeRenderer = class {
449
1099
  attributes: [
450
1100
  { shaderLocation: 0, offset: 0, format: "float32x4" },
451
1101
  { shaderLocation: 1, offset: 16, format: "float32x4" },
452
- { shaderLocation: 2, offset: 32, format: "float32x4" }
1102
+ { shaderLocation: 2, offset: 32, format: "float32x4" },
1103
+ { shaderLocation: 3, offset: 48, format: "float32x4" }
453
1104
  ]
454
1105
  }
455
1106
  ]
@@ -469,35 +1120,24 @@ var CompositeRenderer = class {
469
1120
  ]
470
1121
  },
471
1122
  primitive: { topology: "triangle-strip" }
472
- });
473
- this.uniforms = device.createBuffer({
474
- size: this.uniformData.byteLength,
475
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
476
- });
477
- this.bindGroup = device.createBindGroup({
478
- layout: this.pipeline.getBindGroupLayout(0),
479
- entries: [
480
- { binding: 0, resource: { buffer: this.uniforms } },
481
- { binding: 1, resource: atlas.view },
482
- {
483
- binding: 2,
484
- resource: device.createSampler({ magFilter: "linear", minFilter: "linear" })
485
- }
486
- ]
487
- });
488
- this.instanceBuffer = this.createInstanceBuffer();
1123
+ };
1124
+ }
1125
+ /** Whether `effect` can draw this frame. The first ask starts compiling it. */
1126
+ hasEffect(effect) {
1127
+ if (this.effects.has(effect)) return this.effects.get(effect) !== null;
1128
+ this.effects.set(effect, null);
1129
+ this.device.createRenderPipelineAsync(
1130
+ this.pipelineDescriptor(
1131
+ `gpucanvas effect ${effect.name}`,
1132
+ effectShader(effect.wgsl),
1133
+ this.format
1134
+ )
1135
+ ).then((pipeline) => {
1136
+ this.effects.set(effect, pipeline);
1137
+ this.onEffectReady();
1138
+ }).catch((error) => console.error(`[gpucanvas] effect "${effect.name}" failed`, error));
1139
+ return false;
489
1140
  }
490
- device;
491
- context;
492
- atlas;
493
- background;
494
- placeholder;
495
- instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
496
- instanceBuffer;
497
- uniforms;
498
- uniformData = new Float32Array(12);
499
- pipeline;
500
- bindGroup;
501
1141
  /** Grows the CPU and GPU instance storage to hold at least `count` instances. */
502
1142
  reserve(count) {
503
1143
  const floats = count * FLOATS_PER_INSTANCE;
@@ -506,7 +1146,7 @@ var CompositeRenderer = class {
506
1146
  this.instanceBuffer.destroy();
507
1147
  this.instanceBuffer = this.createInstanceBuffer();
508
1148
  }
509
- draw(camera, cssWidth, cssHeight, dpr, count) {
1149
+ draw(camera, cssWidth, cssHeight, dpr, count, runs, effects) {
510
1150
  const { device } = this;
511
1151
  this.uniformData.set([
512
1152
  cssWidth,
@@ -516,8 +1156,13 @@ var CompositeRenderer = class {
516
1156
  camera.zoom,
517
1157
  dpr,
518
1158
  this.atlas.size,
519
- 0,
520
- ...this.placeholder
1159
+ effects.time,
1160
+ ...this.placeholder,
1161
+ effects.pointer.x,
1162
+ effects.pointer.y,
1163
+ effects.press.x,
1164
+ effects.press.y,
1165
+ effects.sincePress
521
1166
  ]);
522
1167
  device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
523
1168
  if (count > 0) {
@@ -542,10 +1187,14 @@ var CompositeRenderer = class {
542
1187
  ]
543
1188
  });
544
1189
  if (count > 0) {
545
- pass.setPipeline(this.pipeline);
546
1190
  pass.setBindGroup(0, this.bindGroup);
547
1191
  pass.setVertexBuffer(0, this.instanceBuffer);
548
- pass.draw(4, count);
1192
+ let current = null;
1193
+ for (const run of runs.length ? runs : [{ effect: null, start: 0, count }]) {
1194
+ const pipeline = run.effect && this.effects.get(run.effect) || this.pipeline;
1195
+ if (pipeline !== current) pass.setPipeline(current = pipeline);
1196
+ pass.draw(4, run.count, 0, run.start);
1197
+ }
549
1198
  }
550
1199
  pass.end();
551
1200
  device.queue.submit([encoder.finish()]);
@@ -685,6 +1334,7 @@ function cssColorToRgba(color) {
685
1334
  const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
686
1335
  return [r / 255, g / 255, b / 255, a / 255];
687
1336
  }
1337
+ var premultiplied = ([r, g, b, a]) => [r * a, g * a, b * a, a];
688
1338
  var distanceToCenter = (rect, view) => Math.hypot(
689
1339
  rect.x + rect.width / 2 - (view.x + view.width / 2),
690
1340
  rect.y + rect.height / 2 - (view.y + view.height / 2)
@@ -737,7 +1387,30 @@ async function createGpuCanvas(host, options) {
737
1387
  atlas,
738
1388
  ...colorsFor(options.background ?? "#f4f4f5")
739
1389
  );
1390
+ const selectionColor = premultiplied(
1391
+ cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)
1392
+ );
1393
+ const hoverColor = options.hoverColor ? premultiplied(cssColorToRgba(options.hoverColor)) : selectionColor;
740
1394
  const nodes = /* @__PURE__ */ new Map();
1395
+ const effects = /* @__PURE__ */ new Map();
1396
+ const startedAt = performance.now();
1397
+ const FAR_AWAY = { x: -1e7, y: -1e7 };
1398
+ renderer.onEffectReady = () => schedule();
1399
+ const runs = [];
1400
+ let effectsMoving = false;
1401
+ let selection = /* @__PURE__ */ new Set();
1402
+ let hovered = null;
1403
+ const ringRects = /* @__PURE__ */ new Map();
1404
+ const ringRect = (element) => {
1405
+ const own = nodes.get(element);
1406
+ if (own) return own.rect;
1407
+ const at = element.isConnected ? nodeOfElement(nodes, element) : null;
1408
+ if (!at) return null;
1409
+ const node = nodes.get(at);
1410
+ let rect = ringRects.get(element);
1411
+ if (!rect) ringRects.set(element, rect = rectInNode(at, node.rect, element));
1412
+ return rect;
1413
+ };
741
1414
  const pages = /* @__PURE__ */ new Map();
742
1415
  atlas.keep = function* () {
743
1416
  const onScreen = (tile) => intersects(tile.rect, frameView);
@@ -1134,10 +1807,21 @@ async function createGpuCanvas(host, options) {
1134
1807
  const composite = () => {
1135
1808
  let instances = visible.length;
1136
1809
  for (const node of visible) instances += node.tiles.length + (node.preview ? 1 : 0);
1810
+ instances += selection.size + 1;
1137
1811
  renderer.reserve(instances);
1138
1812
  const data = renderer.instances;
1139
1813
  let count = 0;
1814
+ runs.length = 0;
1815
+ effectsMoving = false;
1816
+ let runEffect = null;
1817
+ const startRun = (effect) => {
1818
+ if (runs.length && runEffect === effect) return;
1819
+ runEffect = effect;
1820
+ runs.push({ effect, start: count, count: 0 });
1821
+ };
1140
1822
  const push = (rect, slot) => {
1823
+ startRun(null);
1824
+ runs[runs.length - 1].count++;
1141
1825
  const offset = count++ * FLOATS_PER_INSTANCE;
1142
1826
  data[offset] = rect.x;
1143
1827
  data[offset + 1] = rect.y;
@@ -1154,6 +1838,40 @@ async function createGpuCanvas(host, options) {
1154
1838
  } else {
1155
1839
  data[offset + 9] = 1;
1156
1840
  }
1841
+ data[offset + 10] = 0;
1842
+ };
1843
+ const pushEffect = (node, slot, worn) => {
1844
+ startRun(worn.effect);
1845
+ runs[runs.length - 1].count++;
1846
+ const offset = count++ * FLOATS_PER_INSTANCE;
1847
+ const { rect } = node;
1848
+ data[offset] = rect.x;
1849
+ data[offset + 1] = rect.y;
1850
+ data[offset + 2] = rect.width;
1851
+ data[offset + 3] = rect.height;
1852
+ atlas.touch(slot.layer);
1853
+ data[offset + 4] = slot.x;
1854
+ data[offset + 5] = slot.y;
1855
+ data[offset + 6] = slot.x + slot.width;
1856
+ data[offset + 7] = slot.y + slot.height;
1857
+ data[offset + 8] = slot.layer;
1858
+ data[offset + 9] = 0;
1859
+ data[offset + 10] = worn.pad;
1860
+ data.set(worn.params, offset + 12);
1861
+ };
1862
+ const pushRing = (element, color, width) => {
1863
+ const rect = ringRect(element);
1864
+ if (!rect || !intersects(rect, frameView)) return;
1865
+ startRun(null);
1866
+ runs[runs.length - 1].count++;
1867
+ const offset = count++ * FLOATS_PER_INSTANCE;
1868
+ data[offset] = rect.x;
1869
+ data[offset + 1] = rect.y;
1870
+ data[offset + 2] = rect.width;
1871
+ data[offset + 3] = rect.height;
1872
+ data.set(color, offset + 4);
1873
+ data[offset + 9] = 2;
1874
+ data[offset + 10] = width;
1157
1875
  };
1158
1876
  let onThumbnail = 0;
1159
1877
  for (const node of visible) {
@@ -1167,6 +1885,15 @@ async function createGpuCanvas(host, options) {
1167
1885
  if (before === 3 && shown === 2) stats.lostDetail++;
1168
1886
  node.shown = shown;
1169
1887
  node.shownFrame = stats.frames;
1888
+ const worn = effects.size ? effects.get(node.element) : void 0;
1889
+ if (worn) {
1890
+ if (worn.effect.animated) effectsMoving = true;
1891
+ if (base && renderer.hasEffect(worn.effect)) {
1892
+ pushEffect(node, base, worn);
1893
+ continue;
1894
+ }
1895
+ effectsMoving = true;
1896
+ }
1170
1897
  push(node.rect, base);
1171
1898
  const current = (tile) => tile.scale === detailScale && !tile.stale;
1172
1899
  for (const tile of node.tiles)
@@ -1176,9 +1903,17 @@ async function createGpuCanvas(host, options) {
1176
1903
  if (current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
1177
1904
  }
1178
1905
  stats.onThumbnail = onThumbnail;
1906
+ for (const element of selection) pushRing(element, selectionColor, SELECTION_WIDTH);
1907
+ if (hovered && !selection.has(hovered)) pushRing(hovered, hoverColor, HOVER_WIDTH);
1179
1908
  const cssWidth = canvas.clientWidth;
1180
1909
  const dpr = canvas.width / Math.max(1, cssWidth);
1181
- renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, count);
1910
+ const now = performance.now();
1911
+ renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, count, runs, {
1912
+ time: (now - startedAt) / 1e3,
1913
+ pointer: pointer ? screenToWorld(camera, pointer) : FAR_AWAY,
1914
+ press,
1915
+ sincePress: Math.min((now - pressedAt) / 1e3, 1e4)
1916
+ });
1182
1917
  };
1183
1918
  const frame = (inPaint) => {
1184
1919
  if (destroyed) return;
@@ -1222,7 +1957,7 @@ async function createGpuCanvas(host, options) {
1222
1957
  stats.visible = visible.length;
1223
1958
  stats.pending = pending;
1224
1959
  stats.frameMs = performance.now() - start;
1225
- if (pending > 0) schedule();
1960
+ if (pending > 0 || effectsMoving) schedule();
1226
1961
  };
1227
1962
  let awaitingPaint = false;
1228
1963
  canvas.onpaint = (event) => {
@@ -1237,6 +1972,7 @@ async function createGpuCanvas(host, options) {
1237
1972
  }
1238
1973
  }
1239
1974
  }
1975
+ if (changed) ringRects.clear();
1240
1976
  if (!awaitingPaint && !changed) return;
1241
1977
  awaitingPaint = false;
1242
1978
  frame(true);
@@ -1250,6 +1986,7 @@ async function createGpuCanvas(host, options) {
1250
1986
  return element ? nodes.get(element) : void 0;
1251
1987
  };
1252
1988
  const mutations = new MutationObserver((records) => {
1989
+ ringRects.clear();
1253
1990
  let changed = false;
1254
1991
  for (const record of records) {
1255
1992
  const target = record.target;
@@ -1332,13 +2069,9 @@ async function createGpuCanvas(host, options) {
1332
2069
  };
1333
2070
  const pickLive = () => {
1334
2071
  if (!pointer) return setLive(null);
1335
- const worldX = camera.x + pointer.x / camera.zoom;
1336
- const worldY = camera.y + pointer.y / camera.zoom;
2072
+ const point = screenToWorld(camera, pointer);
1337
2073
  let hit = null;
1338
- for (const node of nodes.values()) {
1339
- const { x, y, width, height } = node.rect;
1340
- if (worldX >= x && worldX < x + width && worldY >= y && worldY < y + height) hit = node;
1341
- }
2074
+ for (const node of nodes.values()) if (containsPoint(node.rect, point)) hit = node;
1342
2075
  setLive(hit);
1343
2076
  };
1344
2077
  const onPointerMove = (event) => {
@@ -1350,6 +2083,17 @@ async function createGpuCanvas(host, options) {
1350
2083
  pointer = null;
1351
2084
  setLive(null);
1352
2085
  };
2086
+ let press = FAR_AWAY;
2087
+ let pressedAt = -Infinity;
2088
+ const onPointerDown = (event) => {
2089
+ const bounds = canvas.getBoundingClientRect();
2090
+ press = screenToWorld(camera, {
2091
+ x: event.clientX - bounds.left,
2092
+ y: event.clientY - bounds.top
2093
+ });
2094
+ pressedAt = performance.now();
2095
+ if (effects.size) schedule();
2096
+ };
1353
2097
  const onFocusIn = (event) => {
1354
2098
  const node = nodeOf(event.target) ?? null;
1355
2099
  if (node === focused) return;
@@ -1388,6 +2132,7 @@ async function createGpuCanvas(host, options) {
1388
2132
  document.fonts.addEventListener("loadingdone", onFontsLoaded);
1389
2133
  surface.addEventListener("pointermove", onPointerMove);
1390
2134
  surface.addEventListener("pointerleave", onPointerLeave);
2135
+ surface.addEventListener("pointerdown", onPointerDown, true);
1391
2136
  canvas.addEventListener("focusin", onFocusIn);
1392
2137
  canvas.addEventListener("focusout", onFocusOut);
1393
2138
  const usedAt = /* @__PURE__ */ new WeakMap();
@@ -1549,6 +2294,7 @@ async function createGpuCanvas(host, options) {
1549
2294
  if (!node) return;
1550
2295
  const resized = node.rect.width !== rect.width || node.rect.height !== rect.height;
1551
2296
  node.rect = rect;
2297
+ ringRects.clear();
1552
2298
  if (resized) {
1553
2299
  if (live === node) setLive(null);
1554
2300
  releaseNode(node);
@@ -1563,6 +2309,8 @@ async function createGpuCanvas(host, options) {
1563
2309
  if (lingering === node) lingering = null;
1564
2310
  if (focused === node) focused = null;
1565
2311
  transitioning.delete(node);
2312
+ effects.delete(element);
2313
+ selection.delete(element);
1566
2314
  nodes.delete(element);
1567
2315
  releaseNode(node);
1568
2316
  element.remove();
@@ -1572,6 +2320,42 @@ async function createGpuCanvas(host, options) {
1572
2320
  const node = nodes.get(element);
1573
2321
  return node ? { ...node.rect } : null;
1574
2322
  },
2323
+ getNodesIn(rect) {
2324
+ const hits = [];
2325
+ for (const node of nodes.values()) if (intersects(node.rect, rect)) hits.push(node.element);
2326
+ return hits;
2327
+ },
2328
+ getNodeOf: (element) => nodeOfElement(nodes, element),
2329
+ getElementRect(element) {
2330
+ const rect = ringRect(element);
2331
+ return rect ? { ...rect } : null;
2332
+ },
2333
+ getElementAt(point) {
2334
+ let top = null;
2335
+ for (const node of nodes.values()) if (containsPoint(node.rect, point)) top = node;
2336
+ return top ? elementInNode(top.element, top.rect, point) : null;
2337
+ },
2338
+ setSelection(elements) {
2339
+ selection = new Set(elements);
2340
+ ringRects.clear();
2341
+ schedule();
2342
+ },
2343
+ setHover(element) {
2344
+ if (element === hovered) return;
2345
+ hovered = element;
2346
+ ringRects.clear();
2347
+ schedule();
2348
+ },
2349
+ setEffect(element, effect, params) {
2350
+ if (!nodes.has(element)) return;
2351
+ if (effect) {
2352
+ const values = effectParams(effect, params);
2353
+ effects.set(element, { effect, params: values, pad: effectPad(effect, values) });
2354
+ } else effects.delete(element);
2355
+ schedule();
2356
+ },
2357
+ screenToWorld: (point) => screenToWorld(camera, point),
2358
+ worldToScreen: (point) => worldToScreen(camera, point),
1575
2359
  getCamera: () => camera,
1576
2360
  setCamera,
1577
2361
  invalidate: onFontsLoaded,
@@ -1613,6 +2397,7 @@ async function createGpuCanvas(host, options) {
1613
2397
  canvas.removeEventListener("load", onResourceLoad, true);
1614
2398
  surface.removeEventListener("pointermove", onPointerMove);
1615
2399
  surface.removeEventListener("pointerleave", onPointerLeave);
2400
+ surface.removeEventListener("pointerdown", onPointerDown, true);
1616
2401
  canvas.removeEventListener("focusin", onFocusIn);
1617
2402
  canvas.removeEventListener("focusout", onFocusOut);
1618
2403
  canvas.removeEventListener("transitionrun", onTransitionRun);
@@ -1628,6 +2413,689 @@ async function createGpuCanvas(host, options) {
1628
2413
  };
1629
2414
  }
1630
2415
 
2416
+ // src/layers.ts
2417
+ var sameLayer = (a, b) => !!a && !!b && a[0] === b[0];
2418
+ var layerIn = (layer, list) => list.some((other) => sameLayer(layer, other));
2419
+ function layerRect(canvas, layer) {
2420
+ let union = null;
2421
+ for (const element of layer) {
2422
+ const rect = canvas.getElementRect(element);
2423
+ if (rect) union = union ? unionRect(union, rect) : rect;
2424
+ }
2425
+ return union;
2426
+ }
2427
+ function unionRect(a, b) {
2428
+ const x = Math.min(a.x, b.x);
2429
+ const y = Math.min(a.y, b.y);
2430
+ return {
2431
+ x,
2432
+ y,
2433
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
2434
+ height: Math.max(a.y + a.height, b.y + b.height) - y
2435
+ };
2436
+ }
2437
+ var sameRect = (a, b) => !!a && !!b && Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.width - b.width) < 0.5 && Math.abs(a.height - b.height) < 0.5;
2438
+ function createLayers(canvas, pick) {
2439
+ const chainOf = (element) => {
2440
+ const node = canvas.getNodeOf(element);
2441
+ if (!node) return [];
2442
+ let inner;
2443
+ if (pick === "node") inner = [];
2444
+ else if (pick === "element") {
2445
+ inner = [];
2446
+ for (let at = element; at && at !== node; at = at.parentElement) {
2447
+ inner.unshift([at]);
2448
+ }
2449
+ } else {
2450
+ inner = pick(element, canvas).map((layer) => [layer].flat()).filter((layer) => layer.length > 0 && layer[0] !== node);
2451
+ }
2452
+ const chain = [[node]];
2453
+ let previous = canvas.getNodeRect(node);
2454
+ for (const layer of inner) {
2455
+ const rect = layerRect(canvas, layer);
2456
+ if (sameRect(rect, previous)) continue;
2457
+ chain.push(layer);
2458
+ previous = rect;
2459
+ }
2460
+ return chain;
2461
+ };
2462
+ const chainAt = (world) => {
2463
+ const element = canvas.getElementAt(world);
2464
+ return element ? chainOf(element) : [];
2465
+ };
2466
+ const childrenOf = (parent) => {
2467
+ if (pick === "node") return [];
2468
+ const children = [];
2469
+ if (pick === "element") {
2470
+ const visit = (element, outer) => {
2471
+ for (const child of element.children) {
2472
+ const rect = canvas.getElementRect(child);
2473
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue;
2474
+ if (sameRect(rect, outer)) visit(child, outer);
2475
+ else children.push([child]);
2476
+ }
2477
+ };
2478
+ for (const root of parent) visit(root, layerRect(canvas, parent));
2479
+ return children;
2480
+ }
2481
+ for (const root of parent) {
2482
+ for (const element of root.querySelectorAll("*")) {
2483
+ if (children.some((child2) => child2.some((held) => held.contains(element)))) continue;
2484
+ const chain = chainOf(element);
2485
+ const index = chain.findIndex((layer) => sameLayer(layer, parent));
2486
+ const child = index >= 0 ? chain[index + 1] : void 0;
2487
+ if (child && !layerIn(child, children)) children.push(child);
2488
+ }
2489
+ }
2490
+ return children;
2491
+ };
2492
+ const layersIn = (scope, rect) => scope ? childrenOf(scope).filter((layer) => {
2493
+ const box = layerRect(canvas, layer);
2494
+ return !!box && intersects(box, rect);
2495
+ }) : canvas.getNodesIn(rect).map((node) => [node]);
2496
+ return { chainOf, chainAt, childrenOf, layersIn };
2497
+ }
2498
+
2499
+ // src/overlay.ts
2500
+ var HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
2501
+ var HANDLE_PX = 8;
2502
+ var CURSORS = {
2503
+ nw: "nwse-resize",
2504
+ se: "nwse-resize",
2505
+ ne: "nesw-resize",
2506
+ sw: "nesw-resize",
2507
+ n: "ns-resize",
2508
+ s: "ns-resize",
2509
+ e: "ew-resize",
2510
+ w: "ew-resize"
2511
+ };
2512
+ var pill = (color) => ({
2513
+ position: "absolute",
2514
+ font: "500 11px/16px system-ui, sans-serif",
2515
+ padding: "0 5px",
2516
+ borderRadius: "3px",
2517
+ color: "#fff",
2518
+ background: color,
2519
+ whiteSpace: "nowrap",
2520
+ pointerEvents: "none"
2521
+ });
2522
+ function createOverlay(surface, color, hoverColor) {
2523
+ const root = document.createElement("div");
2524
+ Object.assign(root.style, {
2525
+ position: "absolute",
2526
+ inset: "0",
2527
+ overflow: "hidden",
2528
+ pointerEvents: "none",
2529
+ zIndex: "2"
2530
+ });
2531
+ const make = (style) => {
2532
+ const element = document.createElement("div");
2533
+ Object.assign(element.style, { left: "0", top: "0" }, style, { display: "none" });
2534
+ root.append(element);
2535
+ return element;
2536
+ };
2537
+ const hoverName = make(pill(hoverColor));
2538
+ const name = make(pill(color));
2539
+ const size = make(pill(color));
2540
+ const bounds = make({ position: "absolute", border: `1px solid ${color}` });
2541
+ const handles = HANDLES.map((handle) => {
2542
+ const element = make({
2543
+ position: "absolute",
2544
+ width: `${HANDLE_PX}px`,
2545
+ height: `${HANDLE_PX}px`,
2546
+ boxSizing: "border-box",
2547
+ background: "#fff",
2548
+ border: `1px solid ${color}`,
2549
+ pointerEvents: "auto",
2550
+ cursor: CURSORS[handle]
2551
+ });
2552
+ element.dataset.gpucanvasHandle = handle;
2553
+ return element;
2554
+ });
2555
+ const guides = [];
2556
+ surface.append(root);
2557
+ const show = (element, visible) => {
2558
+ element.style.display = visible ? "block" : "none";
2559
+ };
2560
+ const place = (element, x, y) => {
2561
+ element.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
2562
+ };
2563
+ const text = (element, value) => {
2564
+ if (element.textContent !== value) element.textContent = value;
2565
+ };
2566
+ return {
2567
+ render(state) {
2568
+ const { hover, selection } = state;
2569
+ show(hoverName, !!hover?.name);
2570
+ if (hover?.name) {
2571
+ text(hoverName, hover.name);
2572
+ place(hoverName, hover.rect.x - 1, hover.rect.y - 19);
2573
+ }
2574
+ show(name, !!selection?.name);
2575
+ show(size, !!selection);
2576
+ show(bounds, !!selection && state.bounds);
2577
+ for (const handle of handles) show(handle, !!selection && state.handles);
2578
+ if (selection) {
2579
+ const { rect } = selection;
2580
+ if (selection.name) {
2581
+ text(name, selection.name);
2582
+ place(name, rect.x - 1, rect.y - 19);
2583
+ }
2584
+ const { width, height } = selection.size;
2585
+ const label = `${Math.round(width)} \xD7 ${Math.round(height)}`;
2586
+ text(size, selection.count > 1 ? `${selection.count} layers \xB7 ${label}` : label);
2587
+ place(size, rect.x + rect.width / 2 - size.offsetWidth / 2, rect.y + rect.height + 6);
2588
+ if (state.bounds) {
2589
+ place(bounds, rect.x, rect.y);
2590
+ bounds.style.width = `${rect.width}px`;
2591
+ bounds.style.height = `${rect.height}px`;
2592
+ }
2593
+ if (state.handles) {
2594
+ HANDLES.forEach((handle, i) => {
2595
+ const fx = handle.includes("w") ? 0 : handle.includes("e") ? 1 : 0.5;
2596
+ const fy = handle.includes("n") ? 0 : handle.includes("s") ? 1 : 0.5;
2597
+ place(
2598
+ handles[i],
2599
+ rect.x + rect.width * fx - HANDLE_PX / 2,
2600
+ rect.y + rect.height * fy - HANDLE_PX / 2
2601
+ );
2602
+ });
2603
+ }
2604
+ }
2605
+ while (guides.length < state.guides.length) {
2606
+ guides.push(make({ position: "absolute", background: "#f43f5e" }));
2607
+ }
2608
+ guides.forEach((line, i) => {
2609
+ const guide = state.guides[i];
2610
+ show(line, !!guide);
2611
+ if (!guide) return;
2612
+ place(line, guide.x, guide.y);
2613
+ line.style.width = `${Math.max(1, guide.width)}px`;
2614
+ line.style.height = `${Math.max(1, guide.height)}px`;
2615
+ });
2616
+ },
2617
+ remove() {
2618
+ root.remove();
2619
+ }
2620
+ };
2621
+ }
2622
+ var guideRect = (guide, toScreen) => {
2623
+ const a = toScreen(guide.x1, guide.y1);
2624
+ const b = toScreen(guide.x2, guide.y2);
2625
+ return {
2626
+ x: Math.min(a.x, b.x),
2627
+ y: Math.min(a.y, b.y),
2628
+ width: Math.abs(b.x - a.x),
2629
+ height: Math.abs(b.y - a.y)
2630
+ };
2631
+ };
2632
+
2633
+ // src/snap.ts
2634
+ var ALL_ANCHORS = [0, 0.5, 1];
2635
+ var anchorsX = (rect) => ALL_ANCHORS.map((f) => rect.x + rect.width * f);
2636
+ var anchorsY = (rect) => ALL_ANCHORS.map((f) => rect.y + rect.height * f);
2637
+ function nearest(moving, lines, threshold) {
2638
+ let best = Infinity;
2639
+ for (const from of moving) {
2640
+ for (const to of lines) {
2641
+ const delta = to - from;
2642
+ if (Math.abs(delta) <= threshold && Math.abs(delta) < Math.abs(best)) best = delta;
2643
+ }
2644
+ }
2645
+ return best === Infinity ? 0 : best;
2646
+ }
2647
+ function snapRect(rect, targets, threshold, edges = { x: ALL_ANCHORS, y: ALL_ANCHORS }) {
2648
+ const xs = targets.flatMap(anchorsX);
2649
+ const ys = targets.flatMap(anchorsY);
2650
+ const dx = nearest(
2651
+ edges.x.map((f) => rect.x + rect.width * f),
2652
+ xs,
2653
+ threshold
2654
+ );
2655
+ const dy = nearest(
2656
+ edges.y.map((f) => rect.y + rect.height * f),
2657
+ ys,
2658
+ threshold
2659
+ );
2660
+ const moved = { ...rect, x: rect.x + dx, y: rect.y + dy };
2661
+ return { dx, dy, guides: guidesFor(moved, targets, edges) };
2662
+ }
2663
+ function guidesFor(rect, targets, edges) {
2664
+ const guides = [];
2665
+ const close = (a, b) => Math.abs(a - b) < 0.01;
2666
+ for (const f of edges.x) {
2667
+ const x = rect.x + rect.width * f;
2668
+ const hits = targets.filter((target) => anchorsX(target).some((at) => close(at, x)));
2669
+ if (!hits.length) continue;
2670
+ const top = Math.min(rect.y, ...hits.map((t) => t.y));
2671
+ const bottom2 = Math.max(rect.y + rect.height, ...hits.map((t) => t.y + t.height));
2672
+ guides.push({ x1: x, y1: top, x2: x, y2: bottom2 });
2673
+ }
2674
+ for (const f of edges.y) {
2675
+ const y = rect.y + rect.height * f;
2676
+ const hits = targets.filter((target) => anchorsY(target).some((at) => close(at, y)));
2677
+ if (!hits.length) continue;
2678
+ const left = Math.min(rect.x, ...hits.map((t) => t.x));
2679
+ const right2 = Math.max(rect.x + rect.width, ...hits.map((t) => t.x + t.width));
2680
+ guides.push({ x1: left, y1: y, x2: right2, y2: y });
2681
+ }
2682
+ return guides;
2683
+ }
2684
+
2685
+ // src/editor.ts
2686
+ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world) => canvas.getNodesIn(world)) {
2687
+ const box = document.createElement("div");
2688
+ Object.assign(box.style, {
2689
+ position: "absolute",
2690
+ display: "none",
2691
+ pointerEvents: "none",
2692
+ border: `1px solid ${color}`,
2693
+ background: `color-mix(in srgb, ${color} 12%, transparent)`,
2694
+ zIndex: "1"
2695
+ });
2696
+ canvas.surface.append(box);
2697
+ return {
2698
+ /** Spans the box from `a` to `b` (surface pixels) and selects what it covers. */
2699
+ update(a, b) {
2700
+ const x = Math.min(a.x, b.x);
2701
+ const y = Math.min(a.y, b.y);
2702
+ const width = Math.abs(b.x - a.x);
2703
+ const height = Math.abs(b.y - a.y);
2704
+ Object.assign(box.style, {
2705
+ display: "block",
2706
+ left: `${x}px`,
2707
+ top: `${y}px`,
2708
+ width: `${width}px`,
2709
+ height: `${height}px`
2710
+ });
2711
+ const camera = canvas.getCamera();
2712
+ const hits = select({
2713
+ ...screenToWorld(camera, { x, y }),
2714
+ width: width / camera.zoom,
2715
+ height: height / camera.zoom
2716
+ });
2717
+ canvas.setSelection(hits);
2718
+ return hits;
2719
+ },
2720
+ hide() {
2721
+ box.style.display = "none";
2722
+ },
2723
+ remove() {
2724
+ box.remove();
2725
+ }
2726
+ };
2727
+ }
2728
+ var CLICK_SLOP = 4;
2729
+ var SNAP_PX = 6;
2730
+ var MAX_NAMED = 1;
2731
+ var isEditable = (element) => !!element && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable);
2732
+ function bindEditor(canvas, options = {}) {
2733
+ const {
2734
+ pick = "node",
2735
+ trigger = "shift-click",
2736
+ hover = true,
2737
+ marquee = true,
2738
+ labels = true,
2739
+ snap = true,
2740
+ color = DEFAULT_SELECTION_COLOR,
2741
+ onChange,
2742
+ onTransform
2743
+ } = options;
2744
+ const label = options.label ?? ((layer) => canvas.getNodeOf(layer[0]) === layer[0] ? null : layer[0].localName);
2745
+ const { surface } = canvas;
2746
+ const layers = createLayers(canvas, pick);
2747
+ const transforms = !!onTransform && trigger === "click";
2748
+ const overlay = createOverlay(surface, color, color);
2749
+ let marqueeLayers = [];
2750
+ const box = marquee ? createMarquee(canvas, color, (world) => {
2751
+ marqueeLayers = layers.layersIn(scopes.at(-1) ?? null, world);
2752
+ return marqueeLayers.flat();
2753
+ }) : null;
2754
+ let selection = [];
2755
+ let scopes = [];
2756
+ let hovered = null;
2757
+ let gesture = null;
2758
+ let pointer = null;
2759
+ let shift = false;
2760
+ let swallowClick = false;
2761
+ const commit = (next, nextScopes = scopes) => {
2762
+ selection = next.filter((layer, i) => next.findIndex((other) => sameLayer(layer, other)) === i);
2763
+ scopes = selection.length ? nextScopes : [];
2764
+ canvas.setSelection(selection.flat());
2765
+ onChange?.(selection);
2766
+ draw();
2767
+ };
2768
+ const setHovered = (layer) => {
2769
+ if (sameLayer(layer, hovered) || layer === hovered) return;
2770
+ hovered = layer;
2771
+ canvas.setHover(layer?.[0] ?? null);
2772
+ draw();
2773
+ };
2774
+ const at = (event) => {
2775
+ const bounds = surface.getBoundingClientRect();
2776
+ return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
2777
+ };
2778
+ const chainAt = (point) => layers.chainAt(canvas.screenToWorld(point));
2779
+ const targetIn = (chain) => {
2780
+ if (!chain.length) return null;
2781
+ for (let i = scopes.length - 1; i >= 0; i--) {
2782
+ const k = chain.findIndex((layer) => sameLayer(layer, scopes[i]));
2783
+ if (k < 0) continue;
2784
+ return k + 1 < chain.length ? { layer: chain[k + 1], scopes: chain.slice(0, k + 1) } : { layer: chain[k], scopes: chain.slice(0, k) };
2785
+ }
2786
+ return { layer: chain[0], scopes: [] };
2787
+ };
2788
+ const selecting = (withShift) => trigger === "click" || withShift;
2789
+ const updateHover = () => {
2790
+ const show = hover && pointer && !gesture && selecting(shift);
2791
+ setHovered(show ? targetIn(chainAt(pointer))?.layer ?? null : null);
2792
+ };
2793
+ let frame = 0;
2794
+ const draw = () => {
2795
+ if (!frame) frame = requestAnimationFrame(render);
2796
+ };
2797
+ const toScreen = (rect) => {
2798
+ const { x, y } = canvas.worldToScreen(rect);
2799
+ const zoom = canvas.getCamera().zoom;
2800
+ return { x, y, width: rect.width * zoom, height: rect.height * zoom };
2801
+ };
2802
+ const unionOf = (rects) => rects.reduce(
2803
+ (union, rect) => !rect ? union : union ? unionRect(union, rect) : rect,
2804
+ null
2805
+ );
2806
+ const render = () => {
2807
+ frame = 0;
2808
+ const transforming = gesture?.kind === "move" || gesture?.kind === "resize" ? gesture : null;
2809
+ const world = transforming ? unionOf(transforming.to) : selection.length ? unionOf(selection.map((layer) => layerRect(canvas, layer))) : null;
2810
+ const hoverRect = hovered && !layerIn(hovered, selection) ? layerRect(canvas, hovered) : null;
2811
+ overlay.render({
2812
+ hover: labels && hoverRect ? { rect: toScreen(hoverRect), name: label(hovered) } : null,
2813
+ selection: labels && world ? {
2814
+ rect: toScreen(world),
2815
+ size: world,
2816
+ name: selection.length <= MAX_NAMED ? label(selection[0]) : null,
2817
+ count: selection.length
2818
+ } : null,
2819
+ bounds: selection.length > 1 || !!transforming,
2820
+ handles: transforms && selection.length > 0 && gesture?.kind !== "marquee",
2821
+ guides: transforming ? transforming.guides.map(
2822
+ (guide) => guideRect(guide, (x, y) => canvas.worldToScreen({ x, y }))
2823
+ ) : []
2824
+ });
2825
+ if (selection.length || hovered || gesture) draw();
2826
+ };
2827
+ const emit = (phase) => {
2828
+ if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
2829
+ onTransform?.({
2830
+ phase,
2831
+ kind: gesture.kind,
2832
+ layers: gesture.layers,
2833
+ from: gesture.from,
2834
+ to: phase === "cancel" ? gesture.from : gesture.to
2835
+ });
2836
+ };
2837
+ const snapTargets = (moving) => {
2838
+ if (!snap) return [];
2839
+ const scope = scopes.at(-1);
2840
+ if (!scope) {
2841
+ const view = visibleWorldRect(canvas.getCamera(), surface.clientWidth, surface.clientHeight);
2842
+ return canvas.getNodesIn(view).filter((node) => !moving.some((layer) => layer[0] === node)).map((node) => canvas.getNodeRect(node));
2843
+ }
2844
+ const rects = layers.childrenOf(scope).filter((layer) => !layerIn(layer, moving)).map((layer) => layerRect(canvas, layer));
2845
+ return [layerRect(canvas, scope), ...rects].filter((rect) => !!rect);
2846
+ };
2847
+ const beginTransform = (start, handle) => {
2848
+ const from = selection.map((layer) => layerRect(canvas, layer));
2849
+ if (from.some((rect) => !rect)) return;
2850
+ const rects = from;
2851
+ gesture = {
2852
+ kind: handle ? "resize" : "move",
2853
+ start,
2854
+ handle,
2855
+ layers: selection,
2856
+ from: rects,
2857
+ bounds: unionOf(rects),
2858
+ to: rects,
2859
+ targets: snapTargets(selection),
2860
+ guides: []
2861
+ };
2862
+ setHovered(null);
2863
+ emit("start");
2864
+ };
2865
+ const updateTransform = (point, keepRatio) => {
2866
+ if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
2867
+ const zoom = canvas.getCamera().zoom;
2868
+ let dx = (point.x - gesture.start.x) / zoom;
2869
+ let dy = (point.y - gesture.start.y) / zoom;
2870
+ const { bounds, handle } = gesture;
2871
+ const threshold = SNAP_PX / zoom;
2872
+ let next;
2873
+ if (!handle) {
2874
+ if (keepRatio) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
2875
+ next = { ...bounds, x: bounds.x + dx, y: bounds.y + dy };
2876
+ const snapped = snapRect(next, gesture.targets, threshold);
2877
+ next = { ...next, x: next.x + snapped.dx, y: next.y + snapped.dy };
2878
+ gesture.guides = snapped.guides;
2879
+ } else {
2880
+ let left = bounds.x;
2881
+ let top = bounds.y;
2882
+ let right2 = bounds.x + bounds.width;
2883
+ let bottom2 = bounds.y + bounds.height;
2884
+ if (handle.includes("w")) left = Math.min(left + dx, right2 - 1);
2885
+ if (handle.includes("e")) right2 = Math.max(right2 + dx, left + 1);
2886
+ if (handle.includes("n")) top = Math.min(top + dy, bottom2 - 1);
2887
+ if (handle.includes("s")) bottom2 = Math.max(bottom2 + dy, top + 1);
2888
+ next = { x: left, y: top, width: right2 - left, height: bottom2 - top };
2889
+ const corner = handle.length === 2;
2890
+ if (keepRatio && corner) {
2891
+ const scale = Math.max(next.width / bounds.width, next.height / bounds.height);
2892
+ const width = bounds.width * scale;
2893
+ const height = bounds.height * scale;
2894
+ next = {
2895
+ x: handle.includes("w") ? right2 - width : left,
2896
+ y: handle.includes("n") ? bottom2 - height : top,
2897
+ width,
2898
+ height
2899
+ };
2900
+ gesture.guides = [];
2901
+ } else {
2902
+ const edges = {
2903
+ x: handle.includes("w") ? [0] : handle.includes("e") ? [1] : [],
2904
+ y: handle.includes("n") ? [0] : handle.includes("s") ? [1] : []
2905
+ };
2906
+ const snapped = snapRect(next, gesture.targets, threshold, edges);
2907
+ if (edges.x[0] === 0)
2908
+ next = { ...next, x: next.x + snapped.dx, width: next.width - snapped.dx };
2909
+ if (edges.x[0] === 1) next = { ...next, width: next.width + snapped.dx };
2910
+ if (edges.y[0] === 0)
2911
+ next = { ...next, y: next.y + snapped.dy, height: next.height - snapped.dy };
2912
+ if (edges.y[0] === 1) next = { ...next, height: next.height + snapped.dy };
2913
+ gesture.guides = snapped.guides;
2914
+ }
2915
+ }
2916
+ const sx = next.width / bounds.width;
2917
+ const sy = next.height / bounds.height;
2918
+ gesture.to = gesture.from.map((rect) => ({
2919
+ x: next.x + (rect.x - bounds.x) * sx,
2920
+ y: next.y + (rect.y - bounds.y) * sy,
2921
+ width: rect.width * sx,
2922
+ height: rect.height * sy
2923
+ }));
2924
+ emit("move");
2925
+ draw();
2926
+ };
2927
+ const onPointerDown = (event) => {
2928
+ shift = event.shiftKey;
2929
+ swallowClick = false;
2930
+ if (event.button !== 0) return;
2931
+ const point = at(event);
2932
+ const take = () => {
2933
+ event.stopPropagation();
2934
+ swallowClick = true;
2935
+ surface.setPointerCapture(event.pointerId);
2936
+ };
2937
+ const handle = event.target.closest?.("[data-gpucanvas-handle]");
2938
+ if (handle && transforms) {
2939
+ take();
2940
+ return beginTransform(point, handle.dataset.gpucanvasHandle);
2941
+ }
2942
+ const target = targetIn(chainAt(point));
2943
+ if (!target) {
2944
+ if (event.shiftKey && box) {
2945
+ take();
2946
+ gesture = { kind: "marquee", start: point };
2947
+ return;
2948
+ }
2949
+ if (selection.length) commit([], []);
2950
+ return;
2951
+ }
2952
+ if (!selecting(event.shiftKey)) return;
2953
+ take();
2954
+ const additive = trigger === "click" ? event.shiftKey : event.shiftKey && (event.metaKey || event.ctrlKey);
2955
+ const inside = !!box && sameLayer(target.layer, scopes.at(-1));
2956
+ if (!additive && !inside && !layerIn(target.layer, selection)) {
2957
+ commit([target.layer], target.scopes);
2958
+ }
2959
+ gesture = { kind: "press", start: point, additive, target, inside };
2960
+ setHovered(null);
2961
+ };
2962
+ const onPointerMove = (event) => {
2963
+ shift = event.shiftKey;
2964
+ pointer = at(event);
2965
+ if (!gesture) return updateHover();
2966
+ if (gesture.kind === "press") {
2967
+ if (Math.hypot(pointer.x - gesture.start.x, pointer.y - gesture.start.y) <= CLICK_SLOP)
2968
+ return;
2969
+ if (gesture.inside || trigger !== "click" && box) {
2970
+ gesture = { kind: "marquee", start: gesture.start };
2971
+ } else if (trigger === "click" && transforms) beginTransform(gesture.start, null);
2972
+ else return;
2973
+ }
2974
+ if (gesture?.kind === "marquee") {
2975
+ box.update(gesture.start, pointer);
2976
+ selection = marqueeLayers;
2977
+ onChange?.(selection);
2978
+ draw();
2979
+ } else updateTransform(pointer, event.shiftKey);
2980
+ };
2981
+ const onPointerUp = () => {
2982
+ const done = gesture;
2983
+ gesture = null;
2984
+ if (!done) return;
2985
+ if (done.kind === "marquee") {
2986
+ box.hide();
2987
+ commit(marqueeLayers);
2988
+ } else if (done.kind === "press") {
2989
+ const { layer, scopes: inside } = done.target;
2990
+ if (!done.additive) commit([layer], inside);
2991
+ else if (layerIn(layer, selection))
2992
+ commit(selection.filter((other) => !sameLayer(other, layer)));
2993
+ else commit([...selection, layer], inside);
2994
+ } else {
2995
+ gesture = done;
2996
+ emit("end");
2997
+ gesture = null;
2998
+ }
2999
+ updateHover();
3000
+ draw();
3001
+ };
3002
+ const onDoubleClick = (event) => {
3003
+ if (!selecting(event.shiftKey)) return;
3004
+ event.preventDefault();
3005
+ event.stopPropagation();
3006
+ const chain = chainAt(at(event));
3007
+ let depth = -1;
3008
+ chain.forEach((layer, i) => {
3009
+ if (layerIn(layer, selection)) depth = i;
3010
+ });
3011
+ if (depth >= 0 && chain[depth + 1]) commit([chain[depth + 1]], chain.slice(0, depth + 1));
3012
+ };
3013
+ const onPointerLeave = () => {
3014
+ pointer = null;
3015
+ setHovered(null);
3016
+ };
3017
+ const onMouseDown = (event) => {
3018
+ if (!swallowClick) return;
3019
+ event.preventDefault();
3020
+ event.stopPropagation();
3021
+ };
3022
+ const onClick = (event) => {
3023
+ if (!swallowClick) return;
3024
+ swallowClick = false;
3025
+ event.preventDefault();
3026
+ event.stopPropagation();
3027
+ };
3028
+ const up = () => {
3029
+ const parent = scopes.at(-1);
3030
+ if (parent) commit([parent], scopes.slice(0, -1));
3031
+ };
3032
+ const onKey = (event) => {
3033
+ if (event.key === "Shift") {
3034
+ shift = event.type === "keydown";
3035
+ return updateHover();
3036
+ }
3037
+ if (event.type !== "keydown" || isEditable(document.activeElement)) return;
3038
+ if (event.key === "Escape") {
3039
+ if (gesture?.kind === "move" || gesture?.kind === "resize") {
3040
+ emit("cancel");
3041
+ gesture = null;
3042
+ } else if (scopes.length) up();
3043
+ else if (selection.length) commit([], []);
3044
+ return;
3045
+ }
3046
+ if (!selection.length) return;
3047
+ if (event.key === "Enter") {
3048
+ event.preventDefault();
3049
+ if (event.shiftKey) return up();
3050
+ const first = layers.childrenOf(selection[0])[0];
3051
+ if (first) commit([first], [...scopes, selection[0]]);
3052
+ } else if (event.key === "Tab") {
3053
+ event.preventDefault();
3054
+ const scope = scopes.at(-1);
3055
+ const siblings = scope ? layers.childrenOf(scope) : canvas.getNodesIn({ x: -1e9, y: -1e9, width: 2e9, height: 2e9 }).map((node) => [node]);
3056
+ const index = siblings.findIndex((layer) => sameLayer(layer, selection[0]));
3057
+ const step = event.shiftKey ? -1 : 1;
3058
+ const next = siblings[(index + step + siblings.length) % siblings.length];
3059
+ if (next) commit([next]);
3060
+ }
3061
+ };
3062
+ surface.addEventListener("pointerdown", onPointerDown, true);
3063
+ surface.addEventListener("mousedown", onMouseDown, true);
3064
+ surface.addEventListener("click", onClick, true);
3065
+ surface.addEventListener("dblclick", onDoubleClick, true);
3066
+ surface.addEventListener("pointermove", onPointerMove);
3067
+ surface.addEventListener("pointerup", onPointerUp);
3068
+ surface.addEventListener("pointercancel", onPointerUp);
3069
+ surface.addEventListener("pointerleave", onPointerLeave);
3070
+ window.addEventListener("keydown", onKey);
3071
+ window.addEventListener("keyup", onKey);
3072
+ return {
3073
+ get: () => selection,
3074
+ set: (next) => commit(
3075
+ [...next].map((layer) => [layer].flat()),
3076
+ []
3077
+ ),
3078
+ clear: () => commit([], []),
3079
+ destroy() {
3080
+ surface.removeEventListener("pointerdown", onPointerDown, true);
3081
+ surface.removeEventListener("mousedown", onMouseDown, true);
3082
+ surface.removeEventListener("click", onClick, true);
3083
+ surface.removeEventListener("dblclick", onDoubleClick, true);
3084
+ surface.removeEventListener("pointermove", onPointerMove);
3085
+ surface.removeEventListener("pointerup", onPointerUp);
3086
+ surface.removeEventListener("pointercancel", onPointerUp);
3087
+ surface.removeEventListener("pointerleave", onPointerLeave);
3088
+ window.removeEventListener("keydown", onKey);
3089
+ window.removeEventListener("keyup", onKey);
3090
+ cancelAnimationFrame(frame);
3091
+ box?.remove();
3092
+ overlay.remove();
3093
+ canvas.setHover(null);
3094
+ canvas.setSelection([]);
3095
+ }
3096
+ };
3097
+ }
3098
+
1631
3099
  // src/index.ts
1632
3100
  async function createGpuCanvas2(host, options = {}) {
1633
3101
  const mode = options.mode ?? "auto";
@@ -1643,9 +3111,14 @@ async function createGpuCanvas2(host, options = {}) {
1643
3111
  }
1644
3112
 
1645
3113
  export {
3114
+ screenToWorld,
3115
+ worldToScreen,
1646
3116
  zoomAt,
1647
3117
  fitCamera,
3118
+ defineEffect,
3119
+ EFFECTS,
1648
3120
  supportsGpuCanvas,
3121
+ bindEditor,
1649
3122
  createGpuCanvas2 as createGpuCanvas
1650
3123
  };
1651
- //# sourceMappingURL=chunk-JQQ44OTN.js.map
3124
+ //# sourceMappingURL=chunk-RBXGH44N.js.map