gpucanvas 0.4.3 → 0.4.5

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.
@@ -7,6 +7,11 @@ var worldToScreen = (camera, point) => ({
7
7
  x: (point.x - camera.x) * camera.zoom,
8
8
  y: (point.y - camera.y) * camera.zoom
9
9
  });
10
+ var rectToScreen = (camera, rect) => ({
11
+ ...worldToScreen(camera, rect),
12
+ width: rect.width * camera.zoom,
13
+ height: rect.height * camera.zoom
14
+ });
10
15
  var MIN_ZOOM = 0.02;
11
16
  var MAX_ZOOM = 16;
12
17
  var clampZoom = (zoom) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
@@ -24,8 +29,7 @@ function visibleWorldRect(camera, viewportWidth, viewportHeight) {
24
29
  height: viewportHeight / camera.zoom
25
30
  };
26
31
  }
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;
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;
32
+ var viewOf = (canvas) => visibleWorldRect(canvas.getCamera(), canvas.surface.clientWidth, canvas.surface.clientHeight);
29
33
  function fitCamera(bounds, viewportWidth, viewportHeight, padding = 40) {
30
34
  const zoom = clampZoom(
31
35
  Math.min(
@@ -39,14 +43,61 @@ function fitCamera(bounds, viewportWidth, viewportHeight, padding = 40) {
39
43
  zoom
40
44
  };
41
45
  }
46
+
47
+ // src/elements.ts
48
+ function rectInNode(node, nodeRect, element) {
49
+ if (element === node) return { ...nodeRect };
50
+ const outer = node.getBoundingClientRect();
51
+ const inner = element.getBoundingClientRect();
52
+ const scale = outer.width > 0 ? nodeRect.width / outer.width : 1;
53
+ return {
54
+ x: nodeRect.x + (inner.left - outer.left) * scale,
55
+ y: nodeRect.y + (inner.top - outer.top) * scale,
56
+ width: inner.width * scale,
57
+ height: inner.height * scale
58
+ };
59
+ }
60
+ function elementInNode(node, nodeRect, point) {
61
+ const outer = node.getBoundingClientRect();
62
+ const scale = nodeRect.width > 0 ? outer.width / nodeRect.width : 1;
63
+ const x = outer.left + (point.x - nodeRect.x) * scale;
64
+ const y = outer.top + (point.y - nodeRect.y) * scale;
65
+ let hit = node;
66
+ descend: for (; ; ) {
67
+ for (let child = hit.lastElementChild; child; child = child.previousElementSibling) {
68
+ const box = child.getBoundingClientRect();
69
+ if (box.width > 0 && box.height > 0 && x >= box.left && x < box.right && y >= box.top && y < box.bottom) {
70
+ hit = child;
71
+ continue descend;
72
+ }
73
+ }
74
+ return hit;
75
+ }
76
+ }
77
+ function nodeOfElement(nodes, element) {
78
+ for (let at = element; at; at = at.parentElement) {
79
+ if (nodes.has(at)) return at;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // src/input.ts
85
+ var markSurface = (element) => {
86
+ element.dataset.gpucanvasSurface = "";
87
+ };
88
+ var isSurface = (target) => target instanceof Element && target.hasAttribute("data-gpucanvas-surface");
89
+ var pointIn = (element, event) => {
90
+ const bounds = element.getBoundingClientRect();
91
+ return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
92
+ };
42
93
  function bindCameraInput(element, getCamera, onChange) {
43
94
  const onWheel = (event) => {
44
95
  event.preventDefault();
45
- const bounds = element.getBoundingClientRect();
46
96
  const camera = getCamera();
47
97
  if (event.ctrlKey || event.metaKey) {
48
98
  const factor = Math.exp(-event.deltaY * (event.deltaMode === 1 ? 0.05 : 0.01));
49
- onChange(zoomAt(camera, event.clientX - bounds.left, event.clientY - bounds.top, factor));
99
+ const at = pointIn(element, event);
100
+ onChange(zoomAt(camera, at.x, at.y, factor));
50
101
  } else {
51
102
  onChange({
52
103
  ...camera,
@@ -76,8 +127,7 @@ function bindCameraInput(element, getCamera, onChange) {
76
127
  }
77
128
  }
78
129
  if (event.button !== 0 && event.button !== 1 || event.shiftKey) return;
79
- if (event.target !== element && !event.target.hasAttribute?.("data-gpucanvas-surface"))
80
- return;
130
+ if (!isSurface(event.target)) return;
81
131
  drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
82
132
  element.setPointerCapture(event.pointerId);
83
133
  };
@@ -89,13 +139,8 @@ function bindCameraInput(element, getCamera, onChange) {
89
139
  touch.y = event.clientY;
90
140
  const after = pinch();
91
141
  if (!before.distance) return;
92
- const bounds = element.getBoundingClientRect();
93
- const zoomed = zoomAt(
94
- getCamera(),
95
- before.x - bounds.left,
96
- before.y - bounds.top,
97
- after.distance / before.distance
98
- );
142
+ const at = pointIn(element, { clientX: before.x, clientY: before.y });
143
+ const zoomed = zoomAt(getCamera(), at.x, at.y, after.distance / before.distance);
99
144
  onChange({
100
145
  ...zoomed,
101
146
  x: zoomed.x - (after.x - before.x) / zoomed.zoom,
@@ -135,6 +180,130 @@ function bindCameraInput(element, getCamera, onChange) {
135
180
  };
136
181
  }
137
182
 
183
+ // src/rect.ts
184
+ var center = (rect) => ({
185
+ x: rect.x + rect.width / 2,
186
+ y: rect.y + rect.height / 2
187
+ });
188
+ var containsPoint = (rect, point) => point.x >= rect.x && point.x < rect.x + rect.width && point.y >= rect.y && point.y < rect.y + rect.height;
189
+ 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;
190
+ var intersection = (a, b) => {
191
+ const x = Math.max(a.x, b.x);
192
+ const y = Math.max(a.y, b.y);
193
+ const right = Math.min(a.x + a.width, b.x + b.width);
194
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
195
+ return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
196
+ };
197
+ var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
198
+ function unionOf(rects) {
199
+ let only = null;
200
+ let count = 0;
201
+ let minX = Infinity;
202
+ let minY = Infinity;
203
+ let maxX = -Infinity;
204
+ let maxY = -Infinity;
205
+ for (const rect of rects) {
206
+ if (!rect) continue;
207
+ only = rect;
208
+ count++;
209
+ minX = Math.min(minX, rect.x);
210
+ minY = Math.min(minY, rect.y);
211
+ maxX = Math.max(maxX, rect.x + rect.width);
212
+ maxY = Math.max(maxY, rect.y + rect.height);
213
+ }
214
+ if (count < 2) return only;
215
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
216
+ }
217
+ 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;
218
+
219
+ // src/types.ts
220
+ var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
221
+ var DEFAULT_SELECTION_COLOR = "#4f8cff";
222
+ var SURFACE_Z = { marks: "1", marquee: "1", overlay: "2" };
223
+ var SELECTION_WIDTH = 2;
224
+ var HOVER_WIDTH = 1;
225
+
226
+ // src/base.ts
227
+ var styleNode = (element) => Object.assign(element.style, { boxSizing: "border-box", contain: "strict", overflow: "hidden" });
228
+ function createBase(host, options, onCamera, hooks) {
229
+ const surface = document.createElement("div");
230
+ markSurface(surface);
231
+ Object.assign(surface.style, {
232
+ position: "relative",
233
+ width: "100%",
234
+ height: "100%",
235
+ overflow: "hidden",
236
+ touchAction: "none"
237
+ });
238
+ host.append(surface);
239
+ const nodes = /* @__PURE__ */ new Map();
240
+ let camera = options.camera ?? DEFAULT_CAMERA;
241
+ const setCamera = (next) => {
242
+ camera = next;
243
+ onCamera(camera);
244
+ options.onCameraChange?.(camera);
245
+ };
246
+ const unbindInput = bindCameraInput(surface, () => camera, setCamera);
247
+ const nodeAt = (point) => {
248
+ let top = null;
249
+ for (const node of nodes.values()) if (containsPoint(node.rect, point)) top = node;
250
+ return top;
251
+ };
252
+ const api = {
253
+ surface,
254
+ addNode(element, rect) {
255
+ if (nodes.has(element)) return api.updateNode(element, rect);
256
+ styleNode(element);
257
+ nodes.set(element, hooks.add(element, rect));
258
+ },
259
+ updateNode(element, rect) {
260
+ const node = nodes.get(element);
261
+ if (node) hooks.update(node, rect);
262
+ },
263
+ removeNode(element) {
264
+ const node = nodes.get(element);
265
+ if (!node) return;
266
+ hooks.remove(node);
267
+ nodes.delete(element);
268
+ element.remove();
269
+ },
270
+ getNodeRect: (element) => {
271
+ const node = nodes.get(element);
272
+ return node ? { ...node.rect } : null;
273
+ },
274
+ getNodesIn(rect) {
275
+ const hits = [];
276
+ for (const node of nodes.values()) if (intersects(node.rect, rect)) hits.push(node.element);
277
+ return hits;
278
+ },
279
+ getNodeOf: (element) => nodeOfElement(nodes, element),
280
+ getElementAt(point) {
281
+ const top = nodeAt(point);
282
+ return top ? elementInNode(top.element, top.rect, point) : null;
283
+ },
284
+ getCamera: () => camera,
285
+ setCamera,
286
+ screenToWorld: (point) => screenToWorld(camera, point),
287
+ worldToScreen: (point) => worldToScreen(camera, point),
288
+ fit(padding) {
289
+ const bounds = unionOf([...nodes.values()].map((node) => node.rect));
290
+ if (bounds) setCamera(fitCamera(bounds, surface.clientWidth, surface.clientHeight, padding));
291
+ }
292
+ };
293
+ return {
294
+ surface,
295
+ nodes,
296
+ camera: () => camera,
297
+ view: () => viewOf(api),
298
+ nodeAt,
299
+ api,
300
+ destroy() {
301
+ unbindInput();
302
+ surface.remove();
303
+ }
304
+ };
305
+ }
306
+
138
307
  // src/effects.ts
139
308
  var defineEffect = (definition) => Object.freeze({
140
309
  animated: false,
@@ -518,84 +687,43 @@ var EFFECTS = {
518
687
  grayscale
519
688
  };
520
689
 
521
- // src/elements.ts
522
- function rectInNode(node, nodeRect, element) {
523
- if (element === node) return { ...nodeRect };
524
- const outer = node.getBoundingClientRect();
525
- const inner = element.getBoundingClientRect();
526
- const scale = outer.width > 0 ? nodeRect.width / outer.width : 1;
527
- return {
528
- x: nodeRect.x + (inner.left - outer.left) * scale,
529
- y: nodeRect.y + (inner.top - outer.top) * scale,
530
- width: inner.width * scale,
531
- height: inner.height * scale
532
- };
533
- }
534
- function elementInNode(node, nodeRect, point) {
535
- const outer = node.getBoundingClientRect();
536
- const scale = nodeRect.width > 0 ? outer.width / nodeRect.width : 1;
537
- const x = outer.left + (point.x - nodeRect.x) * scale;
538
- const y = outer.top + (point.y - nodeRect.y) * scale;
539
- let hit = node;
540
- descend: for (; ; ) {
541
- for (let child = hit.lastElementChild; child; child = child.previousElementSibling) {
542
- const box = child.getBoundingClientRect();
543
- if (box.width > 0 && box.height > 0 && x >= box.left && x < box.right && y >= box.top && y < box.bottom) {
544
- hit = child;
545
- continue descend;
546
- }
547
- }
548
- return hit;
549
- }
550
- }
551
- function nodeOfElement(nodes, element) {
552
- for (let at = element; at; at = at.parentElement) {
553
- if (nodes.has(at)) return at;
554
- }
555
- return null;
556
- }
557
-
558
- // src/node-style.ts
559
- function applyNodeSize(element, rect) {
560
- element.style.width = `${rect.width}px`;
561
- element.style.height = `${rect.height}px`;
562
- }
563
- function applyNodeBase(element) {
564
- element.style.boxSizing = "border-box";
565
- element.style.contain = "strict";
566
- element.style.overflow = "hidden";
567
- }
568
- function boundsOf(rects) {
569
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
570
- for (const rect of rects) {
571
- minX = Math.min(minX, rect.x);
572
- minY = Math.min(minY, rect.y);
573
- maxX = Math.max(maxX, rect.x + rect.width);
574
- maxY = Math.max(maxY, rect.y + rect.height);
575
- }
576
- return minX === Infinity ? null : { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
577
- }
578
-
579
- // src/types.ts
580
- var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
581
- var DEFAULT_SELECTION_COLOR = "#4f8cff";
582
- var SELECTION_WIDTH = 2;
583
- var HOVER_WIDTH = 1;
584
-
585
690
  // src/dom-backend.ts
586
691
  function createDomCanvas(host, options) {
587
- const surface = document.createElement("div");
588
- surface.dataset.gpucanvasSurface = "";
589
- Object.assign(surface.style, {
590
- position: "relative",
591
- width: "100%",
592
- height: "100%",
593
- overflow: "hidden",
594
- touchAction: "none",
595
- background: options.background ?? "transparent"
692
+ let frameMs = 0;
693
+ const apply = (camera) => {
694
+ const start = performance.now();
695
+ world.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
696
+ world.style.setProperty("--gpucanvas-zoom", String(camera.zoom));
697
+ frameMs = performance.now() - start;
698
+ };
699
+ const place = (element, rect) => {
700
+ element.style.left = `${rect.x}px`;
701
+ element.style.top = `${rect.y}px`;
702
+ element.style.width = `${rect.width}px`;
703
+ element.style.height = `${rect.height}px`;
704
+ };
705
+ const base = createBase(host, options, apply, {
706
+ add(element, rect) {
707
+ element.style.position = "absolute";
708
+ place(element, rect);
709
+ world.append(element);
710
+ return { element, rect };
711
+ },
712
+ update(node, rect) {
713
+ node.rect = rect;
714
+ place(node.element, rect);
715
+ },
716
+ remove({ element }) {
717
+ clearEffect(element);
718
+ selection.delete(element);
719
+ outlinedNodes.delete(element);
720
+ element.style.outline = "";
721
+ }
596
722
  });
723
+ const { surface, nodes } = base;
724
+ surface.style.background = options.background ?? "transparent";
597
725
  const world = document.createElement("div");
598
- world.dataset.gpucanvasSurface = "";
726
+ markSurface(world);
599
727
  Object.assign(world.style, {
600
728
  position: "absolute",
601
729
  left: "0",
@@ -607,8 +735,6 @@ function createDomCanvas(host, options) {
607
735
  Object.assign(overlay.style, { position: "absolute", left: "0", top: "0", zIndex: "1" });
608
736
  world.append(overlay);
609
737
  surface.append(world);
610
- host.append(surface);
611
- const nodes = /* @__PURE__ */ new Map();
612
738
  const ring = (color, width) => `calc(${width}px / var(--gpucanvas-zoom)) solid ${color}`;
613
739
  const selectionColor = options.selectionColor ?? DEFAULT_SELECTION_COLOR;
614
740
  const selectionRing = ring(selectionColor, SELECTION_WIDTH);
@@ -619,7 +745,7 @@ function createDomCanvas(host, options) {
619
745
  const boxes = /* @__PURE__ */ new Map();
620
746
  const elementRect = (element) => {
621
747
  const node = element.isConnected ? nodeOfElement(nodes, element) : null;
622
- return node ? rectInNode(node, nodes.get(node), element) : null;
748
+ return node ? rectInNode(node, nodes.get(node).rect, element) : null;
623
749
  };
624
750
  let boxFrame = 0;
625
751
  const placeBoxes = () => {
@@ -693,55 +819,11 @@ function createDomCanvas(host, options) {
693
819
  element.animate(keyframes, { duration: period * 1e3, iterations: Infinity })
694
820
  );
695
821
  };
696
- let camera = options.camera ?? DEFAULT_CAMERA;
697
- let frameMs = 0;
698
- const apply = () => {
699
- const start = performance.now();
700
- world.style.transform = `scale(${camera.zoom}) translate(${-camera.x}px, ${-camera.y}px)`;
701
- world.style.setProperty("--gpucanvas-zoom", String(camera.zoom));
702
- frameMs = performance.now() - start;
703
- };
704
- const setCamera = (next) => {
705
- camera = next;
706
- apply();
707
- options.onCameraChange?.(camera);
708
- };
709
- const unbindInput = bindCameraInput(surface, () => camera, setCamera);
710
- apply();
822
+ apply(base.camera());
711
823
  return {
824
+ ...base.api,
712
825
  mode: "dom",
713
- surface,
714
- addNode(element, rect) {
715
- applyNodeBase(element);
716
- element.style.position = "absolute";
717
- nodes.set(element, rect);
718
- this.updateNode(element, rect);
719
- world.append(element);
720
- },
721
- updateNode(element, rect) {
722
- nodes.set(element, rect);
723
- element.style.left = `${rect.x}px`;
724
- element.style.top = `${rect.y}px`;
725
- applyNodeSize(element, rect);
726
- },
727
- removeNode(element) {
728
- clearEffect(element);
729
- nodes.delete(element);
730
- outlinedNodes.delete(element);
731
- element.style.outline = "";
732
- element.remove();
733
- },
734
- getNodeRect: (element) => nodes.get(element) ?? null,
735
- getNodesIn(rect) {
736
- return [...nodes].filter(([, node]) => intersects(node, rect)).map(([element]) => element);
737
- },
738
- getNodeOf: (element) => nodeOfElement(nodes, element),
739
826
  getElementRect: elementRect,
740
- getElementAt(point) {
741
- let top = null;
742
- for (const [element, rect] of nodes) if (containsPoint(rect, point)) top = element;
743
- return top ? elementInNode(top, nodes.get(top), point) : null;
744
- },
745
827
  setSelection(elements) {
746
828
  selection = new Set(elements);
747
829
  drawOutlines();
@@ -754,10 +836,6 @@ function createDomCanvas(host, options) {
754
836
  setEffect(element, effect, params) {
755
837
  if (nodes.has(element)) applyEffect(element, effect, params);
756
838
  },
757
- getCamera: () => camera,
758
- setCamera,
759
- screenToWorld: (point) => screenToWorld(camera, point),
760
- worldToScreen: (point) => worldToScreen(camera, point),
761
839
  // The browser repaints DOM nodes itself.
762
840
  invalidate() {
763
841
  },
@@ -765,14 +843,10 @@ function createDomCanvas(host, options) {
765
843
  setBackground(color) {
766
844
  surface.style.background = color;
767
845
  },
768
- fit(padding) {
769
- const bounds = boundsOf(nodes.values());
770
- if (bounds) setCamera(fitCamera(bounds, surface.clientWidth, surface.clientHeight, padding));
771
- },
772
846
  getStats() {
773
- const view = visibleWorldRect(camera, surface.clientWidth, surface.clientHeight);
847
+ const view = base.view();
774
848
  let visible = 0;
775
- for (const rect of nodes.values()) if (intersects(rect, view)) visible++;
849
+ for (const node of nodes.values()) if (intersects(node.rect, view)) visible++;
776
850
  return {
777
851
  mode: "dom",
778
852
  nodes: nodes.size,
@@ -786,28 +860,62 @@ function createDomCanvas(host, options) {
786
860
  },
787
861
  destroy() {
788
862
  cancelAnimationFrame(boxFrame);
789
- unbindInput();
790
- surface.remove();
863
+ base.destroy();
791
864
  }
792
865
  };
793
866
  }
794
867
 
795
- // src/atlas.ts
796
- var PADDING = 1;
797
- var Atlas = class {
798
- constructor(device, size, layerCount) {
799
- this.device = device;
800
- this.size = size;
801
- this.texture = device.createTexture({
802
- label: "gpucanvas atlas",
803
- size: [size, size, layerCount],
804
- format: "rgba8unorm",
805
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
868
+ // src/shelves.ts
869
+ var Shelves = class {
870
+ constructor(width, height, tolerance) {
871
+ this.width = width;
872
+ this.height = height;
873
+ this.tolerance = tolerance;
874
+ }
875
+ width;
876
+ height;
877
+ tolerance;
878
+ shelves = [];
879
+ /** Height taken by rows so far. */
880
+ filled = 0;
881
+ /** Top left corner of a free `w` x `h` box, or null when it does not fit. */
882
+ place(w, h) {
883
+ if (w > this.width || h > this.height) return null;
884
+ let shelf = this.shelves.find(
885
+ (candidate) => candidate.height >= h && candidate.height <= h * this.tolerance && candidate.x + w <= this.width
886
+ );
887
+ if (!shelf) {
888
+ if (this.filled + h > this.height) return null;
889
+ shelf = { x: 0, y: this.filled, height: h };
890
+ this.shelves.push(shelf);
891
+ this.filled += h;
892
+ }
893
+ const at = { x: shelf.x, y: shelf.y };
894
+ shelf.x += w;
895
+ return at;
896
+ }
897
+ clear() {
898
+ this.shelves = [];
899
+ this.filled = 0;
900
+ }
901
+ };
902
+
903
+ // src/atlas.ts
904
+ var PADDING = 1;
905
+ var Atlas = class {
906
+ constructor(device, size, layerCount, keep) {
907
+ this.device = device;
908
+ this.keep = keep;
909
+ this.size = size;
910
+ this.texture = device.createTexture({
911
+ label: "gpucanvas atlas",
912
+ size: [size, size, layerCount],
913
+ format: "rgba8unorm",
914
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
806
915
  });
807
916
  this.view = this.texture.createView({ dimension: "2d-array" });
808
917
  this.layers = Array.from({ length: layerCount }, () => ({
809
- shelves: [],
810
- nextY: 0,
918
+ shelves: new Shelves(size, size, 1.5),
811
919
  generation: 0,
812
920
  lastUsed: -1,
813
921
  lastAllocated: -1
@@ -815,25 +923,21 @@ var Atlas = class {
815
923
  this.spare = layerCount - 1;
816
924
  }
817
925
  device;
926
+ keep;
818
927
  texture;
819
928
  view;
820
929
  size;
821
- /** Slots to rescue when their layer is recycled, most important first. */
822
- keep = () => [];
823
930
  layers;
824
931
  spare;
825
932
  frame = 0;
826
933
  /** The layer `allocatePinned` fills; it is never recycled. */
827
934
  pinned = 0;
828
- get layerCount() {
829
- return this.layers.length;
830
- }
831
935
  get usedLayers() {
832
- return this.layers.filter((layer, index) => index !== this.spare && layer.nextY > 0).length;
936
+ return this.layers.filter((layer, index) => index !== this.spare && layer.shelves.filled > 0).length;
833
937
  }
834
938
  /** How much of the pinned layer is taken, 0 to 1. */
835
939
  get pinnedFill() {
836
- return this.layers[this.pinned].nextY / this.size;
940
+ return this.layers[this.pinned].shelves.filled / this.size;
837
941
  }
838
942
  nextFrame() {
839
943
  this.frame++;
@@ -909,32 +1013,23 @@ var Atlas = class {
909
1013
  }
910
1014
  allocateIn(index, w, h) {
911
1015
  const layer = this.layers[index];
912
- let shelf = layer.shelves.find(
913
- (candidate) => candidate.height >= h && candidate.height <= h * 1.5 && candidate.x + w <= this.size
914
- );
915
- if (!shelf) {
916
- if (layer.nextY + h > this.size) return null;
917
- shelf = { y: layer.nextY, height: h, x: 0 };
918
- layer.shelves.push(shelf);
919
- layer.nextY += h;
920
- }
1016
+ const at = layer.shelves.place(w, h);
1017
+ if (!at) return null;
921
1018
  const slot = {
922
1019
  layer: index,
923
- x: shelf.x,
924
- y: shelf.y,
1020
+ x: at.x,
1021
+ y: at.y,
925
1022
  width: w - PADDING,
926
1023
  height: h - PADDING,
927
1024
  generation: layer.generation
928
1025
  };
929
- shelf.x += w;
930
1026
  layer.lastUsed = this.frame;
931
1027
  layer.lastAllocated = this.frame;
932
1028
  return slot;
933
1029
  }
934
1030
  evict(index) {
935
1031
  const layer = this.layers[index];
936
- layer.shelves = [];
937
- layer.nextY = 0;
1032
+ layer.shelves.clear();
938
1033
  layer.generation++;
939
1034
  }
940
1035
  destroy() {
@@ -942,353 +1037,354 @@ var Atlas = class {
942
1037
  }
943
1038
  };
944
1039
 
945
- // src/composite-shader.ts
946
- var prelude = (
947
- /* wgsl */
948
- `
949
- struct View {
950
- size: vec2f, // canvas size in CSS pixels
951
- origin: vec2f, // camera position, world units
952
- zoom: f32,
953
- dpr: f32,
954
- atlasSize: f32,
955
- time: f32, // seconds since the canvas started
956
- placeholder: vec4f, // premultiplied color for nodes without a snapshot
957
- pointer: vec4f, // pointer and last press in world units; far away when there is none
958
- pressTime: f32, // seconds since the last press
1040
+ // src/gpu-node.ts
1041
+ var THUMBNAIL_SCALE = 1 / 16;
1042
+ var SMALL_NODE_PX = 200;
1043
+ var SMALL_REFRESH_MS = 250;
1044
+ var smallOnScreen = (node, zoom) => Math.max(node.rect.width, node.rect.height) * zoom < SMALL_NODE_PX;
1045
+ var nodeOf = (nodes, target) => {
1046
+ const element = target instanceof Element ? target : target.parentElement;
1047
+ const node = element && nodeOfElement(nodes, element);
1048
+ return node ? nodes.get(node) : void 0;
1049
+ };
1050
+ var texels = (length, scale) => Math.max(1, Math.round(length * scale));
1051
+ var nodeRegion = (node) => ({
1052
+ x: node.pageX,
1053
+ y: node.pageY,
1054
+ width: node.rect.width,
1055
+ height: node.rect.height
1056
+ });
1057
+ var createGpuNode = (element, rect, at) => ({
1058
+ element,
1059
+ rect,
1060
+ page: at.page,
1061
+ pageX: at.x,
1062
+ pageY: at.y,
1063
+ slot: null,
1064
+ slotScale: 0,
1065
+ thumb: null,
1066
+ thumbStale: false,
1067
+ thumbAt: -Infinity,
1068
+ shown: 0,
1069
+ shownFrame: -1,
1070
+ dirty: true,
1071
+ tiles: [],
1072
+ preview: null,
1073
+ previewRect: null,
1074
+ previewScale: 0,
1075
+ previewTarget: null,
1076
+ previewTargetScale: 0,
1077
+ changedAt: -Infinity,
1078
+ refreshedAt: -Infinity,
1079
+ wholeStale: false,
1080
+ solo: false,
1081
+ targetScale: 0,
1082
+ priority: 0
1083
+ });
1084
+ function placeNode(node, at) {
1085
+ node.page = at.page;
1086
+ node.pageX = at.x;
1087
+ node.pageY = at.y;
1088
+ node.slot = null;
1089
+ node.thumb = null;
1090
+ node.tiles = [];
1091
+ node.preview = null;
1092
+ node.solo = false;
1093
+ node.dirty = true;
959
1094
  }
960
-
961
- @group(0) @binding(0) var<uniform> view: View;
962
- @group(0) @binding(1) var atlas: texture_2d_array<f32>;
963
- @group(0) @binding(2) var atlasSampler: sampler;
964
-
965
- struct Instance {
966
- @location(0) rect: vec4f, // world x, y, width, height
967
- @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1; for a ring, its premultiplied color
968
- @location(2) info: vec4f, // layer, kind (0 snapshot, 1 placeholder, 2 ring), ring width or effect pad
969
- @location(3) params: vec4f, // an effect's parameters
1095
+ function translateNode(node, rect) {
1096
+ const dx = rect.x - node.rect.x;
1097
+ const dy = rect.y - node.rect.y;
1098
+ const shift = (r) => ({ ...r, x: r.x + dx, y: r.y + dy });
1099
+ for (const tile of node.tiles) tile.rect = shift(tile.rect);
1100
+ if (node.previewRect) node.previewRect = shift(node.previewRect);
1101
+ if (node.previewTarget) node.previewTarget = shift(node.previewTarget);
1102
+ node.rect = rect;
970
1103
  }
971
-
972
- struct Varyings {
973
- @builtin(position) position: vec4f,
974
- @location(0) texel: vec2f,
975
- @location(1) @interpolate(flat) bounds: vec4f,
976
- @location(2) @interpolate(flat) info: vec2f,
977
- @location(3) @interpolate(flat) color: vec4f,
978
- @location(4) uv: vec2f, // 0 to 1 across the node, past that in an effect's padding
979
- @location(5) @interpolate(flat) size: vec2f,
980
- @location(6) @interpolate(flat) params: vec4f,
981
- @location(7) @interpolate(flat) corner: vec2f, // the node's top left in world units
1104
+ function setThumb(node, slot, now) {
1105
+ node.thumb = slot;
1106
+ node.thumbStale = false;
1107
+ node.thumbAt = now;
982
1108
  }
983
-
984
- @vertex
985
- fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
986
- let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
987
- // A ring's quad reaches past its element by the ring's width, an effect's by its padding.
988
- let ring = instance.info.y > 1.5;
989
- let grow = select(instance.info.z, instance.info.z / view.zoom, ring);
990
- let start = instance.rect.xy - grow;
991
- let end = instance.rect.xy + instance.rect.zw + grow;
992
- // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
993
- let scale = view.zoom * view.dpr;
994
- let topLeft = round((start - view.origin) * scale) / view.dpr;
995
- let bottomRight = round((end - view.origin) * scale) / view.dpr;
996
- let point = mix(topLeft, bottomRight, corner);
997
- var out: Varyings;
998
- 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);
999
- if (ring) {
1000
- // World units from the element's top left, and the element's own edges.
1001
- out.texel = mix(vec2f(-grow), instance.rect.zw + grow, corner);
1002
- out.bounds = vec4f(0.0, 0.0, instance.rect.zw);
1003
- out.color = instance.uv;
1004
- } else {
1005
- let pad = grow * (instance.uv.zw - instance.uv.xy) / instance.rect.zw;
1006
- out.texel = mix(instance.uv.xy - pad, instance.uv.zw + pad, corner);
1007
- out.color = instance.uv;
1008
- // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
1009
- // with whatever sits next to it in the atlas.
1010
- out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
1109
+ function recordWhole(node, slot, scale, now) {
1110
+ node.slot = slot;
1111
+ node.slotScale = scale;
1112
+ if (scale === THUMBNAIL_SCALE) setThumb(node, slot, now);
1113
+ else if (node.dirty) node.thumbStale = true;
1114
+ if (node.dirty) {
1115
+ node.preview = null;
1116
+ node.refreshedAt = now;
1011
1117
  }
1012
- out.info = instance.info.xy;
1013
- out.uv = mix(-grow / instance.rect.zw, 1.0 + grow / instance.rect.zw, corner);
1014
- out.size = instance.rect.zw;
1015
- out.corner = instance.rect.xy;
1016
- out.params = instance.params;
1017
- return out;
1118
+ node.dirty = false;
1119
+ node.wholeStale = false;
1018
1120
  }
1019
- `
1020
- );
1021
- var compositeShader = (
1022
- /* wgsl */
1023
- `${prelude}
1024
- @fragment
1025
- fn fs(in: Varyings) -> @location(0) vec4f {
1026
- // Derivatives first: WGSL only allows them in uniform control flow.
1027
- let dx = dpdx(in.texel);
1028
- let dy = dpdy(in.texel);
1029
- if (in.info.y > 1.5) {
1030
- // The ring covers what lies outside the element; the element itself shows through.
1031
- let outside = max(in.bounds.xy - in.texel, in.texel - in.bounds.zw);
1032
- return select(vec4f(0.0), in.color, max(outside.x, outside.y) > 0.0);
1121
+ function recordPartial(node, now) {
1122
+ if (!node.dirty) return;
1123
+ node.dirty = false;
1124
+ node.wholeStale = true;
1125
+ node.thumbStale = true;
1126
+ node.refreshedAt = now;
1127
+ }
1128
+ var isCurrentTile = (tile, detailScale) => tile.scale === detailScale && !tile.stale;
1129
+ var hasFreshThumb = (atlas, node) => atlas.isValid(node.thumb) && !node.thumbStale;
1130
+
1131
+ // src/gpu-live.ts
1132
+ var USE_EVENTS = ["click", "input", "change", "keydown"];
1133
+ var USED_MS = 1e3;
1134
+ var LINGER_MS = 300;
1135
+ var CANVAS_TRANSFORM_HIT_TESTS = typeof HTMLCanvasElement !== "undefined" && "getElementImageDefaultSize" in HTMLCanvasElement.prototype;
1136
+ var FAR_AWAY = { x: -1e7, y: -1e7 };
1137
+ var Live = class {
1138
+ constructor(o) {
1139
+ this.o = o;
1140
+ const { canvas, surface } = o;
1141
+ surface.addEventListener("pointermove", this.onPointerMove);
1142
+ surface.addEventListener("pointerleave", this.onPointerLeave);
1143
+ surface.addEventListener("pointerdown", this.onPointerDown, true);
1144
+ surface.addEventListener("click", this.onClick);
1145
+ canvas.addEventListener("focusin", this.onFocusIn);
1146
+ canvas.addEventListener("focusout", this.onFocusOut);
1147
+ for (const type of USE_EVENTS) canvas.addEventListener(type, this.onUse, true);
1148
+ canvas.addEventListener("transitionrun", this.onTransitionRun);
1033
1149
  }
1034
- if (in.info.y > 0.5) {
1035
- return view.placeholder;
1150
+ o;
1151
+ /** The node under the pointer. */
1152
+ current = null;
1153
+ /** The pointer over the surface, in surface pixels. */
1154
+ pointer = null;
1155
+ /** Where the last press landed, in world units, and when: effects such as shockwaves use it. */
1156
+ press = FAR_AWAY;
1157
+ pressedAt = -Infinity;
1158
+ positioned = false;
1159
+ lingering = null;
1160
+ lingerTimer = 0;
1161
+ focused = null;
1162
+ /** Readable nodes in view with CSS transitions running, and when the last one ends. */
1163
+ transitioning = /* @__PURE__ */ new Map();
1164
+ usedAt = /* @__PURE__ */ new WeakMap();
1165
+ /**
1166
+ * Touch and pen press without hovering first, so the node under them is not hit-testable yet and
1167
+ * the tap lands on the canvas or its page. The press makes the node live, and a click that did
1168
+ * not reach inside it is handed to the element under the finger.
1169
+ */
1170
+ tapped = false;
1171
+ destroyed = false;
1172
+ /** Whether a held node keeps `page` drawable. */
1173
+ holds(page) {
1174
+ return this.held().some((node) => node?.page === page);
1036
1175
  }
1037
- let layer = i32(in.info.x);
1038
- // Texels covered by this pixel along each screen axis.
1039
- let footprint = vec2f(length(vec2f(dx.x, dy.x)), length(vec2f(dx.y, dy.y)));
1040
- let taps = vec2u(clamp(ceil(footprint), vec2f(1.0), vec2f(4.0)));
1041
- var color = vec4f(0.0);
1042
- for (var j = 0u; j < taps.y; j++) {
1043
- for (var i = 0u; i < taps.x; i++) {
1044
- let offset = (vec2f(f32(i), f32(j)) + 0.5) / vec2f(taps) - 0.5;
1045
- let texel = clamp(in.texel + offset * footprint, in.bounds.xy, in.bounds.zw);
1046
- color += textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, layer, 0.0);
1176
+ /** Nodes kept drawable and redrawn on every paint: under the pointer, focused, transitioning. */
1177
+ held() {
1178
+ return [this.current, this.lingering, this.focused, ...this.transitioning.keys()];
1179
+ }
1180
+ /** Moves the hit-test target to `node`: its page is registered and positioned under it. */
1181
+ set(node) {
1182
+ if (node === this.current) return;
1183
+ const { canvas, frameNumber, schedule } = this.o;
1184
+ const previous = this.current;
1185
+ this.current = node;
1186
+ if (node && node === this.lingering) {
1187
+ clearTimeout(this.lingerTimer);
1188
+ this.lingering = null;
1189
+ }
1190
+ if (previous) {
1191
+ previous.element.style.pointerEvents = "none";
1192
+ clearTimeout(this.lingerTimer);
1193
+ const earlier = this.lingering;
1194
+ this.lingering = previous;
1195
+ if (earlier && earlier !== previous) this.letGo(earlier);
1196
+ this.lingerTimer = window.setTimeout(() => {
1197
+ const last = this.lingering;
1198
+ if (!last) return;
1199
+ this.lingering = null;
1200
+ this.letGo(last);
1201
+ }, LINGER_MS);
1202
+ this.unpin(previous.page);
1203
+ }
1204
+ if (!node) return;
1205
+ const { page } = node;
1206
+ page.setDrawable(true, frameNumber());
1207
+ if (!page.registered) {
1208
+ canvas.updateElementGeometry(page.element, {});
1209
+ page.registered = true;
1047
1210
  }
1211
+ this.positioned = false;
1212
+ this.position();
1213
+ schedule();
1048
1214
  }
1049
- return color / f32(taps.x * taps.y);
1050
- }
1051
- `
1052
- );
1053
- var effectShader = (wgsl) => (
1054
- /* wgsl */
1055
- `${prelude}
1056
- struct EffectInput {
1057
- uv: vec2f,
1058
- size: vec2f,
1059
- time: f32,
1060
- zoom: f32,
1061
- params: vec4f,
1062
- pointer: vec2f,
1063
- press: vec2f,
1064
- sincePress: f32,
1065
- }
1066
-
1067
- var<private> sourceRect: vec4f;
1068
- var<private> sourceLayer: i32;
1069
-
1070
- fn source(uv: vec2f) -> vec4f {
1071
- if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) {
1072
- return vec4f(0.0);
1215
+ /** Makes the topmost node under the last pointer position live. */
1216
+ pick() {
1217
+ if (!this.pointer) return this.set(null);
1218
+ this.set(this.o.nodeAt(screenToWorld(this.o.camera(), this.pointer)));
1073
1219
  }
1074
- let texel = clamp(mix(sourceRect.xy, sourceRect.zw, uv), sourceRect.xy + 0.5, sourceRect.zw - 0.5);
1075
- return textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, sourceLayer, 0.0);
1076
- }
1077
-
1078
- fn hash(p: vec2f) -> f32 {
1079
- return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
1080
- }
1081
-
1082
- fn noise(p: vec2f) -> f32 {
1083
- let i = floor(p);
1084
- let f = fract(p);
1085
- let u = f * f * (3.0 - 2.0 * f);
1086
- return mix(
1087
- mix(hash(i), hash(i + vec2f(1.0, 0.0)), u.x),
1088
- mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x),
1089
- u.y,
1090
- );
1091
- }
1092
-
1093
- ${wgsl}
1094
-
1095
- @fragment
1096
- fn fs(in: Varyings) -> @location(0) vec4f {
1097
- sourceRect = in.color;
1098
- sourceLayer = i32(in.info.x);
1099
- let pointer = (view.pointer.xy - in.corner) / in.size;
1100
- let press = (view.pointer.zw - in.corner) / in.size;
1101
- return effect(
1102
- EffectInput(in.uv, in.size, view.time, view.zoom, in.params, pointer, press, view.pressTime),
1103
- );
1104
- }
1105
- `
1106
- );
1107
-
1108
- // src/gpu-renderer.ts
1109
- var FLOATS_PER_INSTANCE = 16;
1110
- var CompositeRenderer = class {
1111
- constructor(device, context, format, atlas, background, placeholder) {
1112
- this.device = device;
1113
- this.context = context;
1114
- this.atlas = atlas;
1115
- this.background = background;
1116
- this.placeholder = placeholder;
1117
- this.bindGroupLayout = device.createBindGroupLayout({
1118
- entries: [
1119
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: {} },
1120
- {
1121
- binding: 1,
1122
- visibility: GPUShaderStage.FRAGMENT,
1123
- texture: { viewDimension: "2d-array" }
1124
- },
1125
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }
1126
- ]
1127
- });
1128
- this.sampler = device.createSampler({ magFilter: "linear", minFilter: "linear" });
1129
- this.pipeline = device.createRenderPipeline(
1130
- this.pipelineDescriptor("gpucanvas composite", compositeShader, format)
1131
- );
1132
- this.format = format;
1133
- this.uniforms = device.createBuffer({
1134
- size: this.uniformData.byteLength,
1135
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1136
- });
1137
- this.bindGroup = device.createBindGroup({
1138
- layout: this.bindGroupLayout,
1139
- entries: [
1140
- { binding: 0, resource: { buffer: this.uniforms } },
1141
- { binding: 1, resource: atlas.view },
1142
- { binding: 2, resource: this.sampler }
1143
- ]
1144
- });
1145
- this.instanceBuffer = this.createInstanceBuffer();
1220
+ /**
1221
+ * Puts the live page's hit area under the live node. Chrome 157 hit tests at the canvas
1222
+ * transform given to `updateElementGeometry`, in CSS pixels. Chrome 156 ignores it and hit
1223
+ * tests at the CSS box, so the page gets the CSS transform `getElementTransform` derives from
1224
+ * the draw matrix, which maps the default-size snapshot (CSS px x dpr) to canvas pixels. A page
1225
+ * promoted this frame has no paint record there yet, so it is positioned on the next frame.
1226
+ */
1227
+ position() {
1228
+ const live = this.current;
1229
+ if (!live || this.positioned) return;
1230
+ const { canvas } = this.o;
1231
+ const camera = this.o.camera();
1232
+ const { zoom } = camera;
1233
+ const x = (live.rect.x - camera.x - live.pageX) * zoom;
1234
+ const y = (live.rect.y - camera.y - live.pageY) * zoom;
1235
+ try {
1236
+ if (CANVAS_TRANSFORM_HIT_TESTS) {
1237
+ canvas.updateElementGeometry(live.page.element, {
1238
+ canvasTransform: new DOMMatrix([zoom, 0, 0, zoom, x, y])
1239
+ });
1240
+ } else {
1241
+ const dpr = this.o.canvasDpr();
1242
+ const drawn = new DOMMatrix([zoom, 0, 0, zoom, x * dpr, y * dpr]);
1243
+ live.page.element.style.transform = canvas.getElementTransform(live.page.element, drawn).toString();
1244
+ }
1245
+ live.element.style.pointerEvents = "auto";
1246
+ this.positioned = true;
1247
+ } catch {
1248
+ this.o.schedule();
1249
+ }
1146
1250
  }
1147
- device;
1148
- context;
1149
- atlas;
1150
- background;
1151
- placeholder;
1152
- instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
1153
- instanceBuffer;
1154
- uniforms;
1155
- uniformData = new Float32Array(20);
1156
- pipeline;
1157
- bindGroup;
1158
- bindGroupLayout;
1159
- sampler;
1160
- /** Effect pipelines, null while one compiles. Compiling never blocks a frame. */
1161
- effects = /* @__PURE__ */ new Map();
1162
- /** Called when an effect pipeline is ready, so the caller can draw it. */
1163
- onEffectReady = () => {
1164
- };
1165
- format;
1166
- pipelineDescriptor(label, code, format) {
1167
- const module = this.device.createShaderModule({ label, code });
1168
- return {
1169
- label,
1170
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
1171
- vertex: {
1172
- module,
1173
- entryPoint: "vs",
1174
- buffers: [
1175
- {
1176
- arrayStride: FLOATS_PER_INSTANCE * 4,
1177
- stepMode: "instance",
1178
- attributes: [
1179
- { shaderLocation: 0, offset: 0, format: "float32x4" },
1180
- { shaderLocation: 1, offset: 16, format: "float32x4" },
1181
- { shaderLocation: 2, offset: 32, format: "float32x4" },
1182
- { shaderLocation: 3, offset: 48, format: "float32x4" }
1183
- ]
1184
- }
1185
- ]
1186
- },
1187
- fragment: {
1188
- module,
1189
- entryPoint: "fs",
1190
- targets: [
1191
- {
1192
- format,
1193
- // Snapshots are requested premultiplied.
1194
- blend: {
1195
- color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
1196
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }
1197
- }
1198
- }
1199
- ]
1200
- },
1201
- primitive: { topology: "triangle-strip" }
1202
- };
1203
- }
1204
- /** Whether `effect` can draw this frame. The first ask starts compiling it. */
1205
- hasEffect(effect) {
1206
- if (this.effects.has(effect)) return this.effects.get(effect) !== null;
1207
- this.effects.set(effect, null);
1208
- this.device.createRenderPipelineAsync(
1209
- this.pipelineDescriptor(
1210
- `gpucanvas effect ${effect.name}`,
1211
- effectShader(effect.wgsl),
1212
- this.format
1213
- )
1214
- ).then((pipeline) => {
1215
- this.effects.set(effect, pipeline);
1216
- this.onEffectReady();
1217
- }).catch((error) => console.error(`[gpucanvas] effect "${effect.name}" failed`, error));
1218
- return false;
1251
+ /** Puts the hit area back under `node` after it moved, when it is the live node. */
1252
+ moved(node) {
1253
+ if (node !== this.current) return;
1254
+ this.positioned = false;
1255
+ this.position();
1219
1256
  }
1220
- /** Grows the CPU and GPU instance storage to hold at least `count` instances. */
1221
- reserve(count) {
1222
- const floats = count * FLOATS_PER_INSTANCE;
1223
- if (this.instances.length >= floats) return;
1224
- this.instances = new Float32Array(Math.max(floats, this.instances.length * 2));
1225
- this.instanceBuffer.destroy();
1226
- this.instanceBuffer = this.createInstanceBuffer();
1257
+ /** Releases a page from hit testing once nothing pins it; it parks after its next raster. */
1258
+ unpin(page) {
1259
+ if (this.o.isPinned(page)) return;
1260
+ if (page.registered) this.o.canvas.clearElementGeometry(page.element);
1261
+ page.registered = false;
1262
+ page.element.style.transform = "";
1263
+ this.o.schedule();
1227
1264
  }
1228
- draw(camera, cssWidth, cssHeight, dpr, count, runs, effects) {
1229
- const { device } = this;
1230
- this.uniformData.set([
1231
- cssWidth,
1232
- cssHeight,
1233
- camera.x,
1234
- camera.y,
1235
- camera.zoom,
1236
- dpr,
1237
- this.atlas.size,
1238
- effects.time,
1239
- ...this.placeholder,
1240
- effects.pointer.x,
1241
- effects.pointer.y,
1242
- effects.press.x,
1243
- effects.press.y,
1244
- effects.sincePress
1245
- ]);
1246
- device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
1247
- if (count > 0) {
1248
- device.queue.writeBuffer(
1249
- this.instanceBuffer,
1250
- 0,
1251
- this.instances,
1252
- 0,
1253
- count * FLOATS_PER_INSTANCE
1254
- );
1255
- }
1256
- const [r, g, b] = this.background;
1257
- const encoder = device.createCommandEncoder();
1258
- const pass = encoder.beginRenderPass({
1259
- colorAttachments: [
1260
- {
1261
- view: this.context.getCurrentTexture().createView(),
1262
- clearValue: { r, g, b, a: 1 },
1263
- loadOp: "clear",
1264
- storeOp: "store"
1265
- }
1266
- ]
1267
- });
1268
- if (count > 0) {
1269
- pass.setBindGroup(0, this.bindGroup);
1270
- pass.setVertexBuffer(0, this.instanceBuffer);
1271
- let current = null;
1272
- for (const run of runs.length ? runs : [{ effect: null, start: 0, count }]) {
1273
- const pipeline = run.effect && this.effects.get(run.effect) || this.pipeline;
1274
- if (pipeline !== current) pass.setPipeline(current = pipeline);
1275
- pass.draw(4, run.count, 0, run.start);
1276
- }
1277
- }
1278
- pass.end();
1279
- device.queue.submit([encoder.finish()]);
1265
+ /** Stops holding a removed node. */
1266
+ forget(node) {
1267
+ if (this.current === node) this.set(null);
1268
+ if (this.lingering === node) this.lingering = null;
1269
+ if (this.focused === node) this.focused = null;
1270
+ this.transitioning.delete(node);
1280
1271
  }
1281
1272
  destroy() {
1282
- this.instanceBuffer.destroy();
1283
- this.uniforms.destroy();
1273
+ const { canvas, surface } = this.o;
1274
+ this.destroyed = true;
1275
+ clearTimeout(this.lingerTimer);
1276
+ surface.removeEventListener("pointermove", this.onPointerMove);
1277
+ surface.removeEventListener("pointerleave", this.onPointerLeave);
1278
+ surface.removeEventListener("pointerdown", this.onPointerDown, true);
1279
+ surface.removeEventListener("click", this.onClick);
1280
+ canvas.removeEventListener("focusin", this.onFocusIn);
1281
+ canvas.removeEventListener("focusout", this.onFocusOut);
1282
+ canvas.removeEventListener("transitionrun", this.onTransitionRun);
1283
+ for (const type of USE_EVENTS) canvas.removeEventListener(type, this.onUse, true);
1284
1284
  }
1285
- createInstanceBuffer() {
1286
- return this.device.createBuffer({
1287
- label: "gpucanvas instances",
1288
- size: this.instances.byteLength,
1289
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
1290
- });
1285
+ /** A node no longer held: redrawn once as it rests, its page released. */
1286
+ letGo(node) {
1287
+ node.dirty = true;
1288
+ this.unpin(node.page);
1291
1289
  }
1290
+ nodeOf(target) {
1291
+ return nodeOf(this.o.nodes, target);
1292
+ }
1293
+ onPointerMove = (event) => {
1294
+ this.pointer = pointIn(this.o.canvas, event);
1295
+ if (event.buttons === 0) this.pick();
1296
+ };
1297
+ onPointerLeave = () => {
1298
+ this.pointer = null;
1299
+ this.set(null);
1300
+ };
1301
+ onPointerDown = (event) => {
1302
+ const at = pointIn(this.o.canvas, event);
1303
+ this.press = screenToWorld(this.o.camera(), at);
1304
+ this.pressedAt = performance.now();
1305
+ if (event.pointerType !== "mouse") {
1306
+ this.pointer = at;
1307
+ this.pick();
1308
+ this.tapped = true;
1309
+ }
1310
+ this.o.onPress();
1311
+ };
1312
+ onClick = (event) => {
1313
+ if (!this.tapped) return;
1314
+ this.tapped = false;
1315
+ const point = screenToWorld(this.o.camera(), pointIn(this.o.canvas, event));
1316
+ const top = this.o.nodeAt(point);
1317
+ if (!top || top.element.contains(event.target)) return;
1318
+ let target = elementInNode(top.element, top.rect, point);
1319
+ while (target && !(target instanceof HTMLElement)) target = target.parentElement;
1320
+ if (!(target instanceof HTMLElement)) return;
1321
+ target.focus({ preventScroll: true });
1322
+ target.dispatchEvent(
1323
+ new MouseEvent("click", {
1324
+ bubbles: true,
1325
+ cancelable: true,
1326
+ composed: true,
1327
+ view: window,
1328
+ clientX: event.clientX,
1329
+ clientY: event.clientY
1330
+ })
1331
+ );
1332
+ };
1333
+ onFocusIn = (event) => {
1334
+ const node = this.nodeOf(event.target) ?? null;
1335
+ if (node === this.focused) return;
1336
+ const previous = this.focused;
1337
+ this.focused = node;
1338
+ if (previous) this.letGo(previous);
1339
+ if (node) {
1340
+ node.page.setDrawable(true, this.o.frameNumber());
1341
+ node.dirty = true;
1342
+ this.o.schedule();
1343
+ }
1344
+ };
1345
+ onFocusOut = (event) => {
1346
+ if (event.relatedTarget && this.o.canvas.contains(event.relatedTarget)) return;
1347
+ const previous = this.focused;
1348
+ this.focused = null;
1349
+ if (previous) this.letGo(previous);
1350
+ };
1351
+ // A transition paints without touching the DOM, so a click (from a person or from script)
1352
+ // would leave a half-finished frame. A readable node in view that was just used keeps its page
1353
+ // pinned and redrawn on every paint until its transitions are over. Content that animates on
1354
+ // its own, like a looping indicator, keeps the cheaper refresh on mutations: pinning it would
1355
+ // repaint its whole page every frame. A deadline ends the pin, transitions in parked pages
1356
+ // never fire transitionend.
1357
+ onUse = (event) => {
1358
+ const node = this.nodeOf(event.target);
1359
+ if (node) this.usedAt.set(node, performance.now());
1360
+ };
1361
+ onTransitionRun = (event) => {
1362
+ const node = this.nodeOf(event.target);
1363
+ const now = performance.now();
1364
+ if (!node || now - (this.usedAt.get(node) ?? -Infinity) > USED_MS) return;
1365
+ if (!intersects(node.rect, this.o.view())) return;
1366
+ if (smallOnScreen(node, this.o.camera().zoom)) return;
1367
+ const ms = transitionMs(event.target) + 32;
1368
+ const end = now + ms;
1369
+ if ((this.transitioning.get(node) ?? 0) >= end) return;
1370
+ this.transitioning.set(node, end);
1371
+ node.dirty = true;
1372
+ this.o.schedule();
1373
+ setTimeout(() => {
1374
+ if (this.destroyed || this.transitioning.get(node) !== end) return;
1375
+ this.transitioning.delete(node);
1376
+ this.letGo(node);
1377
+ this.o.schedule();
1378
+ }, ms);
1379
+ };
1380
+ };
1381
+ var transitionMs = (element) => {
1382
+ const style = getComputedStyle(element);
1383
+ const seconds = (list) => list.split(",").map((value) => parseFloat(value) || 0);
1384
+ const durations = seconds(style.transitionDuration);
1385
+ const delays = seconds(style.transitionDelay);
1386
+ const longest = Math.max(...durations.map((d, i) => d + (delays[i % delays.length] ?? 0)));
1387
+ return Math.min(1e3, longest * 1e3);
1292
1388
  };
1293
1389
 
1294
1390
  // src/page.ts
@@ -1297,21 +1393,19 @@ var Page = class {
1297
1393
  element;
1298
1394
  width;
1299
1395
  height;
1300
- /** Nodes currently placed on this page. */
1301
- count = 0;
1302
1396
  /** Has the `drawable` attribute and is rendered; parked pages use `content-visibility: hidden`. */
1303
1397
  drawable = false;
1304
1398
  /** Frame in which the page became drawable. Its snapshot exists from that frame's paint on. */
1305
1399
  promotedFrame = -1;
1306
- /** Kept drawable for the live (hit-testable) or focused node. */
1307
- pinned = false;
1308
1400
  /** Registered for hit testing with `updateElementGeometry`. */
1309
1401
  registered = false;
1310
- shelves = [];
1311
- nextY = 0;
1402
+ /** The atlas slot of the page's last whole snapshot, reused when it is drawn again that size. */
1403
+ wholeSlot = null;
1404
+ shelves;
1312
1405
  constructor(width, height) {
1313
1406
  this.width = width;
1314
1407
  this.height = height;
1408
+ this.shelves = new Shelves(width + GAP, height + GAP, 1.25);
1315
1409
  this.element = document.createElement("div");
1316
1410
  this.element.dataset.gpucanvasPage = "";
1317
1411
  Object.assign(this.element.style, {
@@ -1330,25 +1424,8 @@ var Page = class {
1330
1424
  }
1331
1425
  /** Reserves a spot for a `width` x `height` node, or returns null when the page is full. */
1332
1426
  place(width, height) {
1333
- const w = Math.ceil(width) + GAP;
1334
- const h = Math.ceil(height) + GAP;
1335
- if (w > this.width + GAP || h > this.height + GAP) return null;
1336
- let shelf = this.shelves.find(
1337
- (candidate) => candidate.height >= h && candidate.height <= h * 1.25 && candidate.x + w <= this.width + GAP
1338
- );
1339
- if (!shelf) {
1340
- if (this.nextY + h > this.height + GAP) return null;
1341
- shelf = { y: this.nextY, height: h, x: 0 };
1342
- this.shelves.push(shelf);
1343
- this.nextY += h;
1344
- }
1345
- const slot = { page: this, x: shelf.x, y: shelf.y };
1346
- shelf.x += w;
1347
- this.count++;
1348
- return slot;
1349
- }
1350
- release() {
1351
- this.count--;
1427
+ const at = this.shelves.place(Math.ceil(width) + GAP, Math.ceil(height) + GAP);
1428
+ return at && { page: this, ...at };
1352
1429
  }
1353
1430
  setDrawable(drawable, frame) {
1354
1431
  if (this.drawable === drawable) return;
@@ -1366,229 +1443,172 @@ var Page = class {
1366
1443
  }
1367
1444
  };
1368
1445
 
1369
- // src/gpu-backend.ts
1446
+ // src/gpu-pages.ts
1447
+ var Pages = class {
1448
+ constructor(canvas, pageSize) {
1449
+ this.canvas = canvas;
1450
+ this.pageSize = pageSize;
1451
+ }
1452
+ canvas;
1453
+ pageSize;
1454
+ members = /* @__PURE__ */ new Map();
1455
+ byElement = /* @__PURE__ */ new Map();
1456
+ open = null;
1457
+ get size() {
1458
+ return this.members.size;
1459
+ }
1460
+ all() {
1461
+ return this.members.keys();
1462
+ }
1463
+ has(page) {
1464
+ return this.members.has(page);
1465
+ }
1466
+ nodesOf(page) {
1467
+ return this.members.get(page);
1468
+ }
1469
+ /** The page whose element is `element`. */
1470
+ pageOf(element) {
1471
+ return this.byElement.get(element);
1472
+ }
1473
+ /** Counts a node, already placed on its page, as one of the page's nodes. */
1474
+ join(node) {
1475
+ this.members.get(node.page).add(node);
1476
+ }
1477
+ release(node) {
1478
+ const members = this.members.get(node.page);
1479
+ members.delete(node);
1480
+ if (members.size === 0 && node.page !== this.open) this.drop(node.page);
1481
+ }
1482
+ /**
1483
+ * Moves a node onto a page of its own and returns the page it left. Chrome rasterizes a
1484
+ * snapshot's whole source element at the requested scale before cropping, so zoomed in, a crop
1485
+ * from a shared page pays for every node on it: about 100 ms at 16x for two dozen app screens,
1486
+ * under 10 ms for one. `moveBefore` keeps focus, running transitions and other DOM state across
1487
+ * the move.
1488
+ */
1489
+ lift(node) {
1490
+ const previous = node.page;
1491
+ const page = this.newPage(Math.ceil(node.rect.width), Math.ceil(node.rect.height));
1492
+ page.place(node.rect.width, node.rect.height);
1493
+ const target = page.element;
1494
+ if (target.moveBefore) target.moveBefore(node.element, null);
1495
+ else target.append(node.element);
1496
+ node.element.style.left = "0px";
1497
+ node.element.style.top = "0px";
1498
+ this.release(node);
1499
+ node.page = page;
1500
+ node.pageX = 0;
1501
+ node.pageY = 0;
1502
+ node.solo = true;
1503
+ this.join(node);
1504
+ return previous;
1505
+ }
1506
+ /** Lays out `element` on the open page, or a new one when it is full or too small. */
1507
+ place(element, rect) {
1508
+ const { pageSize } = this;
1509
+ let slot = this.open?.place(rect.width, rect.height) ?? null;
1510
+ if (!slot) {
1511
+ const page = this.newPage(
1512
+ Math.max(pageSize, Math.ceil(rect.width)),
1513
+ Math.max(pageSize, Math.ceil(rect.height))
1514
+ );
1515
+ if (page.width === pageSize && page.height === pageSize) this.open = page;
1516
+ slot = page.place(rect.width, rect.height);
1517
+ }
1518
+ Object.assign(element.style, {
1519
+ position: "absolute",
1520
+ left: `${slot.x}px`,
1521
+ top: `${slot.y}px`,
1522
+ width: `${rect.width}px`,
1523
+ height: `${rect.height}px`
1524
+ });
1525
+ slot.page.element.append(element);
1526
+ return slot;
1527
+ }
1528
+ /**
1529
+ * Parks drawable pages that nothing in this frame's plan needs: a page promoted for a camera
1530
+ * that has moved on would otherwise be painted by Chrome every frame.
1531
+ */
1532
+ parkIdle(plans, frameNumber, isPinned) {
1533
+ for (const page of this.members.keys()) {
1534
+ if (page.drawable && !plans.has(page) && page.promotedFrame < frameNumber && !isPinned(page))
1535
+ page.setDrawable(false, frameNumber);
1536
+ }
1537
+ }
1538
+ newPage(width, height) {
1539
+ const page = new Page(width, height);
1540
+ this.members.set(page, /* @__PURE__ */ new Set());
1541
+ this.byElement.set(page.element, page);
1542
+ this.canvas.append(page.element);
1543
+ return page;
1544
+ }
1545
+ drop(page) {
1546
+ this.members.delete(page);
1547
+ this.byElement.delete(page.element);
1548
+ page.element.remove();
1549
+ }
1550
+ };
1551
+
1552
+ // src/gpu-plan.ts
1370
1553
  var MIN_RASTER_SCALE = 1 / 64;
1371
1554
  var MAX_RASTER_SCALE = 2;
1372
1555
  var MAX_DETAIL_SCALE = 64;
1373
1556
  var TILE_PIXELS = 512;
1374
1557
  var CONTENT_SETTLE_MS = 150;
1375
- var SMALL_NODE_PX = 200;
1376
- var SMALL_REFRESH_MS = 250;
1377
- var right = (rect) => rect.x + rect.width;
1378
- var bottom = (rect) => rect.y + rect.height;
1379
- var SETTLE_MS = 120;
1380
- var USE_EVENTS = ["click", "input", "change", "keydown"];
1381
- var USED_MS = 1e3;
1382
- var LINGER_MS = 300;
1383
1558
  var MAX_OVERSAMPLING = 4;
1384
- var DEFAULT_PAGE_SIZE = 2048;
1385
- var THUMBNAIL_SCALE = 1 / 16;
1386
- var THUMBNAIL_REFRESH_MS = 1e3;
1387
- var MAX_PROMOTIONS_PER_FRAME = 8;
1388
- var SLOW_FRAME_MS = 22;
1389
- var FAST_FRAME_MS = 12;
1390
1559
  var MISSING = 0;
1391
1560
  var DIRTY = 1;
1392
1561
  var BLURRY = 2;
1393
1562
  var OVERSAMPLED = 3;
1394
- var CANVAS_TRANSFORM_HIT_TESTS = typeof HTMLCanvasElement !== "undefined" && "getElementImageDefaultSize" in HTMLCanvasElement.prototype;
1395
- function supportsGpuCanvas() {
1396
- return typeof navigator !== "undefined" && "gpu" in navigator && typeof HTMLCanvasElement !== "undefined" && "requestPaint" in HTMLCanvasElement.prototype && "getElementTransform" in HTMLCanvasElement.prototype && typeof GPUQueue !== "undefined" && "drawElementImageToTexture" in GPUQueue.prototype;
1397
- }
1398
1563
  function rasterScaleFor(deviceScale) {
1399
1564
  const scale = 2 ** Math.ceil(Math.log2(deviceScale) - 0.14);
1400
1565
  return Math.min(MAX_DETAIL_SCALE, Math.max(MIN_RASTER_SCALE, scale));
1401
1566
  }
1402
- var intersection = (a, b) => {
1403
- const x = Math.max(a.x, b.x);
1404
- const y = Math.max(a.y, b.y);
1405
- const right2 = Math.min(a.x + a.width, b.x + b.width);
1406
- const bottom2 = Math.min(a.y + a.height, b.y + b.height);
1407
- return right2 > x && bottom2 > y ? { x, y, width: right2 - x, height: bottom2 - y } : null;
1567
+ var distanceToCenter = (rect, view) => {
1568
+ const a = center(rect);
1569
+ const b = center(view);
1570
+ return Math.hypot(a.x - b.x, a.y - b.y);
1408
1571
  };
1409
- var contains = (outer, inner) => inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height;
1410
- function cssColorToRgba(color) {
1411
- const context = document.createElement("canvas").getContext("2d");
1412
- context.fillStyle = color;
1413
- context.fillRect(0, 0, 1, 1);
1414
- const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
1415
- return [r / 255, g / 255, b / 255, a / 255];
1416
- }
1417
- var premultiplied = ([r, g, b, a]) => [r * a, g * a, b * a, a];
1418
- var distanceToCenter = (rect, view) => Math.hypot(
1419
- rect.x + rect.width / 2 - (view.x + view.width / 2),
1420
- rect.y + rect.height / 2 - (view.y + view.height / 2)
1421
- );
1422
- async function createGpuCanvas(host, options) {
1423
- const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
1424
- if (!adapter) throw new Error("gpucanvas: no WebGPU adapter");
1425
- const device = await adapter.requestDevice();
1426
- const surface = document.createElement("div");
1427
- surface.dataset.gpucanvasSurface = "";
1428
- Object.assign(surface.style, {
1429
- position: "relative",
1430
- width: "100%",
1431
- height: "100%",
1432
- overflow: "hidden",
1433
- touchAction: "none"
1434
- });
1435
- const canvas = document.createElement("canvas");
1436
- canvas.setAttribute("content", "drawable");
1437
- canvas.dataset.gpucanvasSurface = "";
1438
- Object.assign(canvas.style, { display: "block", width: "100%", height: "100%" });
1439
- surface.append(canvas);
1440
- host.append(surface);
1441
- const context = canvas.getContext("webgpu");
1442
- const format = navigator.gpu.getPreferredCanvasFormat();
1443
- context.configure({ device, format, alphaMode: "opaque" });
1444
- const atlasSize = Math.min(options.atlasSize ?? 4096, device.limits.maxTextureDimension2D);
1445
- const atlas = new Atlas(
1446
- device,
1447
- atlasSize,
1448
- Math.min(options.atlasLayers ?? 5, device.limits.maxTextureArrayLayers)
1449
- );
1450
- const pageSize = Math.min(options.pageSize ?? DEFAULT_PAGE_SIZE, atlasSize);
1451
- const maxRasterCalls = options.maxRastersPerFrame ?? 12;
1452
- const rasterTimeBudgetMs = options.rasterTimeBudgetMs ?? 6;
1453
- const rasterPixelBudget = options.rasterPixelBudget ?? 24e6;
1454
- const colorsFor = (css) => {
1455
- const background = cssColorToRgba(css);
1456
- const shade = background[0] + background[1] + background[2] > 1.5 ? 0.92 : 1.25;
1457
- const placeholder = background.map(
1458
- (channel) => Math.min(1, channel * shade + (shade > 1 ? 0.02 : 0))
1459
- );
1460
- placeholder[3] = 1;
1461
- return [background, placeholder];
1462
- };
1463
- const renderer = new CompositeRenderer(
1464
- device,
1465
- context,
1466
- format,
1467
- atlas,
1468
- ...colorsFor(options.background ?? "#f4f4f5")
1469
- );
1470
- const selectionColor = premultiplied(
1471
- cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)
1472
- );
1473
- const hoverColor = options.hoverColor ? premultiplied(cssColorToRgba(options.hoverColor)) : selectionColor;
1474
- const nodes = /* @__PURE__ */ new Map();
1475
- const effects = /* @__PURE__ */ new Map();
1476
- const startedAt = performance.now();
1477
- const FAR_AWAY = { x: -1e7, y: -1e7 };
1478
- renderer.onEffectReady = () => schedule();
1479
- const runs = [];
1480
- let effectsMoving = false;
1481
- let selection = /* @__PURE__ */ new Set();
1482
- let hovered = null;
1483
- const ringRects = /* @__PURE__ */ new Map();
1484
- const ringRect = (element) => {
1485
- const own = nodes.get(element);
1486
- if (own) return own.rect;
1487
- const at = element.isConnected ? nodeOfElement(nodes, element) : null;
1488
- if (!at) return null;
1489
- const node = nodes.get(at);
1490
- let rect = ringRects.get(element);
1491
- if (!rect) ringRects.set(element, rect = rectInNode(at, node.rect, element));
1492
- return rect;
1493
- };
1494
- const pages = /* @__PURE__ */ new Map();
1495
- atlas.keep = function* () {
1496
- const onScreen = (tile) => intersects(tile.rect, frameView);
1497
- for (const node of visible) yield node.slot;
1498
- for (const node of visible) {
1499
- for (const tile of node.tiles) {
1500
- if (tile.scale === detailScale && !tile.stale && onScreen(tile)) yield tile.slot;
1501
- }
1502
- }
1503
- for (const node of visible) {
1504
- yield node.preview;
1505
- for (const tile of node.tiles) if (onScreen(tile)) yield tile.slot;
1506
- }
1507
- };
1508
- const pageByElement = /* @__PURE__ */ new Map();
1509
- let openPage = null;
1510
- let camera = options.camera ?? DEFAULT_CAMERA;
1511
- let lastCameraChange = -Infinity;
1512
- let frameNumber = 0;
1513
- let promotionsPerFrame = 2;
1514
- let lastFrameAt = 0;
1515
- let rafId = 0;
1516
- let destroyed = false;
1517
- let live = null;
1518
- let livePositioned = false;
1519
- let lingering = null;
1520
- let lingerTimer = 0;
1521
- let focused = null;
1522
- let pointer = null;
1523
- let settleTimer = 0;
1524
- const stats = {
1525
- visible: 0,
1526
- pending: 0,
1527
- rasterizedLastFrame: 0,
1528
- rasterCallsLastFrame: 0,
1529
- rasterMs: 0,
1530
- frameMs: 0,
1531
- frames: 0,
1532
- lostPixels: 0,
1533
- lostDetail: 0,
1534
- onThumbnail: 0,
1535
- /** Last plan's nodes per need class: missing, dirty, blurry, oversampled. */
1536
- needs: [0, 0, 0, 0]
1537
- };
1538
- const visible = [];
1539
- const plans = /* @__PURE__ */ new Map();
1540
- const schedule = () => {
1541
- if (rafId || destroyed) return;
1542
- rafId = requestAnimationFrame(() => {
1543
- rafId = 0;
1544
- frameNumber++;
1545
- frame(false);
1546
- });
1547
- };
1548
- const resizeObserver = new ResizeObserver(([entry]) => {
1549
- const size = entry.devicePixelContentBoxSize?.[0];
1550
- canvas.width = Math.max(
1551
- 1,
1552
- size ? size.inlineSize : Math.round(entry.contentRect.width * devicePixelRatio)
1553
- );
1554
- canvas.height = Math.max(
1555
- 1,
1556
- size ? size.blockSize : Math.round(entry.contentRect.height * devicePixelRatio)
1557
- );
1558
- schedule();
1559
- });
1560
- resizeObserver.observe(canvas, { box: "device-pixel-content-box" });
1561
- const transitioning = /* @__PURE__ */ new Map();
1562
- const captures = [];
1563
- const isPinned = (page) => captures.some((capture) => capture.node.page === page) || live?.page === page || lingering?.page === page || focused?.page === page || [...transitioning.keys()].some((node) => node.page === page);
1564
- let detailScale = 1;
1565
- let frameView = { x: 0, y: 0, width: 0, height: 0 };
1566
- let contentTimer = 0;
1567
- const scheduleAfterContentSettles = () => {
1568
- clearTimeout(contentTimer);
1569
- contentTimer = window.setTimeout(schedule, CONTENT_SETTLE_MS + 10);
1570
- };
1571
- let refreshTimer = 0;
1572
- let refreshAt = Infinity;
1573
- const refreshWait = (node, now) => Math.max(node.rect.width, node.rect.height) * camera.zoom < SMALL_NODE_PX ? node.refreshedAt + SMALL_REFRESH_MS - now : 0;
1574
- const refreshLater = (wait) => {
1575
- const at = performance.now() + wait;
1576
- if (at >= refreshAt) return;
1577
- clearTimeout(refreshTimer);
1578
- refreshAt = at;
1579
- refreshTimer = window.setTimeout(() => {
1580
- refreshAt = Infinity;
1581
- schedule();
1582
- }, wait);
1583
- };
1584
- const planFrame = (view, desiredScale, deviceScale, moving) => {
1572
+ var Planner = class {
1573
+ constructor(atlas, lift, schedule) {
1574
+ this.atlas = atlas;
1575
+ this.lift = lift;
1576
+ this.schedule = schedule;
1577
+ }
1578
+ atlas;
1579
+ lift;
1580
+ schedule;
1581
+ /** This frame's texels per CSS pixel for tiles. */
1582
+ detailScale = 1;
1583
+ /** This frame's world rect in view. */
1584
+ view = { x: 0, y: 0, width: 0, height: 0 };
1585
+ visible = [];
1586
+ plans = /* @__PURE__ */ new Map();
1587
+ /** This frame's plans, most urgent first. */
1588
+ ordered = [];
1589
+ /** Last plan's nodes per need class: missing, dirty, blurry, oversampled. */
1590
+ needs = [0, 0, 0, 0];
1591
+ zoom = 1;
1592
+ contentTimer = 0;
1593
+ refreshTimer = 0;
1594
+ refreshAt = Infinity;
1595
+ /**
1596
+ * Builds this frame's raster plan: which visible nodes need pixels, grouped by page. `dpr` is
1597
+ * canvas pixels per CSS pixel; `moving` means the camera has not rested yet.
1598
+ */
1599
+ plan(nodes, view, zoom, dpr, moving) {
1600
+ const { atlas, visible, plans } = this;
1601
+ const atlasSize = atlas.size;
1602
+ const deviceScale = zoom * dpr;
1603
+ const desiredScale = rasterScaleFor(deviceScale);
1585
1604
  const now = performance.now();
1586
- detailScale = Math.min(deviceScale, MAX_DETAIL_SCALE);
1587
- frameView = view;
1605
+ this.zoom = zoom;
1606
+ this.detailScale = Math.min(deviceScale, MAX_DETAIL_SCALE);
1607
+ this.view = view;
1588
1608
  visible.length = 0;
1589
1609
  plans.clear();
1590
- stats.needs = [0, 0, 0, 0];
1591
- for (const node of nodes.values()) {
1610
+ this.needs = [0, 0, 0, 0];
1611
+ for (const node of nodes) {
1592
1612
  if (!intersects(node.rect, view)) continue;
1593
1613
  visible.push(node);
1594
1614
  const scale = Math.min(
@@ -1599,14 +1619,14 @@ async function createGpuCanvas(host, options) {
1599
1619
  );
1600
1620
  if (node.dirty) node.changedAt = now;
1601
1621
  const thumbnailEnough = scale <= THUMBNAIL_SCALE;
1602
- if (thumbnailEnough && freshThumb(node) && !node.dirty && !node.wholeStale) {
1622
+ if (thumbnailEnough && hasFreshThumb(atlas, node) && !node.dirty && !node.wholeStale) {
1603
1623
  node.slot = node.thumb;
1604
1624
  node.slotScale = THUMBNAIL_SCALE;
1605
1625
  }
1606
1626
  const animating = now - node.changedAt < CONTENT_SETTLE_MS;
1607
1627
  const deep = desiredScale > scale;
1608
- if (deep && !node.solo) lift(node);
1609
- const jobs = deep ? planDetail(node, view, animating, moving) : [];
1628
+ if (deep && !node.solo) this.lift(node);
1629
+ const jobs = deep ? this.planDetail(node, view, animating, moving) : [];
1610
1630
  if (!deep) {
1611
1631
  node.preview = null;
1612
1632
  node.tiles.length = 0;
@@ -1616,12 +1636,12 @@ async function createGpuCanvas(host, options) {
1616
1636
  if (!valid) need = atlas.isValid(node.thumb) ? DIRTY : MISSING;
1617
1637
  else if (deep) need = node.slotScale < scale / (moving ? 4 : 2) ? BLURRY : -1;
1618
1638
  else if (node.dirty || node.wholeStale) {
1619
- const wait = refreshWait(node, now);
1620
- if (wait > 0) refreshLater(wait);
1639
+ const wait = this.refreshWait(node, now);
1640
+ if (wait > 0) this.refreshLater(wait);
1621
1641
  else need = DIRTY;
1622
1642
  } else if (node.slotScale < (moving ? scale / 2 : scale)) need = BLURRY;
1623
1643
  else if (!moving && node.slotScale > scale * MAX_OVERSAMPLING) need = OVERSAMPLED;
1624
- if (need >= 0) stats.needs[need]++;
1644
+ if (need >= 0) this.needs[need]++;
1625
1645
  if (need < 0 && !node.previewTarget && jobs.length === 0) continue;
1626
1646
  node.targetScale = thumbnailEnough ? THUMBNAIL_SCALE : scale;
1627
1647
  node.priority = (need < 0 ? BLURRY : need) * 1e12 + distanceToCenter(node.rect, view);
@@ -1636,8 +1656,33 @@ async function createGpuCanvas(host, options) {
1636
1656
  plan.tiles.push(...jobs);
1637
1657
  }
1638
1658
  for (const plan of plans.values()) plan.tiles.sort((a, b) => a.priority - b.priority);
1639
- };
1640
- const planDetail = (node, view, animating, moving) => {
1659
+ this.ordered = [...plans.values()].sort((a, b) => a.priority - b.priority);
1660
+ }
1661
+ /** Atlas slots to keep when it is full: what is on screen, whole snapshots before sharp tiles. */
1662
+ *keep() {
1663
+ const { visible, detailScale, view } = this;
1664
+ const onScreen = (tile) => intersects(tile.rect, view);
1665
+ for (const node of visible) yield node.slot;
1666
+ for (const node of visible) {
1667
+ for (const tile of node.tiles) {
1668
+ if (isCurrentTile(tile, detailScale) && onScreen(tile)) yield tile.slot;
1669
+ }
1670
+ }
1671
+ for (const node of visible) {
1672
+ yield node.preview;
1673
+ for (const tile of node.tiles) if (onScreen(tile)) yield tile.slot;
1674
+ }
1675
+ }
1676
+ destroy() {
1677
+ clearTimeout(this.contentTimer);
1678
+ clearTimeout(this.refreshTimer);
1679
+ }
1680
+ /**
1681
+ * Plans a zoomed-in node: a preview while its content animates, otherwise the tiles in view
1682
+ * it is missing at this frame's scale.
1683
+ */
1684
+ planDetail(node, view, animating, moving) {
1685
+ const { atlas, detailScale } = this;
1641
1686
  const shown = intersection(node.rect, view);
1642
1687
  node.previewTarget = null;
1643
1688
  node.tiles = node.tiles.filter((tile) => atlas.isValid(tile.slot));
@@ -1647,19 +1692,19 @@ async function createGpuCanvas(host, options) {
1647
1692
  const scale = detailScale / 2;
1648
1693
  const stale = !node.preview || node.dirty || node.previewScale !== scale || !contains(node.previewRect, shown);
1649
1694
  if (stale) {
1650
- node.previewTarget = fitPreview(shown, scale);
1695
+ node.previewTarget = this.fitPreview(shown, scale);
1651
1696
  node.previewTargetScale = scale;
1652
1697
  }
1653
- scheduleAfterContentSettles();
1698
+ this.scheduleAfterContentSettles();
1654
1699
  return [];
1655
1700
  }
1656
- const current = node.tiles.filter((tile) => tile.scale === detailScale && !tile.stale);
1701
+ const current = node.tiles.filter((tile) => isCurrentTile(tile, detailScale));
1657
1702
  if (moving && current.length === 0) return [];
1658
1703
  const size = Math.max(1, Math.floor(TILE_PIXELS / detailScale));
1659
1704
  const jobs = [];
1660
1705
  const first = (edge, origin) => Math.floor((edge - origin) / size);
1661
- for (let row = first(shown.y, node.rect.y); row <= first(bottom(shown) - 1e-6, node.rect.y); row++) {
1662
- for (let column = first(shown.x, node.rect.x); column <= first(right(shown) - 1e-6, node.rect.x); column++) {
1706
+ for (let row = first(shown.y, node.rect.y); row <= first(shown.y + shown.height - 1e-6, node.rect.y); row++) {
1707
+ for (let column = first(shown.x, node.rect.x); column <= first(shown.x + shown.width - 1e-6, node.rect.x); column++) {
1663
1708
  if (current.some((tile) => tile.column === column && tile.row === row)) continue;
1664
1709
  const rect = intersection(node.rect, {
1665
1710
  x: node.rect.x + column * size,
@@ -1682,61 +1727,107 @@ async function createGpuCanvas(host, options) {
1682
1727
  node.preview = null;
1683
1728
  }
1684
1729
  return jobs;
1685
- };
1686
- const fitPreview = (shown, scale) => {
1687
- const limit = (atlasSize - 2) / scale;
1730
+ }
1731
+ /** The world rect a preview covers: `shown`, shrunk around its center to fit one layer. */
1732
+ fitPreview(shown, scale) {
1733
+ const limit = (this.atlas.size - 2) / scale;
1688
1734
  const fit = (start, size) => size <= limit ? [start, size] : [start + (size - limit) / 2, limit];
1689
1735
  const [x, width] = fit(shown.x, shown.width);
1690
1736
  const [y, height] = fit(shown.y, shown.height);
1691
1737
  return { x, y, width, height };
1692
- };
1693
- const drawSnapshot = (page, source, slot) => {
1694
- try {
1695
- device.queue.drawElementImageToTexture(
1696
- source ? {
1697
- source: page.element,
1698
- sourceX: source.x,
1699
- sourceY: source.y,
1700
- sourceWidth: source.width,
1701
- sourceHeight: source.height
1702
- } : { source: page.element },
1703
- {
1704
- texture: atlas.texture,
1705
- origin: { x: slot.x, y: slot.y, z: slot.layer },
1706
- size: { width: slot.width, height: slot.height },
1707
- premultipliedAlpha: true
1708
- }
1709
- );
1710
- return true;
1711
- } catch {
1712
- return false;
1713
- }
1714
- };
1715
- const slotFor = (previous, width, height, thumbnail = false) => atlas.isValid(previous) && atlas.isPinned(previous) === thumbnail && previous.width === width && previous.height === height && Number.isInteger(previous.x) && Number.isInteger(previous.y) ? previous : (thumbnail ? atlas.allocatePinned(width, height) : null) ?? atlas.allocate(width, height);
1716
- const freshThumb = (node) => atlas.isValid(node.thumb) && !node.thumbStale;
1717
- const drawn = (node, slot, scale, now) => {
1718
- node.slot = slot;
1719
- node.slotScale = scale;
1720
- if (scale === THUMBNAIL_SCALE) {
1721
- node.thumb = slot;
1722
- node.thumbStale = false;
1723
- node.thumbAt = now;
1724
- } else if (node.dirty) node.thumbStale = true;
1725
- if (node.dirty) {
1726
- node.preview = null;
1727
- node.refreshedAt = now;
1738
+ }
1739
+ scheduleAfterContentSettles() {
1740
+ clearTimeout(this.contentTimer);
1741
+ this.contentTimer = window.setTimeout(this.schedule, CONTENT_SETTLE_MS + 10);
1742
+ }
1743
+ /** How long a changed node waits before it is redrawn: only small ones wait. */
1744
+ refreshWait(node, now) {
1745
+ return smallOnScreen(node, this.zoom) ? node.refreshedAt + SMALL_REFRESH_MS - now : 0;
1746
+ }
1747
+ refreshLater(wait) {
1748
+ const at = performance.now() + wait;
1749
+ if (at >= this.refreshAt) return;
1750
+ clearTimeout(this.refreshTimer);
1751
+ this.refreshAt = at;
1752
+ this.refreshTimer = window.setTimeout(() => {
1753
+ this.refreshAt = Infinity;
1754
+ this.schedule();
1755
+ }, wait);
1756
+ }
1757
+ };
1758
+
1759
+ // src/capture.ts
1760
+ function captureRegion(device, source, region, width, height) {
1761
+ const texture = device.createTexture({
1762
+ size: { width, height },
1763
+ format: "rgba8unorm",
1764
+ usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
1765
+ });
1766
+ try {
1767
+ device.queue.drawElementImageToTexture(
1768
+ {
1769
+ source,
1770
+ sourceX: region.x,
1771
+ sourceY: region.y,
1772
+ sourceWidth: region.width,
1773
+ sourceHeight: region.height
1774
+ },
1775
+ { texture, size: { width, height }, premultipliedAlpha: false }
1776
+ );
1777
+ } catch (error) {
1778
+ texture.destroy();
1779
+ throw error;
1780
+ }
1781
+ return readTexture(device, texture, width, height);
1782
+ }
1783
+ async function readTexture(device, texture, width, height) {
1784
+ const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
1785
+ const buffer = device.createBuffer({
1786
+ size: bytesPerRow * height,
1787
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
1788
+ });
1789
+ const encoder = device.createCommandEncoder();
1790
+ encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, { width, height });
1791
+ device.queue.submit([encoder.finish()]);
1792
+ try {
1793
+ await buffer.mapAsync(GPUMapMode.READ);
1794
+ const rows = new Uint8Array(buffer.getMappedRange());
1795
+ const pixels = new Uint8ClampedArray(width * height * 4);
1796
+ for (let y = 0; y < height; y++) {
1797
+ pixels.set(rows.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
1728
1798
  }
1729
- node.dirty = false;
1730
- node.wholeStale = false;
1731
- };
1732
- const pageSlots = /* @__PURE__ */ new Map();
1733
- const rasterize = (start) => {
1799
+ return new ImageData(pixels, width, height);
1800
+ } finally {
1801
+ buffer.destroy();
1802
+ texture.destroy();
1803
+ }
1804
+ }
1805
+
1806
+ // src/gpu-raster.ts
1807
+ var THUMBNAIL_REFRESH_MS = 1e3;
1808
+ var MAX_CAPTURE_TRIES = 20;
1809
+ var Rasterizer = class {
1810
+ constructor(device, atlas, budget) {
1811
+ this.device = device;
1812
+ this.atlas = atlas;
1813
+ this.budget = budget;
1814
+ }
1815
+ device;
1816
+ atlas;
1817
+ budget;
1818
+ /**
1819
+ * Rasterizes planned pages, most urgent first, within the frame's budget. A page where many
1820
+ * nodes need pixels is drawn whole in one call; otherwise each node is cropped out. Pages that
1821
+ * end up with nothing left to draw are parked unless `isPinned` holds them.
1822
+ */
1823
+ rasterize(ordered, pages, frameNumber, start, isPinned) {
1824
+ const { atlas, budget } = this;
1825
+ const atlasSize = atlas.size;
1734
1826
  let calls = 0;
1735
1827
  let pixels = 0;
1736
1828
  let rasterized = 0;
1737
1829
  let pending = 0;
1738
- const overBudget = () => calls > 0 && (calls >= maxRasterCalls || pixels >= rasterPixelBudget || performance.now() - start >= rasterTimeBudgetMs);
1739
- const ordered = [...plans.values()].sort((a, b) => a.priority - b.priority);
1830
+ const overBudget = () => calls > 0 && (calls >= budget.calls || pixels >= budget.pixels || performance.now() - start >= budget.ms);
1740
1831
  for (const plan of ordered) {
1741
1832
  const { page } = plan;
1742
1833
  if (!page.drawable || page.promotedFrame >= frameNumber || overBudget()) {
@@ -1744,18 +1835,18 @@ async function createGpuCanvas(host, options) {
1744
1835
  continue;
1745
1836
  }
1746
1837
  const scale = plan.nodes.reduce((max, node) => Math.max(max, node.targetScale), 0);
1747
- const members = pages.get(page);
1838
+ const members = pages.nodesOf(page);
1748
1839
  const neededArea = plan.nodes.reduce((sum, n) => sum + n.rect.width * n.rect.height, 0);
1749
1840
  const wholeFits = page.width * scale <= atlasSize / 2 && page.height * scale <= atlasSize / 2;
1750
1841
  const whole = wholeFits && (plan.nodes.length >= 6 || neededArea >= 0.25 * page.width * page.height);
1751
- const wholeSlot = whole ? slotFor(
1752
- pageSlots.get(page) ?? null,
1842
+ const wholeSlot = whole ? this.slotFor(
1843
+ page.wholeSlot,
1753
1844
  Math.ceil(page.width * scale),
1754
1845
  Math.ceil(page.height * scale),
1755
1846
  scale === THUMBNAIL_SCALE
1756
1847
  ) : null;
1757
- if (wholeSlot) pageSlots.set(page, wholeSlot);
1758
- if (wholeSlot && !drawSnapshot(page, null, wholeSlot)) {
1848
+ if (wholeSlot) page.wholeSlot = wholeSlot;
1849
+ if (wholeSlot && !this.draw(page, null, wholeSlot)) {
1759
1850
  pending += plan.nodes.length + plan.previews.length + plan.tiles.length;
1760
1851
  continue;
1761
1852
  }
@@ -1772,7 +1863,7 @@ async function createGpuCanvas(host, options) {
1772
1863
  width: node.rect.width * scale,
1773
1864
  height: node.rect.height * scale
1774
1865
  };
1775
- drawn(node, part, scale, start);
1866
+ recordWhole(node, part, scale, start);
1776
1867
  }
1777
1868
  rasterized += members.size;
1778
1869
  } else {
@@ -1782,52 +1873,37 @@ async function createGpuCanvas(host, options) {
1782
1873
  continue;
1783
1874
  }
1784
1875
  const thumbnail = node.targetScale === THUMBNAIL_SCALE;
1785
- const slot = slotFor(
1876
+ const slot = this.slotFor(
1786
1877
  thumbnail ? node.thumb : node.slot,
1787
- Math.max(1, Math.round(node.rect.width * node.targetScale)),
1788
- Math.max(1, Math.round(node.rect.height * node.targetScale)),
1878
+ texels(node.rect.width, node.targetScale),
1879
+ texels(node.rect.height, node.targetScale),
1789
1880
  thumbnail
1790
1881
  );
1791
- const region = {
1792
- x: node.pageX,
1793
- y: node.pageY,
1794
- width: node.rect.width,
1795
- height: node.rect.height
1796
- };
1797
- if (!slot || !drawSnapshot(page, region, slot)) {
1882
+ if (!slot || !this.draw(page, nodeRegion(node), slot)) {
1798
1883
  pending++;
1799
1884
  continue;
1800
1885
  }
1801
1886
  calls++;
1802
1887
  pixels += slot.width * slot.height;
1803
- drawn(node, slot, node.targetScale, start);
1888
+ recordWhole(node, slot, node.targetScale, start);
1804
1889
  rasterized++;
1805
1890
  }
1806
1891
  }
1807
1892
  const warm = /* @__PURE__ */ new Set();
1808
1893
  const drawRegion = (node, rect, scale2, previous) => {
1809
1894
  if (!warm.has(scale2) && overBudget()) return null;
1810
- const slot = slotFor(
1811
- previous,
1812
- Math.max(1, Math.round(rect.width * scale2)),
1813
- Math.max(1, Math.round(rect.height * scale2))
1814
- );
1895
+ const slot = this.slotFor(previous, texels(rect.width, scale2), texels(rect.height, scale2));
1815
1896
  const region = {
1816
1897
  x: node.pageX + rect.x - node.rect.x,
1817
1898
  y: node.pageY + rect.y - node.rect.y,
1818
1899
  width: rect.width,
1819
1900
  height: rect.height
1820
1901
  };
1821
- if (!slot || !drawSnapshot(page, region, slot)) return null;
1902
+ if (!slot || !this.draw(page, region, slot)) return null;
1822
1903
  calls++;
1823
1904
  warm.add(scale2);
1824
1905
  pixels += slot.width * slot.height;
1825
- if (node.dirty) {
1826
- node.dirty = false;
1827
- node.wholeStale = true;
1828
- node.thumbStale = true;
1829
- node.refreshedAt = start;
1830
- }
1906
+ recordPartial(node, start);
1831
1907
  return slot;
1832
1908
  };
1833
1909
  for (const node of plan.previews) {
@@ -1858,107 +1934,715 @@ async function createGpuCanvas(host, options) {
1858
1934
  }
1859
1935
  pending += tilesLeft;
1860
1936
  for (const node of members) {
1861
- if (freshThumb(node) || node.dirty) continue;
1937
+ if (hasFreshThumb(atlas, node) || node.dirty) continue;
1862
1938
  if (atlas.isValid(node.thumb) && start - node.thumbAt < THUMBNAIL_REFRESH_MS) continue;
1863
- const slot = slotFor(
1939
+ const slot = this.slotFor(
1864
1940
  node.thumb,
1865
- Math.max(1, Math.round(node.rect.width * THUMBNAIL_SCALE)),
1866
- Math.max(1, Math.round(node.rect.height * THUMBNAIL_SCALE)),
1941
+ texels(node.rect.width, THUMBNAIL_SCALE),
1942
+ texels(node.rect.height, THUMBNAIL_SCALE),
1867
1943
  true
1868
1944
  );
1869
- const region = {
1870
- x: node.pageX,
1871
- y: node.pageY,
1872
- width: node.rect.width,
1873
- height: node.rect.height
1874
- };
1875
- if (!slot || !drawSnapshot(page, region, slot)) continue;
1945
+ if (!slot || !this.draw(page, nodeRegion(node), slot)) continue;
1876
1946
  calls++;
1877
- node.thumb = slot;
1878
- node.thumbStale = false;
1879
- node.thumbAt = start;
1947
+ setThumb(node, slot, start);
1880
1948
  }
1881
1949
  const done = plan.nodes.every((node) => !node.dirty && atlas.isValid(node.slot)) && plan.previews.every((node) => node.previewTarget === null) && tilesLeft === 0;
1882
1950
  if (done && !isPinned(page)) page.setDrawable(false, frameNumber);
1883
1951
  }
1884
- stats.rasterizedLastFrame = rasterized;
1885
- stats.rasterCallsLastFrame = calls;
1886
- return pending;
1952
+ return { pending, rasterized, calls };
1953
+ }
1954
+ /**
1955
+ * Draws each node waiting for a snapshot into a texture of its own and reads it back, removing
1956
+ * it from `captures`. A page that just became drawable has no snapshot until the next paint
1957
+ * event, so a capture may wait a paint or two.
1958
+ */
1959
+ capture(captures) {
1960
+ for (const capture of [...captures]) {
1961
+ const { node, scale } = capture;
1962
+ let image;
1963
+ try {
1964
+ image = captureRegion(
1965
+ this.device,
1966
+ node.page.element,
1967
+ nodeRegion(node),
1968
+ texels(node.rect.width, scale),
1969
+ texels(node.rect.height, scale)
1970
+ );
1971
+ } catch (error) {
1972
+ if (++capture.tries > MAX_CAPTURE_TRIES) {
1973
+ captures.splice(captures.indexOf(capture), 1);
1974
+ capture.reject(error instanceof Error ? error : new Error(String(error)));
1975
+ }
1976
+ continue;
1977
+ }
1978
+ captures.splice(captures.indexOf(capture), 1);
1979
+ image.then(capture.resolve, capture.reject);
1980
+ }
1981
+ }
1982
+ /**
1983
+ * Atlas space for a `width` x `height` snapshot. A redraw at the same size overwrites the slot
1984
+ * it replaces, so redraws (a hover, a click, an animation) never fill the atlas. Slots cut from
1985
+ * a whole-page snapshot start at fractional texels and belong to the page, so they are never
1986
+ * overwritten. Thumbnails go to the pinned layer while it has room.
1987
+ */
1988
+ slotFor(previous, width, height, thumbnail = false) {
1989
+ const { atlas } = this;
1990
+ return atlas.isValid(previous) && atlas.isPinned(previous) === thumbnail && previous.width === width && previous.height === height && Number.isInteger(previous.x) && Number.isInteger(previous.y) ? previous : (thumbnail ? atlas.allocatePinned(width, height) : null) ?? atlas.allocate(width, height);
1991
+ }
1992
+ /** Copies `source` of the page (all of it when null) into `slot`; false until it is recorded. */
1993
+ draw(page, source, slot) {
1994
+ try {
1995
+ this.device.queue.drawElementImageToTexture(
1996
+ source ? {
1997
+ source: page.element,
1998
+ sourceX: source.x,
1999
+ sourceY: source.y,
2000
+ sourceWidth: source.width,
2001
+ sourceHeight: source.height
2002
+ } : { source: page.element },
2003
+ {
2004
+ texture: this.atlas.texture,
2005
+ origin: { x: slot.x, y: slot.y, z: slot.layer },
2006
+ size: { width: slot.width, height: slot.height },
2007
+ premultipliedAlpha: true
2008
+ }
2009
+ );
2010
+ return true;
2011
+ } catch {
2012
+ return false;
2013
+ }
2014
+ }
2015
+ };
2016
+
2017
+ // src/composite-shader.ts
2018
+ var KIND = { snapshot: 0, placeholder: 1, ring: 2 };
2019
+ var prelude = (
2020
+ /* wgsl */
2021
+ `
2022
+ struct View {
2023
+ size: vec2f, // canvas size in CSS pixels
2024
+ origin: vec2f, // camera position, world units
2025
+ zoom: f32,
2026
+ dpr: f32,
2027
+ atlasSize: f32,
2028
+ time: f32, // seconds since the canvas started
2029
+ placeholder: vec4f, // premultiplied color for nodes without a snapshot
2030
+ pointer: vec4f, // pointer and last press in world units; far away when there is none
2031
+ pressTime: f32, // seconds since the last press
2032
+ }
2033
+
2034
+ @group(0) @binding(0) var<uniform> view: View;
2035
+ @group(0) @binding(1) var atlas: texture_2d_array<f32>;
2036
+ @group(0) @binding(2) var atlasSampler: sampler;
2037
+
2038
+ struct Instance {
2039
+ @location(0) rect: vec4f, // world x, y, width, height
2040
+ @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1; for a ring, its premultiplied color
2041
+ @location(2) info: vec4f, // layer, kind (0 snapshot, 1 placeholder, 2 ring), ring width or effect pad
2042
+ @location(3) params: vec4f, // an effect's parameters
2043
+ }
2044
+
2045
+ struct Varyings {
2046
+ @builtin(position) position: vec4f,
2047
+ @location(0) texel: vec2f,
2048
+ @location(1) @interpolate(flat) bounds: vec4f,
2049
+ @location(2) @interpolate(flat) info: vec2f,
2050
+ @location(3) @interpolate(flat) color: vec4f,
2051
+ @location(4) uv: vec2f, // 0 to 1 across the node, past that in an effect's padding
2052
+ @location(5) @interpolate(flat) size: vec2f,
2053
+ @location(6) @interpolate(flat) params: vec4f,
2054
+ @location(7) @interpolate(flat) corner: vec2f, // the node's top left in world units
2055
+ }
2056
+
2057
+ @vertex
2058
+ fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
2059
+ let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
2060
+ // A ring's quad reaches past its element by the ring's width, an effect's by its padding.
2061
+ let ring = instance.info.y > ${KIND.ring - 0.5};
2062
+ let grow = select(instance.info.z, instance.info.z / view.zoom, ring);
2063
+ let start = instance.rect.xy - grow;
2064
+ let end = instance.rect.xy + instance.rect.zw + grow;
2065
+ // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
2066
+ let scale = view.zoom * view.dpr;
2067
+ let topLeft = round((start - view.origin) * scale) / view.dpr;
2068
+ let bottomRight = round((end - view.origin) * scale) / view.dpr;
2069
+ let point = mix(topLeft, bottomRight, corner);
2070
+ var out: Varyings;
2071
+ 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);
2072
+ if (ring) {
2073
+ // World units from the element's top left, and the element's own edges.
2074
+ out.texel = mix(vec2f(-grow), instance.rect.zw + grow, corner);
2075
+ out.bounds = vec4f(0.0, 0.0, instance.rect.zw);
2076
+ out.color = instance.uv;
2077
+ } else {
2078
+ let pad = grow * (instance.uv.zw - instance.uv.xy) / instance.rect.zw;
2079
+ out.texel = mix(instance.uv.xy - pad, instance.uv.zw + pad, corner);
2080
+ out.color = instance.uv;
2081
+ // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
2082
+ // with whatever sits next to it in the atlas.
2083
+ out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
2084
+ }
2085
+ out.info = instance.info.xy;
2086
+ out.uv = mix(-grow / instance.rect.zw, 1.0 + grow / instance.rect.zw, corner);
2087
+ out.size = instance.rect.zw;
2088
+ out.corner = instance.rect.xy;
2089
+ out.params = instance.params;
2090
+ return out;
2091
+ }
2092
+ `
2093
+ );
2094
+ var compositeShader = (
2095
+ /* wgsl */
2096
+ `${prelude}
2097
+ @fragment
2098
+ fn fs(in: Varyings) -> @location(0) vec4f {
2099
+ // Derivatives first: WGSL only allows them in uniform control flow.
2100
+ let dx = dpdx(in.texel);
2101
+ let dy = dpdy(in.texel);
2102
+ if (in.info.y > ${KIND.ring - 0.5}) {
2103
+ // The ring covers what lies outside the element; the element itself shows through.
2104
+ let outside = max(in.bounds.xy - in.texel, in.texel - in.bounds.zw);
2105
+ return select(vec4f(0.0), in.color, max(outside.x, outside.y) > 0.0);
2106
+ }
2107
+ if (in.info.y > ${KIND.placeholder - 0.5}) {
2108
+ return view.placeholder;
2109
+ }
2110
+ let layer = i32(in.info.x);
2111
+ // Texels covered by this pixel along each screen axis.
2112
+ let footprint = vec2f(length(vec2f(dx.x, dy.x)), length(vec2f(dx.y, dy.y)));
2113
+ let taps = vec2u(clamp(ceil(footprint), vec2f(1.0), vec2f(4.0)));
2114
+ var color = vec4f(0.0);
2115
+ for (var j = 0u; j < taps.y; j++) {
2116
+ for (var i = 0u; i < taps.x; i++) {
2117
+ let offset = (vec2f(f32(i), f32(j)) + 0.5) / vec2f(taps) - 0.5;
2118
+ let texel = clamp(in.texel + offset * footprint, in.bounds.xy, in.bounds.zw);
2119
+ color += textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, layer, 0.0);
2120
+ }
2121
+ }
2122
+ return color / f32(taps.x * taps.y);
2123
+ }
2124
+ `
2125
+ );
2126
+ var effectShader = (wgsl) => (
2127
+ /* wgsl */
2128
+ `${prelude}
2129
+ struct EffectInput {
2130
+ uv: vec2f,
2131
+ size: vec2f,
2132
+ time: f32,
2133
+ zoom: f32,
2134
+ params: vec4f,
2135
+ pointer: vec2f,
2136
+ press: vec2f,
2137
+ sincePress: f32,
2138
+ }
2139
+
2140
+ var<private> sourceRect: vec4f;
2141
+ var<private> sourceLayer: i32;
2142
+
2143
+ fn source(uv: vec2f) -> vec4f {
2144
+ if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) {
2145
+ return vec4f(0.0);
2146
+ }
2147
+ let texel = clamp(mix(sourceRect.xy, sourceRect.zw, uv), sourceRect.xy + 0.5, sourceRect.zw - 0.5);
2148
+ return textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, sourceLayer, 0.0);
2149
+ }
2150
+
2151
+ fn hash(p: vec2f) -> f32 {
2152
+ return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
2153
+ }
2154
+
2155
+ fn noise(p: vec2f) -> f32 {
2156
+ let i = floor(p);
2157
+ let f = fract(p);
2158
+ let u = f * f * (3.0 - 2.0 * f);
2159
+ return mix(
2160
+ mix(hash(i), hash(i + vec2f(1.0, 0.0)), u.x),
2161
+ mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x),
2162
+ u.y,
2163
+ );
2164
+ }
2165
+
2166
+ ${wgsl}
2167
+
2168
+ @fragment
2169
+ fn fs(in: Varyings) -> @location(0) vec4f {
2170
+ sourceRect = in.color;
2171
+ sourceLayer = i32(in.info.x);
2172
+ let pointer = (view.pointer.xy - in.corner) / in.size;
2173
+ let press = (view.pointer.zw - in.corner) / in.size;
2174
+ return effect(
2175
+ EffectInput(in.uv, in.size, view.time, view.zoom, in.params, pointer, press, view.pressTime),
2176
+ );
2177
+ }
2178
+ `
2179
+ );
2180
+
2181
+ // src/gpu-renderer.ts
2182
+ var FLOATS_PER_INSTANCE = 16;
2183
+ var CompositeRenderer = class {
2184
+ constructor(device, context, format, atlas, background, placeholder, onEffectReady) {
2185
+ this.device = device;
2186
+ this.context = context;
2187
+ this.format = format;
2188
+ this.atlas = atlas;
2189
+ this.background = background;
2190
+ this.placeholder = placeholder;
2191
+ this.onEffectReady = onEffectReady;
2192
+ this.bindGroupLayout = device.createBindGroupLayout({
2193
+ entries: [
2194
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: {} },
2195
+ {
2196
+ binding: 1,
2197
+ visibility: GPUShaderStage.FRAGMENT,
2198
+ texture: { viewDimension: "2d-array" }
2199
+ },
2200
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }
2201
+ ]
2202
+ });
2203
+ this.sampler = device.createSampler({ magFilter: "linear", minFilter: "linear" });
2204
+ this.pipeline = device.createRenderPipeline(
2205
+ this.pipelineDescriptor("gpucanvas composite", compositeShader)
2206
+ );
2207
+ this.uniforms = device.createBuffer({
2208
+ size: this.uniformData.byteLength,
2209
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
2210
+ });
2211
+ this.bindGroup = device.createBindGroup({
2212
+ layout: this.bindGroupLayout,
2213
+ entries: [
2214
+ { binding: 0, resource: { buffer: this.uniforms } },
2215
+ { binding: 1, resource: atlas.view },
2216
+ { binding: 2, resource: this.sampler }
2217
+ ]
2218
+ });
2219
+ this.instanceBuffer = this.createInstanceBuffer();
2220
+ }
2221
+ device;
2222
+ context;
2223
+ format;
2224
+ atlas;
2225
+ background;
2226
+ placeholder;
2227
+ onEffectReady;
2228
+ instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
2229
+ count = 0;
2230
+ runs = [];
2231
+ instanceBuffer;
2232
+ uniforms;
2233
+ uniformData = new Float32Array(20);
2234
+ pipeline;
2235
+ bindGroup;
2236
+ bindGroupLayout;
2237
+ sampler;
2238
+ /** Effect pipelines, null while one compiles. Compiling never blocks a frame. */
2239
+ effects = /* @__PURE__ */ new Map();
2240
+ failed = /* @__PURE__ */ new Set();
2241
+ pipelineDescriptor(label, code) {
2242
+ const module = this.device.createShaderModule({ label, code });
2243
+ return {
2244
+ label,
2245
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
2246
+ vertex: {
2247
+ module,
2248
+ entryPoint: "vs",
2249
+ buffers: [
2250
+ {
2251
+ arrayStride: FLOATS_PER_INSTANCE * 4,
2252
+ stepMode: "instance",
2253
+ attributes: [
2254
+ { shaderLocation: 0, offset: 0, format: "float32x4" },
2255
+ { shaderLocation: 1, offset: 16, format: "float32x4" },
2256
+ { shaderLocation: 2, offset: 32, format: "float32x4" },
2257
+ { shaderLocation: 3, offset: 48, format: "float32x4" }
2258
+ ]
2259
+ }
2260
+ ]
2261
+ },
2262
+ fragment: {
2263
+ module,
2264
+ entryPoint: "fs",
2265
+ targets: [
2266
+ {
2267
+ format: this.format,
2268
+ // Snapshots are requested premultiplied.
2269
+ blend: {
2270
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
2271
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }
2272
+ }
2273
+ }
2274
+ ]
2275
+ },
2276
+ primitive: { topology: "triangle-strip" }
2277
+ };
2278
+ }
2279
+ /** Whether `effect` can draw this frame. The first ask starts compiling it. */
2280
+ hasEffect(effect) {
2281
+ if (this.effects.has(effect)) return this.effects.get(effect) !== null;
2282
+ this.effects.set(effect, null);
2283
+ this.device.createRenderPipelineAsync(
2284
+ this.pipelineDescriptor(`gpucanvas effect ${effect.name}`, effectShader(effect.wgsl))
2285
+ ).then((pipeline) => {
2286
+ this.effects.set(effect, pipeline);
2287
+ this.onEffectReady();
2288
+ }).catch((error) => {
2289
+ this.failed.add(effect);
2290
+ console.error(`[gpucanvas] effect "${effect.name}" failed`, error);
2291
+ });
2292
+ return false;
2293
+ }
2294
+ /** Whether `effect`'s pipeline failed to compile; it never draws then. */
2295
+ effectFailed(effect) {
2296
+ return this.failed.has(effect);
2297
+ }
2298
+ /** Starts a frame's list of quads. */
2299
+ begin() {
2300
+ this.count = 0;
2301
+ this.runs.length = 0;
2302
+ }
2303
+ /** Room for the next quad, in the run of `effect`; returns its first float. */
2304
+ next(effect) {
2305
+ if ((this.count + 1) * FLOATS_PER_INSTANCE > this.instances.length) {
2306
+ const larger = new Float32Array(this.instances.length * 2);
2307
+ larger.set(this.instances);
2308
+ this.instances = larger;
2309
+ this.instanceBuffer.destroy();
2310
+ this.instanceBuffer = this.createInstanceBuffer();
2311
+ }
2312
+ const last = this.runs.at(-1);
2313
+ if (last && last.effect === effect) last.count++;
2314
+ else this.runs.push({ effect, start: this.count, count: 1 });
2315
+ return this.count++ * FLOATS_PER_INSTANCE;
2316
+ }
2317
+ writeRect(offset, rect) {
2318
+ const data = this.instances;
2319
+ data[offset] = rect.x;
2320
+ data[offset + 1] = rect.y;
2321
+ data[offset + 2] = rect.width;
2322
+ data[offset + 3] = rect.height;
2323
+ }
2324
+ writeSlot(offset, slot) {
2325
+ const data = this.instances;
2326
+ this.atlas.touch(slot.layer);
2327
+ data[offset + 4] = slot.x;
2328
+ data[offset + 5] = slot.y;
2329
+ data[offset + 6] = slot.x + slot.width;
2330
+ data[offset + 7] = slot.y + slot.height;
2331
+ data[offset + 8] = slot.layer;
2332
+ data[offset + 9] = KIND.snapshot;
2333
+ }
2334
+ /** `rect` showing the snapshot in `slot`, or the placeholder color while there is none. */
2335
+ quad(rect, slot) {
2336
+ const offset = this.next(null);
2337
+ this.writeRect(offset, rect);
2338
+ if (slot) this.writeSlot(offset, slot);
2339
+ else this.instances[offset + 9] = KIND.placeholder;
2340
+ this.instances[offset + 10] = 0;
2341
+ }
2342
+ /** `rect` drawn through `effect` from the snapshot in `slot`, reaching `pad` past its edges. */
2343
+ effectQuad(rect, slot, effect, pad, params) {
2344
+ const offset = this.next(effect);
2345
+ this.writeRect(offset, rect);
2346
+ this.writeSlot(offset, slot);
2347
+ this.instances[offset + 10] = pad;
2348
+ this.instances.set(params, offset + 12);
2349
+ }
2350
+ /** An outline `width` CSS pixels wide just outside `rect`, in a premultiplied color. */
2351
+ ring(rect, color, width) {
2352
+ const offset = this.next(null);
2353
+ this.writeRect(offset, rect);
2354
+ this.instances.set(color, offset + 4);
2355
+ this.instances[offset + 9] = KIND.ring;
2356
+ this.instances[offset + 10] = width;
2357
+ }
2358
+ /** Draws the quads listed since `begin`. */
2359
+ draw(camera, cssWidth, cssHeight, dpr, effects) {
2360
+ const { device, count, runs } = this;
2361
+ this.uniformData.set([
2362
+ cssWidth,
2363
+ cssHeight,
2364
+ camera.x,
2365
+ camera.y,
2366
+ camera.zoom,
2367
+ dpr,
2368
+ this.atlas.size,
2369
+ effects.time,
2370
+ ...this.placeholder,
2371
+ effects.pointer.x,
2372
+ effects.pointer.y,
2373
+ effects.press.x,
2374
+ effects.press.y,
2375
+ effects.sincePress
2376
+ ]);
2377
+ device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
2378
+ if (count > 0) {
2379
+ device.queue.writeBuffer(
2380
+ this.instanceBuffer,
2381
+ 0,
2382
+ this.instances,
2383
+ 0,
2384
+ count * FLOATS_PER_INSTANCE
2385
+ );
2386
+ }
2387
+ const [r, g, b] = this.background;
2388
+ const encoder = device.createCommandEncoder();
2389
+ const pass = encoder.beginRenderPass({
2390
+ colorAttachments: [
2391
+ {
2392
+ view: this.context.getCurrentTexture().createView(),
2393
+ clearValue: { r, g, b, a: 1 },
2394
+ loadOp: "clear",
2395
+ storeOp: "store"
2396
+ }
2397
+ ]
2398
+ });
2399
+ if (count > 0) {
2400
+ pass.setBindGroup(0, this.bindGroup);
2401
+ pass.setVertexBuffer(0, this.instanceBuffer);
2402
+ let current = null;
2403
+ for (const run of runs) {
2404
+ const pipeline = run.effect && this.effects.get(run.effect) || this.pipeline;
2405
+ if (pipeline !== current) pass.setPipeline(current = pipeline);
2406
+ pass.draw(4, run.count, 0, run.start);
2407
+ }
2408
+ }
2409
+ pass.end();
2410
+ device.queue.submit([encoder.finish()]);
2411
+ }
2412
+ destroy() {
2413
+ this.instanceBuffer.destroy();
2414
+ this.uniforms.destroy();
2415
+ }
2416
+ createInstanceBuffer() {
2417
+ return this.device.createBuffer({
2418
+ label: "gpucanvas instances",
2419
+ size: this.instances.byteLength,
2420
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
2421
+ });
2422
+ }
2423
+ };
2424
+
2425
+ // src/gpu-backend.ts
2426
+ var SETTLE_MS = 120;
2427
+ var DEFAULT_PAGE_SIZE = 2048;
2428
+ var MAX_PROMOTIONS_PER_FRAME = 8;
2429
+ var SLOW_FRAME_MS = 22;
2430
+ var FAST_FRAME_MS = 12;
2431
+ function supportsGpuCanvas() {
2432
+ return typeof navigator !== "undefined" && "gpu" in navigator && typeof HTMLCanvasElement !== "undefined" && "requestPaint" in HTMLCanvasElement.prototype && "getElementTransform" in HTMLCanvasElement.prototype && typeof GPUQueue !== "undefined" && "drawElementImageToTexture" in GPUQueue.prototype;
2433
+ }
2434
+ function cssColorToRgba(color) {
2435
+ const context = document.createElement("canvas").getContext("2d");
2436
+ context.fillStyle = color;
2437
+ context.fillRect(0, 0, 1, 1);
2438
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
2439
+ return [r / 255, g / 255, b / 255, a / 255];
2440
+ }
2441
+ var premultiplied = ([r, g, b, a]) => [r * a, g * a, b * a, a];
2442
+ async function createGpuCanvas(host, options) {
2443
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
2444
+ if (!adapter) throw new Error("gpucanvas: no WebGPU adapter");
2445
+ const device = await adapter.requestDevice();
2446
+ const canvas = document.createElement("canvas");
2447
+ canvas.setAttribute("content", "drawable");
2448
+ markSurface(canvas);
2449
+ Object.assign(canvas.style, { display: "block", width: "100%", height: "100%" });
2450
+ const context = canvas.getContext("webgpu");
2451
+ const format = navigator.gpu.getPreferredCanvasFormat();
2452
+ try {
2453
+ if (!context) throw new Error("gpucanvas: no WebGPU canvas context");
2454
+ context.configure({ device, format, alphaMode: "opaque" });
2455
+ } catch (error) {
2456
+ device.destroy();
2457
+ throw error;
2458
+ }
2459
+ const base = createBase(
2460
+ host,
2461
+ options,
2462
+ () => {
2463
+ lastCameraChange = performance.now();
2464
+ live.set(null);
2465
+ clearTimeout(settleTimer);
2466
+ settleTimer = window.setTimeout(() => {
2467
+ live.pick();
2468
+ schedule();
2469
+ }, SETTLE_MS);
2470
+ schedule();
2471
+ },
2472
+ {
2473
+ add(element, rect) {
2474
+ element.style.pointerEvents = "none";
2475
+ const node = createGpuNode(element, rect, pages.place(element, rect));
2476
+ pages.join(node);
2477
+ schedule();
2478
+ return node;
2479
+ },
2480
+ update(node, rect) {
2481
+ ringRects.clear();
2482
+ if (node.rect.width === rect.width && node.rect.height === rect.height) {
2483
+ translateNode(node, rect);
2484
+ live.moved(node);
2485
+ return schedule();
2486
+ }
2487
+ node.rect = rect;
2488
+ relocate(node, () => {
2489
+ pages.release(node);
2490
+ placeNode(node, pages.place(node.element, rect));
2491
+ pages.join(node);
2492
+ });
2493
+ schedule();
2494
+ },
2495
+ remove(node) {
2496
+ live.forget(node);
2497
+ effects.delete(node.element);
2498
+ selection.delete(node.element);
2499
+ pages.release(node);
2500
+ schedule();
2501
+ }
2502
+ }
2503
+ );
2504
+ const { surface, nodes } = base;
2505
+ surface.append(canvas);
2506
+ const atlasSize = Math.min(options.atlasSize ?? 4096, device.limits.maxTextureDimension2D);
2507
+ const atlas = new Atlas(
2508
+ device,
2509
+ atlasSize,
2510
+ Math.min(options.atlasLayers ?? 5, device.limits.maxTextureArrayLayers),
2511
+ () => planner.keep()
2512
+ );
2513
+ const pages = new Pages(canvas, Math.min(options.pageSize ?? DEFAULT_PAGE_SIZE, atlasSize));
2514
+ const rasterizer = new Rasterizer(device, atlas, {
2515
+ calls: options.maxRastersPerFrame ?? 12,
2516
+ ms: options.rasterTimeBudgetMs ?? 6,
2517
+ pixels: options.rasterPixelBudget ?? 24e6
2518
+ });
2519
+ const colorsFor = (css) => {
2520
+ const background = cssColorToRgba(css);
2521
+ const shade = background[0] + background[1] + background[2] > 1.5 ? 0.92 : 1.25;
2522
+ const placeholder = background.map(
2523
+ (channel) => Math.min(1, channel * shade + (shade > 1 ? 0.02 : 0))
2524
+ );
2525
+ placeholder[3] = 1;
2526
+ return [background, placeholder];
2527
+ };
2528
+ const renderer = new CompositeRenderer(
2529
+ device,
2530
+ context,
2531
+ format,
2532
+ atlas,
2533
+ ...colorsFor(options.background ?? "#f4f4f5"),
2534
+ () => schedule()
2535
+ );
2536
+ const selectionColor = premultiplied(
2537
+ cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)
2538
+ );
2539
+ const hoverColor = options.hoverColor ? premultiplied(cssColorToRgba(options.hoverColor)) : selectionColor;
2540
+ const effects = /* @__PURE__ */ new Map();
2541
+ const startedAt = performance.now();
2542
+ let effectsMoving = false;
2543
+ let selection = /* @__PURE__ */ new Set();
2544
+ let hovered = null;
2545
+ const ringRects = /* @__PURE__ */ new Map();
2546
+ const ringRect = (element) => {
2547
+ const own = nodes.get(element);
2548
+ if (own) return own.rect;
2549
+ const at = element.isConnected ? nodeOfElement(nodes, element) : null;
2550
+ if (!at) return null;
2551
+ const node = nodes.get(at);
2552
+ let rect = ringRects.get(element);
2553
+ if (!rect) ringRects.set(element, rect = rectInNode(at, node.rect, element));
2554
+ return rect;
2555
+ };
2556
+ let lastCameraChange = -Infinity;
2557
+ let frameNumber = 0;
2558
+ let promotionsPerFrame = 2;
2559
+ let lastFrameAt = 0;
2560
+ let rafId = 0;
2561
+ let destroyed = false;
2562
+ let settleTimer = 0;
2563
+ let awaitingPaint = false;
2564
+ const requestPaint = () => {
2565
+ awaitingPaint = true;
2566
+ canvas.requestPaint();
2567
+ };
2568
+ const stats = {
2569
+ visible: 0,
2570
+ pending: 0,
2571
+ rasterizedLastFrame: 0,
2572
+ rasterCallsLastFrame: 0,
2573
+ rasterMs: 0,
2574
+ frameMs: 0,
2575
+ frames: 0,
2576
+ lostPixels: 0,
2577
+ lostDetail: 0,
2578
+ onThumbnail: 0
2579
+ };
2580
+ const schedule = () => {
2581
+ if (rafId || destroyed) return;
2582
+ rafId = requestAnimationFrame(() => {
2583
+ rafId = 0;
2584
+ frameNumber++;
2585
+ frame(false);
2586
+ });
2587
+ };
2588
+ const canvasDpr = () => canvas.width / Math.max(1, canvas.clientWidth);
2589
+ const resizeObserver = new ResizeObserver(([entry]) => {
2590
+ const size = entry.devicePixelContentBoxSize?.[0];
2591
+ canvas.width = Math.max(
2592
+ 1,
2593
+ size ? size.inlineSize : Math.round(entry.contentRect.width * devicePixelRatio)
2594
+ );
2595
+ canvas.height = Math.max(
2596
+ 1,
2597
+ size ? size.blockSize : Math.round(entry.contentRect.height * devicePixelRatio)
2598
+ );
2599
+ schedule();
2600
+ });
2601
+ resizeObserver.observe(canvas, { box: "device-pixel-content-box" });
2602
+ const captures = [];
2603
+ const captured = (page) => captures.some((capture) => capture.node.page === page);
2604
+ const isPinned = (page) => captured(page) || live.holds(page);
2605
+ const live = new Live({
2606
+ canvas,
2607
+ surface,
2608
+ nodes,
2609
+ camera: base.camera,
2610
+ view: base.view,
2611
+ nodeAt: base.nodeAt,
2612
+ frameNumber: () => frameNumber,
2613
+ canvasDpr,
2614
+ schedule,
2615
+ isPinned,
2616
+ onPress: () => {
2617
+ if (effects.size) schedule();
2618
+ }
2619
+ });
2620
+ const relocate = (node, move) => {
2621
+ const wasLive = live.current === node;
2622
+ if (wasLive) live.set(null);
2623
+ const previous = node.page;
2624
+ move();
2625
+ if (pages.has(previous) && !isPinned(previous)) {
2626
+ live.unpin(previous);
2627
+ previous.setDrawable(false, frameNumber);
2628
+ }
2629
+ if (wasLive) live.pick();
1887
2630
  };
2631
+ const lift = (node) => relocate(node, () => pages.lift(node));
2632
+ const planner = new Planner(atlas, lift, schedule);
1888
2633
  const composite = () => {
1889
- let instances2 = visible.length;
1890
- for (const node of visible) instances2 += node.tiles.length + (node.preview ? 1 : 0);
1891
- instances2 += selection.size + 1;
1892
- renderer.reserve(instances2);
1893
- const data = renderer.instances;
1894
- let count = 0;
1895
- runs.length = 0;
2634
+ const { visible, detailScale, view } = planner;
2635
+ renderer.begin();
1896
2636
  effectsMoving = false;
1897
- let runEffect = null;
1898
- const startRun = (effect) => {
1899
- if (runs.length && runEffect === effect) return;
1900
- runEffect = effect;
1901
- runs.push({ effect, start: count, count: 0 });
1902
- };
1903
- const push = (rect, slot) => {
1904
- startRun(null);
1905
- runs[runs.length - 1].count++;
1906
- const offset = count++ * FLOATS_PER_INSTANCE;
1907
- data[offset] = rect.x;
1908
- data[offset + 1] = rect.y;
1909
- data[offset + 2] = rect.width;
1910
- data[offset + 3] = rect.height;
1911
- if (slot) {
1912
- atlas.touch(slot.layer);
1913
- data[offset + 4] = slot.x;
1914
- data[offset + 5] = slot.y;
1915
- data[offset + 6] = slot.x + slot.width;
1916
- data[offset + 7] = slot.y + slot.height;
1917
- data[offset + 8] = slot.layer;
1918
- data[offset + 9] = 0;
1919
- } else {
1920
- data[offset + 9] = 1;
1921
- }
1922
- data[offset + 10] = 0;
1923
- };
1924
- const pushEffect = (node, slot, worn) => {
1925
- startRun(worn.effect);
1926
- runs[runs.length - 1].count++;
1927
- const offset = count++ * FLOATS_PER_INSTANCE;
1928
- const { rect } = node;
1929
- data[offset] = rect.x;
1930
- data[offset + 1] = rect.y;
1931
- data[offset + 2] = rect.width;
1932
- data[offset + 3] = rect.height;
1933
- atlas.touch(slot.layer);
1934
- data[offset + 4] = slot.x;
1935
- data[offset + 5] = slot.y;
1936
- data[offset + 6] = slot.x + slot.width;
1937
- data[offset + 7] = slot.y + slot.height;
1938
- data[offset + 8] = slot.layer;
1939
- data[offset + 9] = 0;
1940
- data[offset + 10] = worn.pad;
1941
- data.set(worn.params, offset + 12);
1942
- };
1943
2637
  const pushRing = (element, color, width) => {
1944
2638
  const rect = ringRect(element);
1945
- if (!rect || !intersects(rect, frameView)) return;
1946
- startRun(null);
1947
- runs[runs.length - 1].count++;
1948
- const offset = count++ * FLOATS_PER_INSTANCE;
1949
- data[offset] = rect.x;
1950
- data[offset + 1] = rect.y;
1951
- data[offset + 2] = rect.width;
1952
- data[offset + 3] = rect.height;
1953
- data.set(color, offset + 4);
1954
- data[offset + 9] = 2;
1955
- data[offset + 10] = width;
2639
+ if (rect && intersects(rect, view)) renderer.ring(rect, color, width);
1956
2640
  };
1957
2641
  let onThumbnail = 0;
1958
2642
  for (const node of visible) {
1959
- const base = atlas.isValid(node.slot) ? node.slot : atlas.isValid(node.thumb) ? node.thumb : null;
1960
- if (base && base !== node.slot) onThumbnail++;
1961
- const hasSlot = base !== null;
2643
+ const image = atlas.isValid(node.slot) ? node.slot : atlas.isValid(node.thumb) ? node.thumb : null;
2644
+ if (image && image !== node.slot) onThumbnail++;
2645
+ const hasSlot = image !== null;
1962
2646
  const sharp = node.tiles.length > 0 || node.preview !== null && atlas.isValid(node.preview);
1963
2647
  const shown = sharp ? 3 : hasSlot ? 2 : 1;
1964
2648
  const before = node.shownFrame === stats.frames - 1 ? node.shown : 0;
@@ -1967,44 +2651,47 @@ async function createGpuCanvas(host, options) {
1967
2651
  node.shown = shown;
1968
2652
  node.shownFrame = stats.frames;
1969
2653
  const worn = effects.size ? effects.get(node.element) : void 0;
1970
- if (worn) {
2654
+ if (worn && !renderer.effectFailed(worn.effect)) {
1971
2655
  if (worn.effect.animated) effectsMoving = true;
1972
- if (base && renderer.hasEffect(worn.effect)) {
1973
- pushEffect(node, base, worn);
2656
+ if (image && renderer.hasEffect(worn.effect)) {
2657
+ renderer.effectQuad(node.rect, image, worn.effect, worn.pad, worn.params);
1974
2658
  continue;
1975
2659
  }
1976
2660
  effectsMoving = true;
1977
2661
  }
1978
- push(node.rect, base);
1979
- const current = (tile) => tile.scale === detailScale && !tile.stale;
2662
+ renderer.quad(node.rect, image);
2663
+ const current = (tile) => isCurrentTile(tile, detailScale);
1980
2664
  for (const tile of node.tiles)
1981
- if (!current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
1982
- if (node.preview && atlas.isValid(node.preview)) push(node.previewRect, node.preview);
2665
+ if (!current(tile) && atlas.isValid(tile.slot)) renderer.quad(tile.rect, tile.slot);
2666
+ if (node.preview && atlas.isValid(node.preview))
2667
+ renderer.quad(node.previewRect, node.preview);
1983
2668
  for (const tile of node.tiles)
1984
- if (current(tile) && atlas.isValid(tile.slot)) push(tile.rect, tile.slot);
2669
+ if (current(tile) && atlas.isValid(tile.slot)) renderer.quad(tile.rect, tile.slot);
1985
2670
  }
1986
2671
  stats.onThumbnail = onThumbnail;
1987
2672
  for (const element of selection) pushRing(element, selectionColor, SELECTION_WIDTH);
1988
2673
  if (hovered && !selection.has(hovered)) pushRing(hovered, hoverColor, HOVER_WIDTH);
1989
2674
  const cssWidth = canvas.clientWidth;
1990
- const dpr = canvas.width / Math.max(1, cssWidth);
2675
+ const dpr = canvasDpr();
1991
2676
  const now = performance.now();
1992
- renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, count, runs, {
2677
+ const camera = base.camera();
2678
+ renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, {
1993
2679
  time: (now - startedAt) / 1e3,
1994
- pointer: pointer ? screenToWorld(camera, pointer) : FAR_AWAY,
1995
- press,
1996
- sincePress: Math.min((now - pressedAt) / 1e3, 1e4)
2680
+ pointer: live.pointer ? screenToWorld(camera, live.pointer) : FAR_AWAY,
2681
+ press: live.press,
2682
+ sincePress: Math.min((now - live.pressedAt) / 1e3, 1e4)
1997
2683
  });
1998
2684
  };
1999
2685
  const frame = (inPaint) => {
2000
2686
  if (destroyed) return;
2001
2687
  const start = performance.now();
2002
2688
  atlas.nextFrame();
2003
- const cssWidth = canvas.clientWidth;
2004
- const dpr = canvas.width / Math.max(1, cssWidth);
2005
- const view = visibleWorldRect(camera, cssWidth, canvas.clientHeight);
2689
+ const dpr = canvasDpr();
2690
+ const camera = base.camera();
2691
+ const view = base.view();
2006
2692
  const moving = start - lastCameraChange < SETTLE_MS;
2007
- planFrame(view, rasterScaleFor(camera.zoom * dpr), camera.zoom * dpr, moving);
2693
+ planner.plan(nodes.values(), view, camera.zoom, dpr, moving);
2694
+ const { plans, ordered } = planner;
2008
2695
  if (!inPaint && plans.size > 0) {
2009
2696
  const interval = start - lastFrameAt;
2010
2697
  if (interval < 100) {
@@ -2014,99 +2701,44 @@ async function createGpuCanvas(host, options) {
2014
2701
  }
2015
2702
  lastFrameAt = start;
2016
2703
  let promoted = 0;
2017
- for (const plan of [...plans.values()].sort((a, b) => a.priority - b.priority)) {
2704
+ for (const plan of ordered) {
2018
2705
  if (plan.page.drawable) continue;
2019
2706
  if (promoted++ >= promotionsPerFrame) break;
2020
2707
  plan.page.setDrawable(true, frameNumber);
2021
2708
  }
2022
- awaitingPaint = true;
2023
- canvas.requestPaint();
2709
+ requestPaint();
2024
2710
  return;
2025
2711
  }
2026
2712
  let pending = 0;
2027
2713
  stats.rasterizedLastFrame = 0;
2028
2714
  stats.rasterCallsLastFrame = 0;
2029
- if (inPaint && plans.size > 0) pending = rasterize(start);
2030
- for (const page of pages.keys()) {
2031
- if (page.drawable && !plans.has(page) && page.promotedFrame < frameNumber && !isPinned(page))
2032
- page.setDrawable(false, frameNumber);
2715
+ if (inPaint && plans.size > 0) {
2716
+ const drawn = rasterizer.rasterize(ordered, pages, frameNumber, start, isPinned);
2717
+ pending = drawn.pending;
2718
+ stats.rasterizedLastFrame = drawn.rasterized;
2719
+ stats.rasterCallsLastFrame = drawn.calls;
2033
2720
  }
2721
+ pages.parkIdle(plans, frameNumber, isPinned);
2034
2722
  stats.rasterMs = performance.now() - start;
2035
2723
  composite();
2036
2724
  stats.frames++;
2037
- positionLivePage();
2038
- stats.visible = visible.length;
2725
+ live.position();
2726
+ stats.visible = planner.visible.length;
2039
2727
  stats.pending = pending;
2040
2728
  stats.frameMs = performance.now() - start;
2041
2729
  if (pending > 0 || effectsMoving) schedule();
2042
2730
  };
2043
- let awaitingPaint = false;
2044
- const drawCaptures = () => {
2045
- for (const capture of [...captures]) {
2046
- const { node, scale } = capture;
2047
- const width = Math.max(1, Math.round(node.rect.width * scale));
2048
- const height = Math.max(1, Math.round(node.rect.height * scale));
2049
- const texture = device.createTexture({
2050
- size: { width, height },
2051
- format: "rgba8unorm",
2052
- usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
2053
- });
2054
- try {
2055
- device.queue.drawElementImageToTexture(
2056
- {
2057
- source: node.page.element,
2058
- sourceX: node.pageX,
2059
- sourceY: node.pageY,
2060
- sourceWidth: node.rect.width,
2061
- sourceHeight: node.rect.height
2062
- },
2063
- { texture, size: { width, height }, premultipliedAlpha: false }
2064
- );
2065
- } catch (error) {
2066
- texture.destroy();
2067
- if (++capture.tries > 20) {
2068
- captures.splice(captures.indexOf(capture), 1);
2069
- capture.reject(error instanceof Error ? error : new Error(String(error)));
2070
- }
2071
- continue;
2072
- }
2073
- captures.splice(captures.indexOf(capture), 1);
2074
- readTexture(texture, width, height).then(capture.resolve, capture.reject);
2075
- }
2731
+ canvas.onpaint = (event) => {
2076
2732
  if (captures.length) {
2077
- awaitingPaint = true;
2078
- canvas.requestPaint();
2079
- } else schedule();
2080
- };
2081
- const readTexture = async (texture, width, height) => {
2082
- const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
2083
- const buffer = device.createBuffer({
2084
- size: bytesPerRow * height,
2085
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
2086
- });
2087
- const encoder = device.createCommandEncoder();
2088
- encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, { width, height });
2089
- device.queue.submit([encoder.finish()]);
2090
- try {
2091
- await buffer.mapAsync(GPUMapMode.READ);
2092
- const rows = new Uint8Array(buffer.getMappedRange());
2093
- const pixels = new Uint8ClampedArray(width * height * 4);
2094
- for (let y = 0; y < height; y++) {
2095
- pixels.set(rows.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
2096
- }
2097
- return new ImageData(pixels, width, height);
2098
- } finally {
2099
- buffer.destroy();
2100
- texture.destroy();
2733
+ rasterizer.capture(captures);
2734
+ if (captures.length) requestPaint();
2735
+ else schedule();
2101
2736
  }
2102
- };
2103
- canvas.onpaint = (event) => {
2104
- if (captures.length) drawCaptures();
2105
2737
  let changed = false;
2106
2738
  for (const element of event.changedElements) {
2107
- const page = pageByElement.get(element);
2739
+ const page = pages.pageOf(element);
2108
2740
  if (!page) continue;
2109
- for (const node of [live, lingering, focused, ...transitioning.keys()]) {
2741
+ for (const node of live.held()) {
2110
2742
  if (node?.page === page) {
2111
2743
  node.dirty = true;
2112
2744
  changed = true;
@@ -2118,26 +2750,18 @@ async function createGpuCanvas(host, options) {
2118
2750
  awaitingPaint = false;
2119
2751
  frame(true);
2120
2752
  };
2121
- const viewNow = () => visibleWorldRect(camera, canvas.clientWidth, canvas.clientHeight);
2122
- const nodeOf = (target) => {
2123
- let element = target instanceof HTMLElement ? target : target.parentElement;
2124
- while (element?.parentElement && !pageByElement.has(element.parentElement)) {
2125
- element = element.parentElement;
2126
- }
2127
- return element ? nodes.get(element) : void 0;
2128
- };
2129
2753
  const mutations = new MutationObserver((records) => {
2130
2754
  ringRects.clear();
2131
2755
  let changed = false;
2132
2756
  for (const record of records) {
2133
2757
  const target = record.target;
2134
- if (record.type === "attributes" && (nodes.has(target) || pageByElement.has(target) || target === canvas)) {
2758
+ if (record.type === "attributes" && (nodes.has(target) || pages.pageOf(target) || target === canvas)) {
2135
2759
  continue;
2136
2760
  }
2137
- const node = nodeOf(record.target);
2761
+ const node = nodeOf(nodes, record.target);
2138
2762
  if (node && !node.dirty) {
2139
2763
  node.dirty = true;
2140
- if (!changed) changed = intersects(node.rect, viewNow());
2764
+ if (!changed) changed = intersects(node.rect, base.view());
2141
2765
  }
2142
2766
  }
2143
2767
  if (changed) schedule();
@@ -2148,150 +2772,8 @@ async function createGpuCanvas(host, options) {
2148
2772
  characterData: true,
2149
2773
  attributes: true
2150
2774
  });
2151
- const unpin = (page) => {
2152
- if (isPinned(page)) return;
2153
- if (page.registered) canvas.clearElementGeometry(page.element);
2154
- page.registered = false;
2155
- page.element.style.transform = "";
2156
- schedule();
2157
- };
2158
- const positionLivePage = () => {
2159
- if (!live || livePositioned) return;
2160
- const { zoom } = camera;
2161
- const x = (live.rect.x - camera.x - live.pageX) * zoom;
2162
- const y = (live.rect.y - camera.y - live.pageY) * zoom;
2163
- try {
2164
- if (CANVAS_TRANSFORM_HIT_TESTS) {
2165
- canvas.updateElementGeometry(live.page.element, {
2166
- canvasTransform: new DOMMatrix([zoom, 0, 0, zoom, x, y])
2167
- });
2168
- } else {
2169
- const dpr = canvas.width / Math.max(1, canvas.clientWidth);
2170
- const drawn2 = new DOMMatrix([zoom, 0, 0, zoom, x * dpr, y * dpr]);
2171
- live.page.element.style.transform = canvas.getElementTransform(live.page.element, drawn2).toString();
2172
- }
2173
- live.element.style.pointerEvents = "auto";
2174
- livePositioned = true;
2175
- } catch {
2176
- schedule();
2177
- }
2178
- };
2179
- const setLive = (node) => {
2180
- if (node === live) return;
2181
- const previous = live;
2182
- live = node;
2183
- if (previous) {
2184
- previous.element.style.pointerEvents = "none";
2185
- clearTimeout(lingerTimer);
2186
- const earlier = lingering;
2187
- lingering = previous;
2188
- if (earlier && earlier !== previous) {
2189
- earlier.dirty = true;
2190
- unpin(earlier.page);
2191
- }
2192
- lingerTimer = window.setTimeout(() => {
2193
- const last = lingering;
2194
- if (!last) return;
2195
- lingering = null;
2196
- last.dirty = true;
2197
- unpin(last.page);
2198
- }, LINGER_MS);
2199
- unpin(previous.page);
2200
- }
2201
- if (!node) return;
2202
- const { page } = node;
2203
- page.setDrawable(true, frameNumber);
2204
- if (!page.registered) {
2205
- canvas.updateElementGeometry(page.element, {});
2206
- page.registered = true;
2207
- }
2208
- livePositioned = false;
2209
- positionLivePage();
2210
- schedule();
2211
- };
2212
- const pickLive = () => {
2213
- if (!pointer) return setLive(null);
2214
- const point = screenToWorld(camera, pointer);
2215
- let hit = null;
2216
- for (const node of nodes.values()) if (containsPoint(node.rect, point)) hit = node;
2217
- setLive(hit);
2218
- };
2219
- const onPointerMove = (event) => {
2220
- const bounds = canvas.getBoundingClientRect();
2221
- pointer = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
2222
- if (event.buttons === 0) pickLive();
2223
- };
2224
- const onPointerLeave = () => {
2225
- pointer = null;
2226
- setLive(null);
2227
- };
2228
- let press = FAR_AWAY;
2229
- let pressedAt = -Infinity;
2230
- let tapped = false;
2231
- const onPointerDown = (event) => {
2232
- const bounds = canvas.getBoundingClientRect();
2233
- const at = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
2234
- press = screenToWorld(camera, at);
2235
- pressedAt = performance.now();
2236
- if (event.pointerType !== "mouse") {
2237
- pointer = at;
2238
- pickLive();
2239
- tapped = true;
2240
- }
2241
- if (effects.size) schedule();
2242
- };
2243
- const onClick = (event) => {
2244
- if (!tapped) return;
2245
- tapped = false;
2246
- const bounds = canvas.getBoundingClientRect();
2247
- const point = screenToWorld(camera, {
2248
- x: event.clientX - bounds.left,
2249
- y: event.clientY - bounds.top
2250
- });
2251
- let top = null;
2252
- for (const node of nodes.values()) if (containsPoint(node.rect, point)) top = node;
2253
- if (!top || top.element.contains(event.target)) return;
2254
- let target = elementInNode(top.element, top.rect, point);
2255
- while (target && !(target instanceof HTMLElement)) target = target.parentElement;
2256
- if (!(target instanceof HTMLElement)) return;
2257
- target.focus({ preventScroll: true });
2258
- target.dispatchEvent(
2259
- new MouseEvent("click", {
2260
- bubbles: true,
2261
- cancelable: true,
2262
- composed: true,
2263
- view: window,
2264
- clientX: event.clientX,
2265
- clientY: event.clientY
2266
- })
2267
- );
2268
- };
2269
- const onFocusIn = (event) => {
2270
- const node = nodeOf(event.target) ?? null;
2271
- if (node === focused) return;
2272
- const previous = focused;
2273
- focused = node;
2274
- if (previous) {
2275
- previous.dirty = true;
2276
- unpin(previous.page);
2277
- }
2278
- if (node) {
2279
- node.page.setDrawable(true, frameNumber);
2280
- node.dirty = true;
2281
- schedule();
2282
- }
2283
- };
2284
- const onFocusOut = (event) => {
2285
- if (event.relatedTarget && canvas.contains(event.relatedTarget)) return;
2286
- const previous = focused;
2287
- focused = null;
2288
- if (previous) {
2289
- previous.dirty = true;
2290
- unpin(previous.page);
2291
- }
2292
- };
2293
2775
  const onResourceLoad = (event) => {
2294
- const node = nodeOf(event.target);
2776
+ const node = nodeOf(nodes, event.target);
2295
2777
  if (!node) return;
2296
2778
  node.dirty = true;
2297
2779
  schedule();
@@ -2302,212 +2784,13 @@ async function createGpuCanvas(host, options) {
2302
2784
  schedule();
2303
2785
  };
2304
2786
  document.fonts.addEventListener("loadingdone", onFontsLoaded);
2305
- surface.addEventListener("pointermove", onPointerMove);
2306
- surface.addEventListener("pointerleave", onPointerLeave);
2307
- surface.addEventListener("pointerdown", onPointerDown, true);
2308
- surface.addEventListener("click", onClick);
2309
- canvas.addEventListener("focusin", onFocusIn);
2310
- canvas.addEventListener("focusout", onFocusOut);
2311
- const usedAt = /* @__PURE__ */ new WeakMap();
2312
- const onUse = (event) => {
2313
- const node = nodeOf(event.target);
2314
- if (node) usedAt.set(node, performance.now());
2315
- };
2316
- const transitionMs = (element) => {
2317
- const style = getComputedStyle(element);
2318
- const seconds = (list) => list.split(",").map((value) => parseFloat(value) || 0);
2319
- const durations = seconds(style.transitionDuration);
2320
- const delays = seconds(style.transitionDelay);
2321
- const longest = Math.max(...durations.map((d, i) => d + (delays[i % delays.length] ?? 0)));
2322
- return Math.min(1e3, longest * 1e3);
2323
- };
2324
- const onTransitionRun = (event) => {
2325
- const node = nodeOf(event.target);
2326
- const now = performance.now();
2327
- if (!node || now - (usedAt.get(node) ?? -Infinity) > USED_MS) return;
2328
- if (!intersects(node.rect, viewNow())) return;
2329
- if (Math.max(node.rect.width, node.rect.height) * camera.zoom < SMALL_NODE_PX) return;
2330
- const ms = transitionMs(event.target) + 32;
2331
- const end = now + ms;
2332
- if ((transitioning.get(node) ?? 0) >= end) return;
2333
- transitioning.set(node, end);
2334
- node.dirty = true;
2335
- schedule();
2336
- setTimeout(() => {
2337
- if (transitioning.get(node) !== end) return;
2338
- transitioning.delete(node);
2339
- node.dirty = true;
2340
- unpin(node.page);
2341
- schedule();
2342
- }, ms);
2343
- };
2344
- for (const type of USE_EVENTS) canvas.addEventListener(type, onUse, true);
2345
- canvas.addEventListener("transitionrun", onTransitionRun);
2346
- const setCamera = (next) => {
2347
- camera = next;
2348
- lastCameraChange = performance.now();
2349
- setLive(null);
2350
- clearTimeout(settleTimer);
2351
- settleTimer = window.setTimeout(() => {
2352
- pickLive();
2353
- schedule();
2354
- }, SETTLE_MS);
2355
- schedule();
2356
- options.onCameraChange?.(camera);
2357
- };
2358
- const unbindInput = bindCameraInput(surface, () => camera, setCamera);
2359
- const placeNode = (element, rect) => {
2360
- let slot = openPage?.place(rect.width, rect.height) ?? null;
2361
- if (!slot) {
2362
- const page = new Page(
2363
- Math.max(pageSize, Math.ceil(rect.width)),
2364
- Math.max(pageSize, Math.ceil(rect.height))
2365
- );
2366
- pages.set(page, /* @__PURE__ */ new Set());
2367
- pageByElement.set(page.element, page);
2368
- canvas.append(page.element);
2369
- if (page.width === pageSize && page.height === pageSize) openPage = page;
2370
- slot = page.place(rect.width, rect.height);
2371
- }
2372
- Object.assign(element.style, {
2373
- position: "absolute",
2374
- left: `${slot.x}px`,
2375
- top: `${slot.y}px`,
2376
- width: `${rect.width}px`,
2377
- height: `${rect.height}px`
2378
- });
2379
- slot.page.element.append(element);
2380
- return slot;
2381
- };
2382
- const releaseNode = (node) => {
2383
- const members = pages.get(node.page);
2384
- members.delete(node);
2385
- node.page.release();
2386
- if (members.size === 0 && node.page !== openPage) {
2387
- pages.delete(node.page);
2388
- pageByElement.delete(node.page.element);
2389
- node.page.element.remove();
2390
- }
2391
- };
2392
- const lift = (node) => {
2393
- const previous = node.page;
2394
- const wasLive = live === node;
2395
- if (wasLive) setLive(null);
2396
- const page = new Page(Math.ceil(node.rect.width), Math.ceil(node.rect.height));
2397
- pages.set(page, /* @__PURE__ */ new Set());
2398
- pageByElement.set(page.element, page);
2399
- canvas.append(page.element);
2400
- page.place(node.rect.width, node.rect.height);
2401
- const target = page.element;
2402
- if (target.moveBefore) target.moveBefore(node.element, null);
2403
- else target.append(node.element);
2404
- node.element.style.left = "0px";
2405
- node.element.style.top = "0px";
2406
- releaseNode(node);
2407
- node.page = page;
2408
- node.pageX = 0;
2409
- node.pageY = 0;
2410
- node.solo = true;
2411
- pages.get(page).add(node);
2412
- if (pages.has(previous) && !isPinned(previous)) {
2413
- unpin(previous);
2414
- previous.setDrawable(false, frameNumber);
2415
- }
2416
- if (wasLive) pickLive();
2417
- };
2418
- const attach = (node) => {
2419
- const slot = placeNode(node.element, node.rect);
2420
- node.page = slot.page;
2421
- node.pageX = slot.x;
2422
- node.pageY = slot.y;
2423
- node.slot = null;
2424
- node.thumb = null;
2425
- node.solo = false;
2426
- node.dirty = true;
2427
- pages.get(slot.page).add(node);
2428
- };
2429
2787
  return {
2788
+ ...base.api,
2430
2789
  mode: "gpu",
2431
- surface,
2432
- addNode(element, rect) {
2433
- Object.assign(element.style, {
2434
- boxSizing: "border-box",
2435
- contain: "strict",
2436
- overflow: "hidden",
2437
- pointerEvents: "none"
2438
- });
2439
- const node = {
2440
- element,
2441
- rect,
2442
- slotScale: 0,
2443
- thumb: null,
2444
- thumbStale: false,
2445
- thumbAt: -Infinity,
2446
- shown: 0,
2447
- shownFrame: -1,
2448
- targetScale: 0,
2449
- tiles: [],
2450
- preview: null,
2451
- previewRect: null,
2452
- previewScale: 0,
2453
- previewTarget: null,
2454
- previewTargetScale: 0,
2455
- changedAt: -Infinity,
2456
- refreshedAt: -Infinity,
2457
- wholeStale: false,
2458
- solo: false,
2459
- priority: 0
2460
- };
2461
- attach(node);
2462
- nodes.set(element, node);
2463
- schedule();
2464
- },
2465
- updateNode(element, rect) {
2466
- const node = nodes.get(element);
2467
- if (!node) return;
2468
- const resized = node.rect.width !== rect.width || node.rect.height !== rect.height;
2469
- node.rect = rect;
2470
- ringRects.clear();
2471
- if (resized) {
2472
- if (live === node) setLive(null);
2473
- releaseNode(node);
2474
- attach(node);
2475
- }
2476
- schedule();
2477
- },
2478
- removeNode(element) {
2479
- const node = nodes.get(element);
2480
- if (!node) return;
2481
- if (live === node) setLive(null);
2482
- if (lingering === node) lingering = null;
2483
- if (focused === node) focused = null;
2484
- transitioning.delete(node);
2485
- effects.delete(element);
2486
- selection.delete(element);
2487
- nodes.delete(element);
2488
- releaseNode(node);
2489
- element.remove();
2490
- schedule();
2491
- },
2492
- getNodeRect: (element) => {
2493
- const node = nodes.get(element);
2494
- return node ? { ...node.rect } : null;
2495
- },
2496
- getNodesIn(rect) {
2497
- const hits = [];
2498
- for (const node of nodes.values()) if (intersects(node.rect, rect)) hits.push(node.element);
2499
- return hits;
2500
- },
2501
- getNodeOf: (element) => nodeOfElement(nodes, element),
2502
2790
  getElementRect(element) {
2503
2791
  const rect = ringRect(element);
2504
2792
  return rect ? { ...rect } : null;
2505
2793
  },
2506
- getElementAt(point) {
2507
- let top = null;
2508
- for (const node of nodes.values()) if (containsPoint(node.rect, point)) top = node;
2509
- return top ? elementInNode(top.element, top.rect, point) : null;
2510
- },
2511
2794
  setSelection(elements) {
2512
2795
  selection = new Set(elements);
2513
2796
  ringRects.clear();
@@ -2527,10 +2810,6 @@ async function createGpuCanvas(host, options) {
2527
2810
  } else effects.delete(element);
2528
2811
  schedule();
2529
2812
  },
2530
- screenToWorld: (point) => screenToWorld(camera, point),
2531
- worldToScreen: (point) => worldToScreen(camera, point),
2532
- getCamera: () => camera,
2533
- setCamera,
2534
2813
  invalidate: onFontsLoaded,
2535
2814
  snapshot(element, options2 = {}) {
2536
2815
  const node = nodes.get(element);
@@ -2541,18 +2820,13 @@ async function createGpuCanvas(host, options) {
2541
2820
  return new Promise((resolve, reject) => {
2542
2821
  captures.push({ node, scale, resolve, reject, tries: 0 });
2543
2822
  node.page.setDrawable(true, frameNumber);
2544
- awaitingPaint = true;
2545
- canvas.requestPaint();
2823
+ requestPaint();
2546
2824
  });
2547
2825
  },
2548
2826
  setBackground(color) {
2549
2827
  [renderer.background, renderer.placeholder] = colorsFor(color);
2550
2828
  schedule();
2551
2829
  },
2552
- fit(padding) {
2553
- const bounds = boundsOf([...nodes.values()].map((node) => node.rect));
2554
- if (bounds) setCamera(fitCamera(bounds, canvas.clientWidth, canvas.clientHeight, padding));
2555
- },
2556
2830
  getStats: () => ({
2557
2831
  mode: "gpu",
2558
2832
  nodes: nodes.size,
@@ -2562,7 +2836,7 @@ async function createGpuCanvas(host, options) {
2562
2836
  rasterCallsLastFrame: stats.rasterCallsLastFrame,
2563
2837
  rasterMs: stats.rasterMs,
2564
2838
  pages: pages.size,
2565
- drawablePages: [...pages.keys()].filter((page) => page.drawable).length,
2839
+ drawablePages: [...pages.all()].filter((page) => page.drawable).length,
2566
2840
  atlasLayersUsed: atlas.usedLayers,
2567
2841
  frameMs: stats.frameMs,
2568
2842
  frames: stats.frames,
@@ -2570,29 +2844,20 @@ async function createGpuCanvas(host, options) {
2570
2844
  lostDetail: stats.lostDetail,
2571
2845
  thumbnailFill: atlas.pinnedFill,
2572
2846
  onThumbnail: stats.onThumbnail,
2573
- needs: stats.needs
2847
+ needs: planner.needs
2574
2848
  }),
2575
2849
  destroy() {
2576
2850
  destroyed = true;
2577
2851
  cancelAnimationFrame(rafId);
2578
2852
  clearTimeout(settleTimer);
2579
- clearTimeout(lingerTimer);
2580
- clearTimeout(contentTimer);
2853
+ live.destroy();
2854
+ planner.destroy();
2581
2855
  mutations.disconnect();
2582
2856
  document.fonts.removeEventListener("loadingdone", onFontsLoaded);
2583
2857
  canvas.removeEventListener("load", onResourceLoad, true);
2584
- surface.removeEventListener("pointermove", onPointerMove);
2585
- surface.removeEventListener("pointerleave", onPointerLeave);
2586
- surface.removeEventListener("pointerdown", onPointerDown, true);
2587
- surface.removeEventListener("click", onClick);
2588
- canvas.removeEventListener("focusin", onFocusIn);
2589
- canvas.removeEventListener("focusout", onFocusOut);
2590
- canvas.removeEventListener("transitionrun", onTransitionRun);
2591
- for (const type of USE_EVENTS) canvas.removeEventListener(type, onUse, true);
2592
- unbindInput();
2593
2858
  resizeObserver.disconnect();
2594
2859
  canvas.onpaint = null;
2595
- surface.remove();
2860
+ base.destroy();
2596
2861
  renderer.destroy();
2597
2862
  atlas.destroy();
2598
2863
  device.destroy();
@@ -2651,13 +2916,13 @@ var labelText = (element) => {
2651
2916
  }
2652
2917
  return text.replace(/\s+/g, " ").trim();
2653
2918
  };
2654
- var sameRect = (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;
2655
2919
  var round = (rect) => ({
2656
2920
  x: Math.round(rect.x),
2657
2921
  y: Math.round(rect.y),
2658
2922
  width: Math.round(rect.width),
2659
2923
  height: Math.round(rect.height)
2660
2924
  });
2925
+ var MAX_CAPTURE_PX = 8192;
2661
2926
  function createAgentView(canvas) {
2662
2927
  const ids = /* @__PURE__ */ new WeakMap();
2663
2928
  const elements = /* @__PURE__ */ new Map();
@@ -2703,11 +2968,7 @@ function createAgentView(canvas) {
2703
2968
  return {
2704
2969
  /** What is in `rect` (default: the view), node by node, as plain JSON. */
2705
2970
  describe(options = {}) {
2706
- const area = options.rect ?? visibleWorldRect(
2707
- canvas.getCamera(),
2708
- canvas.surface.clientWidth,
2709
- canvas.surface.clientHeight
2710
- );
2971
+ const area = options.rect ?? viewOf(canvas);
2711
2972
  return canvas.getNodesIn(area).flatMap((node) => read(node, options.depth ?? 6, options));
2712
2973
  },
2713
2974
  /** The element behind an id from `describe`, or null once it is gone. */
@@ -2725,9 +2986,9 @@ function createAgentView(canvas) {
2725
2986
  * `scale` texels per world unit. GPU backend only.
2726
2987
  */
2727
2988
  async capture(target, { scale = 1, background } = {}) {
2728
- const area = typeof target === "object" && "width" in target ? target : this.rect(target);
2989
+ const area = typeof target === "string" || target instanceof Element ? this.rect(target) : target;
2729
2990
  if (!area) throw new Error("Nothing to capture");
2730
- const fit = Math.min(scale, 8192 / area.width, 8192 / area.height);
2991
+ const fit = Math.min(scale, MAX_CAPTURE_PX / area.width, MAX_CAPTURE_PX / area.height);
2731
2992
  const width = Math.max(1, Math.round(area.width * fit));
2732
2993
  const height = Math.max(1, Math.round(area.height * fit));
2733
2994
  const out = new OffscreenCanvas(width, height);
@@ -2742,7 +3003,13 @@ function createAgentView(canvas) {
2742
3003
  const rect = canvas.getNodeRect(node);
2743
3004
  const bitmap = new OffscreenCanvas(images[i].width, images[i].height);
2744
3005
  bitmap.getContext("2d").putImageData(images[i], 0, 0);
2745
- context.drawImage(bitmap, (rect.x - area.x) * fit, (rect.y - area.y) * fit);
3006
+ context.drawImage(
3007
+ bitmap,
3008
+ (rect.x - area.x) * fit,
3009
+ (rect.y - area.y) * fit,
3010
+ rect.width * fit,
3011
+ rect.height * fit
3012
+ );
2746
3013
  });
2747
3014
  return context.getImageData(0, 0, width, height);
2748
3015
  }
@@ -2752,25 +3019,7 @@ function createAgentView(canvas) {
2752
3019
  // src/layers.ts
2753
3020
  var sameLayer = (a, b) => !!a && !!b && a[0] === b[0];
2754
3021
  var layerIn = (layer, list) => list.some((other) => sameLayer(layer, other));
2755
- function layerRect(canvas, layer) {
2756
- let union = null;
2757
- for (const element of layer) {
2758
- const rect = canvas.getElementRect(element);
2759
- if (rect) union = union ? unionRect(union, rect) : rect;
2760
- }
2761
- return union;
2762
- }
2763
- function unionRect(a, b) {
2764
- const x = Math.min(a.x, b.x);
2765
- const y = Math.min(a.y, b.y);
2766
- return {
2767
- x,
2768
- y,
2769
- width: Math.max(a.x + a.width, b.x + b.width) - x,
2770
- height: Math.max(a.y + a.height, b.y + b.height) - y
2771
- };
2772
- }
2773
- var sameRect2 = (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;
3022
+ var layerRect = (canvas, layer) => unionOf(layer.map((element) => canvas.getElementRect(element)));
2774
3023
  function createLayers(canvas, pick) {
2775
3024
  const chainOf = (element) => {
2776
3025
  const node = canvas.getNodeOf(element);
@@ -2789,7 +3038,7 @@ function createLayers(canvas, pick) {
2789
3038
  let previous = canvas.getNodeRect(node);
2790
3039
  for (const layer of inner) {
2791
3040
  const rect = layerRect(canvas, layer);
2792
- if (sameRect2(rect, previous)) continue;
3041
+ if (sameRect(rect, previous)) continue;
2793
3042
  chain.push(layer);
2794
3043
  previous = rect;
2795
3044
  }
@@ -2807,7 +3056,7 @@ function createLayers(canvas, pick) {
2807
3056
  for (const child of element.children) {
2808
3057
  const rect = canvas.getElementRect(child);
2809
3058
  if (!rect || rect.width <= 0 || rect.height <= 0) continue;
2810
- if (sameRect2(rect, outer)) visit(child, outer);
3059
+ if (sameRect(rect, outer)) visit(child, outer);
2811
3060
  else children.push([child]);
2812
3061
  }
2813
3062
  };
@@ -2834,6 +3083,16 @@ function createLayers(canvas, pick) {
2834
3083
 
2835
3084
  // src/overlay.ts
2836
3085
  var HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
3086
+ var HANDLE_ANCHORS = {
3087
+ nw: { x: 0, y: 0 },
3088
+ n: { x: 0.5, y: 0 },
3089
+ ne: { x: 1, y: 0 },
3090
+ e: { x: 1, y: 0.5 },
3091
+ se: { x: 1, y: 1 },
3092
+ s: { x: 0.5, y: 1 },
3093
+ sw: { x: 0, y: 1 },
3094
+ w: { x: 0, y: 0.5 }
3095
+ };
2837
3096
  var HANDLE_PX = 8;
2838
3097
  var CURSORS = {
2839
3098
  nw: "nwse-resize",
@@ -2855,14 +3114,14 @@ var pill = (color) => ({
2855
3114
  whiteSpace: "nowrap",
2856
3115
  pointerEvents: "none"
2857
3116
  });
2858
- function createOverlay(surface, color, hoverColor) {
3117
+ function createOverlay(surface, color) {
2859
3118
  const root = document.createElement("div");
2860
3119
  Object.assign(root.style, {
2861
3120
  position: "absolute",
2862
3121
  inset: "0",
2863
3122
  overflow: "hidden",
2864
3123
  pointerEvents: "none",
2865
- zIndex: "2"
3124
+ zIndex: SURFACE_Z.overlay
2866
3125
  });
2867
3126
  const make = (style) => {
2868
3127
  const element = document.createElement("div");
@@ -2870,7 +3129,7 @@ function createOverlay(surface, color, hoverColor) {
2870
3129
  root.append(element);
2871
3130
  return element;
2872
3131
  };
2873
- const hoverName = make(pill(hoverColor));
3132
+ const hoverName = make(pill(color));
2874
3133
  const name = make(pill(color));
2875
3134
  const size = make(pill(color));
2876
3135
  const bounds = make({ position: "absolute", border: `1px solid ${color}` });
@@ -2928,12 +3187,11 @@ function createOverlay(surface, color, hoverColor) {
2928
3187
  }
2929
3188
  if (state.handles) {
2930
3189
  HANDLES.forEach((handle, i) => {
2931
- const fx = handle.includes("w") ? 0 : handle.includes("e") ? 1 : 0.5;
2932
- const fy = handle.includes("n") ? 0 : handle.includes("s") ? 1 : 0.5;
3190
+ const anchor = HANDLE_ANCHORS[handle];
2933
3191
  place(
2934
3192
  handles[i],
2935
- rect.x + rect.width * fx - HANDLE_PX / 2,
2936
- rect.y + rect.height * fy - HANDLE_PX / 2
3193
+ rect.x + rect.width * anchor.x - HANDLE_PX / 2,
3194
+ rect.y + rect.height * anchor.y - HANDLE_PX / 2
2937
3195
  );
2938
3196
  });
2939
3197
  }
@@ -2955,9 +3213,10 @@ function createOverlay(surface, color, hoverColor) {
2955
3213
  }
2956
3214
  };
2957
3215
  }
2958
- var guideRect = (guide, toScreen) => {
2959
- const a = toScreen(guide.x1, guide.y1);
2960
- const b = toScreen(guide.x2, guide.y2);
3216
+ var handleAt = (target) => target instanceof Element ? target.closest("[data-gpucanvas-handle]")?.dataset.gpucanvasHandle ?? null : null;
3217
+ var guideRect = (guide, camera) => {
3218
+ const a = worldToScreen(camera, { x: guide.x1, y: guide.y1 });
3219
+ const b = worldToScreen(camera, { x: guide.x2, y: guide.y2 });
2961
3220
  return {
2962
3221
  x: Math.min(a.x, b.x),
2963
3222
  y: Math.min(a.y, b.y),
@@ -3004,22 +3263,22 @@ function guidesFor(rect, targets, edges) {
3004
3263
  const hits = targets.filter((target) => anchorsX(target).some((at) => close(at, x)));
3005
3264
  if (!hits.length) continue;
3006
3265
  const top = Math.min(rect.y, ...hits.map((t) => t.y));
3007
- const bottom2 = Math.max(rect.y + rect.height, ...hits.map((t) => t.y + t.height));
3008
- guides.push({ x1: x, y1: top, x2: x, y2: bottom2 });
3266
+ const bottom = Math.max(rect.y + rect.height, ...hits.map((t) => t.y + t.height));
3267
+ guides.push({ x1: x, y1: top, x2: x, y2: bottom });
3009
3268
  }
3010
3269
  for (const f of edges.y) {
3011
3270
  const y = rect.y + rect.height * f;
3012
3271
  const hits = targets.filter((target) => anchorsY(target).some((at) => close(at, y)));
3013
3272
  if (!hits.length) continue;
3014
3273
  const left = Math.min(rect.x, ...hits.map((t) => t.x));
3015
- const right2 = Math.max(rect.x + rect.width, ...hits.map((t) => t.x + t.width));
3016
- guides.push({ x1: left, y1: y, x2: right2, y2: y });
3274
+ const right = Math.max(rect.x + rect.width, ...hits.map((t) => t.x + t.width));
3275
+ guides.push({ x1: left, y1: y, x2: right, y2: y });
3017
3276
  }
3018
3277
  return guides;
3019
3278
  }
3020
3279
 
3021
3280
  // src/editor.ts
3022
- function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world) => canvas.getNodesIn(world)) {
3281
+ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR) {
3023
3282
  const box = document.createElement("div");
3024
3283
  Object.assign(box.style, {
3025
3284
  position: "absolute",
@@ -3027,11 +3286,11 @@ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world)
3027
3286
  pointerEvents: "none",
3028
3287
  border: `1px solid ${color}`,
3029
3288
  background: `color-mix(in srgb, ${color} 12%, transparent)`,
3030
- zIndex: "1"
3289
+ zIndex: SURFACE_Z.marquee
3031
3290
  });
3032
3291
  canvas.surface.append(box);
3033
3292
  return {
3034
- /** Spans the box from `a` to `b` (surface pixels) and selects what it covers. */
3293
+ /** Spans the box from `a` to `b` (surface pixels) and returns the world rect it covers. */
3035
3294
  update(a, b) {
3036
3295
  const x = Math.min(a.x, b.x);
3037
3296
  const y = Math.min(a.y, b.y);
@@ -3045,13 +3304,11 @@ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world)
3045
3304
  height: `${height}px`
3046
3305
  });
3047
3306
  const camera = canvas.getCamera();
3048
- const hits = select({
3307
+ return {
3049
3308
  ...screenToWorld(camera, { x, y }),
3050
3309
  width: width / camera.zoom,
3051
3310
  height: height / camera.zoom
3052
- });
3053
- canvas.setSelection(hits);
3054
- return hits;
3311
+ };
3055
3312
  },
3056
3313
  hide() {
3057
3314
  box.style.display = "none";
@@ -3061,9 +3318,11 @@ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world)
3061
3318
  }
3062
3319
  };
3063
3320
  }
3321
+ var defaultLabel = (canvas) => (layer) => canvas.getNodeOf(layer[0]) === layer[0] ? null : layer[0].localName;
3064
3322
  var CLICK_SLOP = 4;
3065
3323
  var SNAP_PX = 6;
3066
3324
  var MAX_NAMED = 1;
3325
+ var EVERYWHERE = { x: -1e9, y: -1e9, width: 2e9, height: 2e9 };
3067
3326
  var isEditable = (element) => !!element && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement || element.isContentEditable);
3068
3327
  function bindEditor(canvas, options = {}) {
3069
3328
  const {
@@ -3077,16 +3336,12 @@ function bindEditor(canvas, options = {}) {
3077
3336
  onChange,
3078
3337
  onTransform
3079
3338
  } = options;
3080
- const label = options.label ?? ((layer) => canvas.getNodeOf(layer[0]) === layer[0] ? null : layer[0].localName);
3339
+ const label = options.label ?? defaultLabel(canvas);
3081
3340
  const { surface } = canvas;
3082
3341
  const layers = createLayers(canvas, pick);
3083
3342
  const transforms = !!onTransform && trigger === "click";
3084
- const overlay = createOverlay(surface, color, color);
3085
- let marqueeLayers = [];
3086
- const box = marquee ? createMarquee(canvas, color, (world) => {
3087
- marqueeLayers = layers.layersIn(scopes.at(-1) ?? null, world);
3088
- return marqueeLayers.flat();
3089
- }) : null;
3343
+ const overlay = createOverlay(surface, color);
3344
+ const box = marquee ? createMarquee(canvas, color) : null;
3090
3345
  let selection = [];
3091
3346
  let scopes = [];
3092
3347
  let hovered = null;
@@ -3096,7 +3351,8 @@ function bindEditor(canvas, options = {}) {
3096
3351
  let swallowClick = false;
3097
3352
  let keepFocus = false;
3098
3353
  const commit = (next, nextScopes = scopes) => {
3099
- selection = next.filter((layer, i) => next.findIndex((other) => sameLayer(layer, other)) === i);
3354
+ const seen = /* @__PURE__ */ new Set();
3355
+ selection = next.filter((layer) => !seen.has(layer[0]) && !!seen.add(layer[0]));
3100
3356
  scopes = selection.length ? nextScopes : [];
3101
3357
  canvas.setSelection(selection.flat());
3102
3358
  onChange?.(selection);
@@ -3108,10 +3364,7 @@ function bindEditor(canvas, options = {}) {
3108
3364
  canvas.setHover(layer?.[0] ?? null);
3109
3365
  draw();
3110
3366
  };
3111
- const at = (event) => {
3112
- const bounds = surface.getBoundingClientRect();
3113
- return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
3114
- };
3367
+ const at = (event) => pointIn(surface, event);
3115
3368
  const chainAt = (point) => layers.chainAt(canvas.screenToWorld(point));
3116
3369
  const fieldAt = (point) => {
3117
3370
  const element = canvas.getElementAt(canvas.screenToWorld(point));
@@ -3140,15 +3393,7 @@ function bindEditor(canvas, options = {}) {
3140
3393
  const draw = () => {
3141
3394
  if (!frame) frame = requestAnimationFrame(render);
3142
3395
  };
3143
- const toScreen = (rect) => {
3144
- const { x, y } = canvas.worldToScreen(rect);
3145
- const zoom = canvas.getCamera().zoom;
3146
- return { x, y, width: rect.width * zoom, height: rect.height * zoom };
3147
- };
3148
- const unionOf = (rects) => rects.reduce(
3149
- (union, rect) => !rect ? union : union ? unionRect(union, rect) : rect,
3150
- null
3151
- );
3396
+ const toScreen = (rect) => rectToScreen(canvas.getCamera(), rect);
3152
3397
  const render = () => {
3153
3398
  frame = 0;
3154
3399
  const transforming = gesture?.kind === "move" || gesture?.kind === "resize" ? gesture : null;
@@ -3164,28 +3409,24 @@ function bindEditor(canvas, options = {}) {
3164
3409
  } : null,
3165
3410
  bounds: selection.length > 1 || !!transforming,
3166
3411
  handles: transforms && selection.length > 0 && gesture?.kind !== "marquee",
3167
- guides: transforming ? transforming.guides.map(
3168
- (guide) => guideRect(guide, (x, y) => canvas.worldToScreen({ x, y }))
3169
- ) : []
3412
+ guides: transforming ? transforming.guides.map((guide) => guideRect(guide, canvas.getCamera())) : []
3170
3413
  });
3171
3414
  if (selection.length || hovered || gesture) draw();
3172
3415
  };
3173
- const emit = (phase) => {
3174
- if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
3416
+ const emit = (transform, phase) => {
3175
3417
  onTransform?.({
3176
3418
  phase,
3177
- kind: gesture.kind,
3178
- layers: gesture.layers,
3179
- from: gesture.from,
3180
- to: phase === "cancel" ? gesture.from : gesture.to
3419
+ kind: transform.kind,
3420
+ layers: transform.layers,
3421
+ from: transform.from,
3422
+ to: phase === "cancel" ? transform.from : transform.to
3181
3423
  });
3182
3424
  };
3183
3425
  const snapTargets = (moving) => {
3184
3426
  if (!snap) return [];
3185
3427
  const scope = scopes.at(-1);
3186
3428
  if (!scope) {
3187
- const view = visibleWorldRect(canvas.getCamera(), surface.clientWidth, surface.clientHeight);
3188
- return canvas.getNodesIn(view).filter((node) => !moving.some((layer) => layer[0] === node)).map((node) => canvas.getNodeRect(node));
3429
+ return canvas.getNodesIn(viewOf(canvas)).filter((node) => !moving.some((layer) => layer[0] === node)).map((node) => canvas.getNodeRect(node));
3189
3430
  }
3190
3431
  const rects = layers.childrenOf(scope).filter((layer) => !layerIn(layer, moving)).map((layer) => layerRect(canvas, layer));
3191
3432
  return [layerRect(canvas, scope), ...rects].filter((rect) => !!rect);
@@ -3194,7 +3435,7 @@ function bindEditor(canvas, options = {}) {
3194
3435
  const from = selection.map((layer) => layerRect(canvas, layer));
3195
3436
  if (from.some((rect) => !rect)) return;
3196
3437
  const rects = from;
3197
- gesture = {
3438
+ const transform = {
3198
3439
  kind: handle ? "resize" : "move",
3199
3440
  start,
3200
3441
  handle,
@@ -3205,8 +3446,9 @@ function bindEditor(canvas, options = {}) {
3205
3446
  targets: snapTargets(selection),
3206
3447
  guides: []
3207
3448
  };
3449
+ gesture = transform;
3208
3450
  setHovered(null);
3209
- emit("start");
3451
+ emit(transform, "start");
3210
3452
  };
3211
3453
  const updateTransform = (point, keepRatio) => {
3212
3454
  if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
@@ -3225,29 +3467,30 @@ function bindEditor(canvas, options = {}) {
3225
3467
  } else {
3226
3468
  let left = bounds.x;
3227
3469
  let top = bounds.y;
3228
- let right2 = bounds.x + bounds.width;
3229
- let bottom2 = bounds.y + bounds.height;
3230
- if (handle.includes("w")) left = Math.min(left + dx, right2 - 1);
3231
- if (handle.includes("e")) right2 = Math.max(right2 + dx, left + 1);
3232
- if (handle.includes("n")) top = Math.min(top + dy, bottom2 - 1);
3233
- if (handle.includes("s")) bottom2 = Math.max(bottom2 + dy, top + 1);
3234
- next = { x: left, y: top, width: right2 - left, height: bottom2 - top };
3235
- const corner = handle.length === 2;
3470
+ let right = bounds.x + bounds.width;
3471
+ let bottom = bounds.y + bounds.height;
3472
+ const anchor = HANDLE_ANCHORS[handle];
3473
+ if (anchor.x === 0) left = Math.min(left + dx, right - 1);
3474
+ if (anchor.x === 1) right = Math.max(right + dx, left + 1);
3475
+ if (anchor.y === 0) top = Math.min(top + dy, bottom - 1);
3476
+ if (anchor.y === 1) bottom = Math.max(bottom + dy, top + 1);
3477
+ next = { x: left, y: top, width: right - left, height: bottom - top };
3478
+ const corner = anchor.x !== 0.5 && anchor.y !== 0.5;
3236
3479
  if (keepRatio && corner) {
3237
3480
  const scale = Math.max(next.width / bounds.width, next.height / bounds.height);
3238
3481
  const width = bounds.width * scale;
3239
3482
  const height = bounds.height * scale;
3240
3483
  next = {
3241
- x: handle.includes("w") ? right2 - width : left,
3242
- y: handle.includes("n") ? bottom2 - height : top,
3484
+ x: anchor.x === 0 ? right - width : left,
3485
+ y: anchor.y === 0 ? bottom - height : top,
3243
3486
  width,
3244
3487
  height
3245
3488
  };
3246
3489
  gesture.guides = [];
3247
3490
  } else {
3248
3491
  const edges = {
3249
- x: handle.includes("w") ? [0] : handle.includes("e") ? [1] : [],
3250
- y: handle.includes("n") ? [0] : handle.includes("s") ? [1] : []
3492
+ x: anchor.x === 0.5 ? [] : [anchor.x],
3493
+ y: anchor.y === 0.5 ? [] : [anchor.y]
3251
3494
  };
3252
3495
  const snapped = snapRect(next, gesture.targets, threshold, edges);
3253
3496
  if (edges.x[0] === 0)
@@ -3267,7 +3510,7 @@ function bindEditor(canvas, options = {}) {
3267
3510
  width: rect.width * sx,
3268
3511
  height: rect.height * sy
3269
3512
  }));
3270
- emit("move");
3513
+ emit(gesture, "move");
3271
3514
  draw();
3272
3515
  };
3273
3516
  const onPointerDown = (event) => {
@@ -3287,16 +3530,16 @@ function bindEditor(canvas, options = {}) {
3287
3530
  swallowClick = true;
3288
3531
  surface.setPointerCapture(event.pointerId);
3289
3532
  };
3290
- const handle = event.target.closest?.("[data-gpucanvas-handle]");
3533
+ const handle = handleAt(event.target);
3291
3534
  if (handle && transforms) {
3292
3535
  take();
3293
- return beginTransform(point, handle.dataset.gpucanvasHandle);
3536
+ return beginTransform(point, handle);
3294
3537
  }
3295
3538
  const target = targetIn(chainAt(point));
3296
3539
  if (!target) {
3297
3540
  if (event.shiftKey && box) {
3298
3541
  take();
3299
- gesture = { kind: "marquee", start: point };
3542
+ gesture = { kind: "marquee", start: point, scopes };
3300
3543
  return;
3301
3544
  }
3302
3545
  if (selection.length) commit([], []);
@@ -3320,35 +3563,27 @@ function bindEditor(canvas, options = {}) {
3320
3563
  if (Math.hypot(pointer.x - gesture.start.x, pointer.y - gesture.start.y) <= CLICK_SLOP)
3321
3564
  return;
3322
3565
  if (gesture.inside || trigger !== "click" && box) {
3323
- gesture = { kind: "marquee", start: gesture.start };
3566
+ gesture = { kind: "marquee", start: gesture.start, scopes };
3324
3567
  } else if (trigger === "click" && transforms) beginTransform(gesture.start, null);
3325
3568
  else return;
3326
3569
  }
3327
3570
  if (gesture?.kind === "marquee") {
3328
- box.update(gesture.start, pointer);
3329
- selection = marqueeLayers;
3330
- onChange?.(selection);
3331
- draw();
3571
+ const world = box.update(gesture.start, pointer);
3572
+ commit(layers.layersIn(gesture.scopes.at(-1) ?? null, world), gesture.scopes);
3332
3573
  } else updateTransform(pointer, event.shiftKey);
3333
3574
  };
3334
3575
  const onPointerUp = () => {
3335
3576
  const done = gesture;
3336
3577
  gesture = null;
3337
3578
  if (!done) return;
3338
- if (done.kind === "marquee") {
3339
- box.hide();
3340
- commit(marqueeLayers);
3341
- } else if (done.kind === "press") {
3579
+ if (done.kind === "marquee") box.hide();
3580
+ else if (done.kind === "press") {
3342
3581
  const { layer, scopes: inside } = done.target;
3343
3582
  if (!done.additive) commit([layer], inside);
3344
3583
  else if (layerIn(layer, selection))
3345
3584
  commit(selection.filter((other) => !sameLayer(other, layer)));
3346
3585
  else commit([...selection, layer], inside);
3347
- } else {
3348
- gesture = done;
3349
- emit("end");
3350
- gesture = null;
3351
- }
3586
+ } else emit(done, "end");
3352
3587
  updateHover();
3353
3588
  draw();
3354
3589
  };
@@ -3412,7 +3647,7 @@ function bindEditor(canvas, options = {}) {
3412
3647
  }
3413
3648
  if (event.key === "Escape") {
3414
3649
  if (gesture?.kind === "move" || gesture?.kind === "resize") {
3415
- emit("cancel");
3650
+ emit(gesture, "cancel");
3416
3651
  gesture = null;
3417
3652
  } else if (scopes.length) up();
3418
3653
  else if (selection.length) commit([], []);
@@ -3427,7 +3662,7 @@ function bindEditor(canvas, options = {}) {
3427
3662
  } else if (event.key === "Tab") {
3428
3663
  event.preventDefault();
3429
3664
  const scope = scopes.at(-1);
3430
- const siblings = scope ? layers.childrenOf(scope) : canvas.getNodesIn({ x: -1e9, y: -1e9, width: 2e9, height: 2e9 }).map((node) => [node]);
3665
+ const siblings = scope ? layers.childrenOf(scope) : canvas.getNodesIn(EVERYWHERE).map((node) => [node]);
3431
3666
  const index = siblings.findIndex((layer) => sameLayer(layer, selection[0]));
3432
3667
  const step = event.shiftKey ? -1 : 1;
3433
3668
  const next = siblings[(index + step + siblings.length) % siblings.length];
@@ -3487,7 +3722,7 @@ var pillStyle = (color) => ({
3487
3722
  whiteSpace: "nowrap",
3488
3723
  pointerEvents: "none"
3489
3724
  });
3490
- function createMarks(canvas, { color = "#4f8cff" } = {}) {
3725
+ function createMarks(canvas, { color = DEFAULT_SELECTION_COLOR } = {}) {
3491
3726
  const id = `gpucanvas-marks-${++instances}`;
3492
3727
  const root = document.createElement("div");
3493
3728
  Object.assign(root.style, {
@@ -3495,7 +3730,7 @@ function createMarks(canvas, { color = "#4f8cff" } = {}) {
3495
3730
  inset: "0",
3496
3731
  overflow: "hidden",
3497
3732
  pointerEvents: "none",
3498
- zIndex: "1"
3733
+ zIndex: SURFACE_Z.marks
3499
3734
  });
3500
3735
  const svg = document.createElementNS(SVG, "svg");
3501
3736
  Object.assign(svg.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
@@ -3530,11 +3765,7 @@ function createMarks(canvas, { color = "#4f8cff" } = {}) {
3530
3765
  return ref;
3531
3766
  };
3532
3767
  const toScreen = (point) => canvas.worldToScreen(point);
3533
- const screenRect = (rect) => {
3534
- const a = toScreen(rect);
3535
- const b = toScreen({ x: rect.x + rect.width, y: rect.y + rect.height });
3536
- return { x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y };
3537
- };
3768
+ const screenRect = (rect) => rectToScreen(canvas.getCamera(), rect);
3538
3769
  const worldRect = (target) => target instanceof Element ? canvas.getElementRect(target) : { x: target.x, y: target.y, width: 0, height: 0 };
3539
3770
  const place = (element, x, y) => {
3540
3771
  const transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
@@ -3551,21 +3782,22 @@ function createMarks(canvas, { color = "#4f8cff" } = {}) {
3551
3782
  root.append(element);
3552
3783
  return element;
3553
3784
  };
3554
- const marks = /* @__PURE__ */ new Set();
3785
+ const marks = /* @__PURE__ */ new Map();
3555
3786
  let frame = 0;
3556
3787
  const loop = () => {
3557
3788
  frame = marks.size ? requestAnimationFrame(loop) : 0;
3558
- for (const draw of marks) draw();
3789
+ for (const draw of marks.keys()) draw();
3559
3790
  };
3560
3791
  const add = (draw, remove) => {
3561
- marks.add(draw);
3562
- if (!frame) frame = requestAnimationFrame(loop);
3563
- return {
3792
+ const mark = {
3564
3793
  remove() {
3565
3794
  marks.delete(draw);
3566
3795
  remove();
3567
3796
  }
3568
3797
  };
3798
+ marks.set(draw, mark.remove);
3799
+ if (!frame) frame = requestAnimationFrame(loop);
3800
+ return mark;
3569
3801
  };
3570
3802
  return {
3571
3803
  /** An arrow from one element (or world point) to another, with an optional label. */
@@ -3716,9 +3948,7 @@ function createMarks(canvas, { color = "#4f8cff" } = {}) {
3716
3948
  },
3717
3949
  /** Removes every mark. */
3718
3950
  clear() {
3719
- for (const draw of [...marks]) marks.delete(draw);
3720
- root.querySelectorAll(":scope > div").forEach((element) => element.remove());
3721
- svg.querySelectorAll(":scope > path").forEach((element) => element.remove());
3951
+ for (const remove of [...marks.values()]) remove();
3722
3952
  },
3723
3953
  destroy() {
3724
3954
  cancelAnimationFrame(frame);
@@ -3787,6 +4017,7 @@ function recordFilmstrip(canvas, node, {
3787
4017
  const frames = [];
3788
4018
  let stopped = false;
3789
4019
  let timer = 0;
4020
+ let warned = false;
3790
4021
  const take = () => {
3791
4022
  canvas.snapshot(node, { scale }).then((image) => {
3792
4023
  if (stopped) return;
@@ -3794,7 +4025,10 @@ function recordFilmstrip(canvas, node, {
3794
4025
  frames.push(frame);
3795
4026
  if (frames.length > max) frames.shift();
3796
4027
  onFrame?.(frame, frames);
3797
- }).catch(() => {
4028
+ }).catch((error) => {
4029
+ if (warned) return;
4030
+ warned = true;
4031
+ console.warn("gpucanvas: filmstrip snapshot failed", error);
3798
4032
  });
3799
4033
  };
3800
4034
  const observer = new MutationObserver((records) => {
@@ -3867,6 +4101,7 @@ async function createGpuCanvas2(host, options = {}) {
3867
4101
  return await createGpuCanvas(host, options);
3868
4102
  } catch (error) {
3869
4103
  if (mode === "gpu") throw error;
4104
+ console.warn("gpucanvas: the GPU backend failed to start, using DOM", error);
3870
4105
  return createDomCanvas(host, options);
3871
4106
  }
3872
4107
  }
@@ -3880,6 +4115,7 @@ export {
3880
4115
  EFFECTS,
3881
4116
  supportsGpuCanvas,
3882
4117
  createAgentView,
4118
+ defaultLabel,
3883
4119
  bindEditor,
3884
4120
  createMarks,
3885
4121
  toBlob,
@@ -3890,4 +4126,4 @@ export {
3890
4126
  swapNodes,
3891
4127
  createGpuCanvas2 as createGpuCanvas
3892
4128
  };
3893
- //# sourceMappingURL=chunk-2EDYGRFR.js.map
4129
+ //# sourceMappingURL=chunk-IIRGNX76.js.map