gpucanvas 0.2.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(
@@ -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`;
@@ -107,6 +502,7 @@ function boundsOf(rects) {
107
502
  var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
108
503
  var DEFAULT_SELECTION_COLOR = "#4f8cff";
109
504
  var SELECTION_WIDTH = 2;
505
+ var HOVER_WIDTH = 1;
110
506
 
111
507
  // src/dom-backend.ts
112
508
  function createDomCanvas(host, options) {
@@ -129,11 +525,96 @@ function createDomCanvas(host, options) {
129
525
  transformOrigin: "0 0",
130
526
  willChange: "transform"
131
527
  });
528
+ const overlay = document.createElement("div");
529
+ Object.assign(overlay.style, { position: "absolute", left: "0", top: "0", zIndex: "1" });
530
+ world.append(overlay);
132
531
  surface.append(world);
133
532
  host.append(surface);
134
533
  const nodes = /* @__PURE__ */ new Map();
135
- let selected = /* @__PURE__ */ new Set();
136
- const outline = `calc(${SELECTION_WIDTH}px / var(--gpucanvas-zoom)) solid ${options.selectionColor ?? DEFAULT_SELECTION_COLOR}`;
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
+ };
137
618
  let camera = options.camera ?? DEFAULT_CAMERA;
138
619
  let frameMs = 0;
139
620
  const apply = () => {
@@ -166,22 +647,39 @@ function createDomCanvas(host, options) {
166
647
  applyNodeSize(element, rect);
167
648
  },
168
649
  removeNode(element) {
650
+ clearEffect(element);
169
651
  nodes.delete(element);
170
- selected.delete(element);
652
+ outlinedNodes.delete(element);
653
+ element.style.outline = "";
171
654
  element.remove();
172
655
  },
173
656
  getNodeRect: (element) => nodes.get(element) ?? null,
174
657
  getNodesIn(rect) {
175
658
  return [...nodes].filter(([, node]) => intersects(node, rect)).map(([element]) => element);
176
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
+ },
177
667
  setSelection(elements) {
178
- const next = new Set([...elements].filter((element) => nodes.has(element)));
179
- for (const element of selected) if (!next.has(element)) element.style.outline = "";
180
- for (const element of next) if (!selected.has(element)) element.style.outline = outline;
181
- selected = next;
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);
182
678
  },
183
679
  getCamera: () => camera,
184
680
  setCamera,
681
+ screenToWorld: (point) => screenToWorld(camera, point),
682
+ worldToScreen: (point) => worldToScreen(camera, point),
185
683
  // The browser repaints DOM nodes itself.
186
684
  invalidate() {
187
685
  },
@@ -208,6 +706,7 @@ function createDomCanvas(host, options) {
208
706
  };
209
707
  },
210
708
  destroy() {
709
+ cancelAnimationFrame(boxFrame);
211
710
  unbindInput();
212
711
  surface.remove();
213
712
  }
@@ -365,7 +864,7 @@ var Atlas = class {
365
864
  };
366
865
 
367
866
  // src/composite-shader.ts
368
- var compositeShader = (
867
+ var prelude = (
369
868
  /* wgsl */
370
869
  `
371
870
  struct View {
@@ -374,9 +873,10 @@ struct View {
374
873
  zoom: f32,
375
874
  dpr: f32,
376
875
  atlasSize: f32,
377
- outline: f32, // selection ring width, CSS pixels
876
+ time: f32, // seconds since the canvas started
378
877
  placeholder: vec4f, // premultiplied color for nodes without a snapshot
379
- selection: vec4f, // premultiplied selection ring color
878
+ pointer: vec4f, // pointer and last press in world units; far away when there is none
879
+ pressTime: f32, // seconds since the last press
380
880
  }
381
881
 
382
882
  @group(0) @binding(0) var<uniform> view: View;
@@ -385,8 +885,9 @@ struct View {
385
885
 
386
886
  struct Instance {
387
887
  @location(0) rect: vec4f, // world x, y, width, height
388
- @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1
389
- @location(2) info: vec4f, // layer, kind: 0 snapshot, 1 placeholder, 2 selection ring
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
390
891
  }
391
892
 
392
893
  struct Varyings {
@@ -394,14 +895,19 @@ struct Varyings {
394
895
  @location(0) texel: vec2f,
395
896
  @location(1) @interpolate(flat) bounds: vec4f,
396
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
397
903
  }
398
904
 
399
905
  @vertex
400
906
  fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
401
907
  let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
402
- // A selection ring's quad reaches past the node by the ring's width.
908
+ // A ring's quad reaches past its element by the ring's width, an effect's by its padding.
403
909
  let ring = instance.info.y > 1.5;
404
- let grow = select(0.0, view.outline / view.zoom, ring);
910
+ let grow = select(instance.info.z, instance.info.z / view.zoom, ring);
405
911
  let start = instance.rect.xy - grow;
406
912
  let end = instance.rect.xy + instance.rect.zw + grow;
407
913
  // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
@@ -412,28 +918,39 @@ fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
412
918
  var out: Varyings;
413
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);
414
920
  if (ring) {
415
- // World units from the node's top left, and the node's own edges.
921
+ // World units from the element's top left, and the element's own edges.
416
922
  out.texel = mix(vec2f(-grow), instance.rect.zw + grow, corner);
417
923
  out.bounds = vec4f(0.0, 0.0, instance.rect.zw);
924
+ out.color = instance.uv;
418
925
  } else {
419
- out.texel = mix(instance.uv.xy, instance.uv.zw, corner);
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;
420
929
  // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
421
930
  // with whatever sits next to it in the atlas.
422
931
  out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
423
932
  }
424
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;
425
938
  return out;
426
939
  }
427
-
940
+ `
941
+ );
942
+ var compositeShader = (
943
+ /* wgsl */
944
+ `${prelude}
428
945
  @fragment
429
946
  fn fs(in: Varyings) -> @location(0) vec4f {
430
947
  // Derivatives first: WGSL only allows them in uniform control flow.
431
948
  let dx = dpdx(in.texel);
432
949
  let dy = dpdy(in.texel);
433
950
  if (in.info.y > 1.5) {
434
- // The ring covers what lies outside the node; the node itself shows through.
951
+ // The ring covers what lies outside the element; the element itself shows through.
435
952
  let outside = max(in.bounds.xy - in.texel, in.texel - in.bounds.zw);
436
- return select(vec4f(0.0), view.selection, max(outside.x, outside.y) > 0.0);
953
+ return select(vec4f(0.0), in.color, max(outside.x, outside.y) > 0.0);
437
954
  }
438
955
  if (in.info.y > 0.5) {
439
956
  return view.placeholder;
@@ -454,25 +971,124 @@ fn fs(in: Varyings) -> @location(0) vec4f {
454
971
  }
455
972
  `
456
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
+ );
457
1028
 
458
1029
  // src/gpu-renderer.ts
459
- var FLOATS_PER_INSTANCE = 12;
1030
+ var FLOATS_PER_INSTANCE = 16;
460
1031
  var CompositeRenderer = class {
461
- constructor(device, context, format, atlas, background, placeholder, selection, outline) {
1032
+ constructor(device, context, format, atlas, background, placeholder) {
462
1033
  this.device = device;
463
1034
  this.context = context;
464
1035
  this.atlas = atlas;
465
1036
  this.background = background;
466
1037
  this.placeholder = placeholder;
467
- this.selection = selection;
468
- this.outline = outline;
469
- const module = device.createShaderModule({
470
- label: "gpucanvas composite",
471
- 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
+ ]
1048
+ });
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
472
1057
  });
473
- this.pipeline = device.createRenderPipeline({
474
- label: "gpucanvas composite",
475
- layout: "auto",
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] }),
476
1092
  vertex: {
477
1093
  module,
478
1094
  entryPoint: "vs",
@@ -483,7 +1099,8 @@ var CompositeRenderer = class {
483
1099
  attributes: [
484
1100
  { shaderLocation: 0, offset: 0, format: "float32x4" },
485
1101
  { shaderLocation: 1, offset: 16, format: "float32x4" },
486
- { shaderLocation: 2, offset: 32, format: "float32x4" }
1102
+ { shaderLocation: 2, offset: 32, format: "float32x4" },
1103
+ { shaderLocation: 3, offset: 48, format: "float32x4" }
487
1104
  ]
488
1105
  }
489
1106
  ]
@@ -503,37 +1120,24 @@ var CompositeRenderer = class {
503
1120
  ]
504
1121
  },
505
1122
  primitive: { topology: "triangle-strip" }
506
- });
507
- this.uniforms = device.createBuffer({
508
- size: this.uniformData.byteLength,
509
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
510
- });
511
- this.bindGroup = device.createBindGroup({
512
- layout: this.pipeline.getBindGroupLayout(0),
513
- entries: [
514
- { binding: 0, resource: { buffer: this.uniforms } },
515
- { binding: 1, resource: atlas.view },
516
- {
517
- binding: 2,
518
- resource: device.createSampler({ magFilter: "linear", minFilter: "linear" })
519
- }
520
- ]
521
- });
522
- 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;
523
1140
  }
524
- device;
525
- context;
526
- atlas;
527
- background;
528
- placeholder;
529
- selection;
530
- outline;
531
- instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
532
- instanceBuffer;
533
- uniforms;
534
- uniformData = new Float32Array(16);
535
- pipeline;
536
- bindGroup;
537
1141
  /** Grows the CPU and GPU instance storage to hold at least `count` instances. */
538
1142
  reserve(count) {
539
1143
  const floats = count * FLOATS_PER_INSTANCE;
@@ -542,7 +1146,7 @@ var CompositeRenderer = class {
542
1146
  this.instanceBuffer.destroy();
543
1147
  this.instanceBuffer = this.createInstanceBuffer();
544
1148
  }
545
- draw(camera, cssWidth, cssHeight, dpr, count) {
1149
+ draw(camera, cssWidth, cssHeight, dpr, count, runs, effects) {
546
1150
  const { device } = this;
547
1151
  this.uniformData.set([
548
1152
  cssWidth,
@@ -552,9 +1156,13 @@ var CompositeRenderer = class {
552
1156
  camera.zoom,
553
1157
  dpr,
554
1158
  this.atlas.size,
555
- this.outline,
1159
+ effects.time,
556
1160
  ...this.placeholder,
557
- ...this.selection
1161
+ effects.pointer.x,
1162
+ effects.pointer.y,
1163
+ effects.press.x,
1164
+ effects.press.y,
1165
+ effects.sincePress
558
1166
  ]);
559
1167
  device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
560
1168
  if (count > 0) {
@@ -579,10 +1187,14 @@ var CompositeRenderer = class {
579
1187
  ]
580
1188
  });
581
1189
  if (count > 0) {
582
- pass.setPipeline(this.pipeline);
583
1190
  pass.setBindGroup(0, this.bindGroup);
584
1191
  pass.setVertexBuffer(0, this.instanceBuffer);
585
- 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
+ }
586
1198
  }
587
1199
  pass.end();
588
1200
  device.queue.submit([encoder.finish()]);
@@ -773,12 +1385,32 @@ async function createGpuCanvas(host, options) {
773
1385
  context,
774
1386
  format,
775
1387
  atlas,
776
- ...colorsFor(options.background ?? "#f4f4f5"),
777
- premultiplied(cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)),
778
- SELECTION_WIDTH
1388
+ ...colorsFor(options.background ?? "#f4f4f5")
779
1389
  );
1390
+ const selectionColor = premultiplied(
1391
+ cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)
1392
+ );
1393
+ const hoverColor = options.hoverColor ? premultiplied(cssColorToRgba(options.hoverColor)) : selectionColor;
780
1394
  const nodes = /* @__PURE__ */ new Map();
781
- const selected = /* @__PURE__ */ new Set();
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
+ };
782
1414
  const pages = /* @__PURE__ */ new Map();
783
1415
  atlas.keep = function* () {
784
1416
  const onScreen = (tile) => intersects(tile.rect, frameView);
@@ -1174,12 +1806,22 @@ async function createGpuCanvas(host, options) {
1174
1806
  };
1175
1807
  const composite = () => {
1176
1808
  let instances = visible.length;
1177
- for (const node of visible)
1178
- instances += node.tiles.length + (node.preview ? 1 : 0) + (selected.has(node) ? 1 : 0);
1809
+ for (const node of visible) instances += node.tiles.length + (node.preview ? 1 : 0);
1810
+ instances += selection.size + 1;
1179
1811
  renderer.reserve(instances);
1180
1812
  const data = renderer.instances;
1181
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
+ };
1182
1822
  const push = (rect, slot) => {
1823
+ startRun(null);
1824
+ runs[runs.length - 1].count++;
1183
1825
  const offset = count++ * FLOATS_PER_INSTANCE;
1184
1826
  data[offset] = rect.x;
1185
1827
  data[offset + 1] = rect.y;
@@ -1196,11 +1838,40 @@ async function createGpuCanvas(host, options) {
1196
1838
  } else {
1197
1839
  data[offset + 9] = 1;
1198
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);
1199
1861
  };
1200
- const pushRing = (rect) => {
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++;
1201
1867
  const offset = count++ * FLOATS_PER_INSTANCE;
1202
- data.set([rect.x, rect.y, rect.width, rect.height], offset);
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);
1203
1873
  data[offset + 9] = 2;
1874
+ data[offset + 10] = width;
1204
1875
  };
1205
1876
  let onThumbnail = 0;
1206
1877
  for (const node of visible) {
@@ -1214,6 +1885,15 @@ async function createGpuCanvas(host, options) {
1214
1885
  if (before === 3 && shown === 2) stats.lostDetail++;
1215
1886
  node.shown = shown;
1216
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
+ }
1217
1897
  push(node.rect, base);
1218
1898
  const current = (tile) => tile.scale === detailScale && !tile.stale;
1219
1899
  for (const tile of node.tiles)
@@ -1223,12 +1903,17 @@ async function createGpuCanvas(host, options) {
1223
1903
  if (current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
1224
1904
  }
1225
1905
  stats.onThumbnail = onThumbnail;
1226
- if (selected.size) {
1227
- for (const node of visible) if (selected.has(node)) pushRing(node.rect);
1228
- }
1906
+ for (const element of selection) pushRing(element, selectionColor, SELECTION_WIDTH);
1907
+ if (hovered && !selection.has(hovered)) pushRing(hovered, hoverColor, HOVER_WIDTH);
1229
1908
  const cssWidth = canvas.clientWidth;
1230
1909
  const dpr = canvas.width / Math.max(1, cssWidth);
1231
- 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
+ });
1232
1917
  };
1233
1918
  const frame = (inPaint) => {
1234
1919
  if (destroyed) return;
@@ -1272,7 +1957,7 @@ async function createGpuCanvas(host, options) {
1272
1957
  stats.visible = visible.length;
1273
1958
  stats.pending = pending;
1274
1959
  stats.frameMs = performance.now() - start;
1275
- if (pending > 0) schedule();
1960
+ if (pending > 0 || effectsMoving) schedule();
1276
1961
  };
1277
1962
  let awaitingPaint = false;
1278
1963
  canvas.onpaint = (event) => {
@@ -1287,6 +1972,7 @@ async function createGpuCanvas(host, options) {
1287
1972
  }
1288
1973
  }
1289
1974
  }
1975
+ if (changed) ringRects.clear();
1290
1976
  if (!awaitingPaint && !changed) return;
1291
1977
  awaitingPaint = false;
1292
1978
  frame(true);
@@ -1300,6 +1986,7 @@ async function createGpuCanvas(host, options) {
1300
1986
  return element ? nodes.get(element) : void 0;
1301
1987
  };
1302
1988
  const mutations = new MutationObserver((records) => {
1989
+ ringRects.clear();
1303
1990
  let changed = false;
1304
1991
  for (const record of records) {
1305
1992
  const target = record.target;
@@ -1382,13 +2069,9 @@ async function createGpuCanvas(host, options) {
1382
2069
  };
1383
2070
  const pickLive = () => {
1384
2071
  if (!pointer) return setLive(null);
1385
- const worldX = camera.x + pointer.x / camera.zoom;
1386
- const worldY = camera.y + pointer.y / camera.zoom;
2072
+ const point = screenToWorld(camera, pointer);
1387
2073
  let hit = null;
1388
- for (const node of nodes.values()) {
1389
- const { x, y, width, height } = node.rect;
1390
- if (worldX >= x && worldX < x + width && worldY >= y && worldY < y + height) hit = node;
1391
- }
2074
+ for (const node of nodes.values()) if (containsPoint(node.rect, point)) hit = node;
1392
2075
  setLive(hit);
1393
2076
  };
1394
2077
  const onPointerMove = (event) => {
@@ -1400,6 +2083,17 @@ async function createGpuCanvas(host, options) {
1400
2083
  pointer = null;
1401
2084
  setLive(null);
1402
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
+ };
1403
2097
  const onFocusIn = (event) => {
1404
2098
  const node = nodeOf(event.target) ?? null;
1405
2099
  if (node === focused) return;
@@ -1438,6 +2132,7 @@ async function createGpuCanvas(host, options) {
1438
2132
  document.fonts.addEventListener("loadingdone", onFontsLoaded);
1439
2133
  surface.addEventListener("pointermove", onPointerMove);
1440
2134
  surface.addEventListener("pointerleave", onPointerLeave);
2135
+ surface.addEventListener("pointerdown", onPointerDown, true);
1441
2136
  canvas.addEventListener("focusin", onFocusIn);
1442
2137
  canvas.addEventListener("focusout", onFocusOut);
1443
2138
  const usedAt = /* @__PURE__ */ new WeakMap();
@@ -1599,6 +2294,7 @@ async function createGpuCanvas(host, options) {
1599
2294
  if (!node) return;
1600
2295
  const resized = node.rect.width !== rect.width || node.rect.height !== rect.height;
1601
2296
  node.rect = rect;
2297
+ ringRects.clear();
1602
2298
  if (resized) {
1603
2299
  if (live === node) setLive(null);
1604
2300
  releaseNode(node);
@@ -1613,7 +2309,8 @@ async function createGpuCanvas(host, options) {
1613
2309
  if (lingering === node) lingering = null;
1614
2310
  if (focused === node) focused = null;
1615
2311
  transitioning.delete(node);
1616
- selected.delete(node);
2312
+ effects.delete(element);
2313
+ selection.delete(element);
1617
2314
  nodes.delete(element);
1618
2315
  releaseNode(node);
1619
2316
  element.remove();
@@ -1628,14 +2325,37 @@ async function createGpuCanvas(host, options) {
1628
2325
  for (const node of nodes.values()) if (intersects(node.rect, rect)) hits.push(node.element);
1629
2326
  return hits;
1630
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
+ },
1631
2338
  setSelection(elements) {
1632
- selected.clear();
1633
- for (const element of elements) {
1634
- const node = nodes.get(element);
1635
- if (node) selected.add(node);
1636
- }
2339
+ selection = new Set(elements);
2340
+ ringRects.clear();
1637
2341
  schedule();
1638
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),
1639
2359
  getCamera: () => camera,
1640
2360
  setCamera,
1641
2361
  invalidate: onFontsLoaded,
@@ -1677,6 +2397,7 @@ async function createGpuCanvas(host, options) {
1677
2397
  canvas.removeEventListener("load", onResourceLoad, true);
1678
2398
  surface.removeEventListener("pointermove", onPointerMove);
1679
2399
  surface.removeEventListener("pointerleave", onPointerLeave);
2400
+ surface.removeEventListener("pointerdown", onPointerDown, true);
1680
2401
  canvas.removeEventListener("focusin", onFocusIn);
1681
2402
  canvas.removeEventListener("focusout", onFocusOut);
1682
2403
  canvas.removeEventListener("transitionrun", onTransitionRun);
@@ -1692,6 +2413,689 @@ async function createGpuCanvas(host, options) {
1692
2413
  };
1693
2414
  }
1694
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
+
1695
3099
  // src/index.ts
1696
3100
  async function createGpuCanvas2(host, options = {}) {
1697
3101
  const mode = options.mode ?? "auto";
@@ -1707,9 +3111,14 @@ async function createGpuCanvas2(host, options = {}) {
1707
3111
  }
1708
3112
 
1709
3113
  export {
3114
+ screenToWorld,
3115
+ worldToScreen,
1710
3116
  zoomAt,
1711
3117
  fitCamera,
3118
+ defineEffect,
3119
+ EFFECTS,
1712
3120
  supportsGpuCanvas,
3121
+ bindEditor,
1713
3122
  createGpuCanvas2 as createGpuCanvas
1714
3123
  };
1715
- //# sourceMappingURL=chunk-VOFDBN5J.js.map
3124
+ //# sourceMappingURL=chunk-RBXGH44N.js.map