gpucanvas 0.4.4 → 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.
@@ -219,12 +219,13 @@ var sameRect = (a, b) => !!a && !!b && Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y
219
219
  // src/types.ts
220
220
  var DEFAULT_CAMERA = { x: 0, y: 0, zoom: 1 };
221
221
  var DEFAULT_SELECTION_COLOR = "#4f8cff";
222
+ var SURFACE_Z = { marks: "1", marquee: "1", overlay: "2" };
222
223
  var SELECTION_WIDTH = 2;
223
224
  var HOVER_WIDTH = 1;
224
225
 
225
226
  // src/base.ts
226
227
  var styleNode = (element) => Object.assign(element.style, { boxSizing: "border-box", contain: "strict", overflow: "hidden" });
227
- function createBase(host, options, onCamera) {
228
+ function createBase(host, options, onCamera, hooks) {
228
229
  const surface = document.createElement("div");
229
230
  markSurface(surface);
230
231
  Object.assign(surface.style, {
@@ -250,6 +251,22 @@ function createBase(host, options, onCamera) {
250
251
  };
251
252
  const api = {
252
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
+ },
253
270
  getNodeRect: (element) => {
254
271
  const node = nodes.get(element);
255
272
  return node ? { ...node.rect } : null;
@@ -679,7 +696,30 @@ function createDomCanvas(host, options) {
679
696
  world.style.setProperty("--gpucanvas-zoom", String(camera.zoom));
680
697
  frameMs = performance.now() - start;
681
698
  };
682
- const base = createBase(host, options, apply);
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
+ }
722
+ });
683
723
  const { surface, nodes } = base;
684
724
  surface.style.background = options.background ?? "transparent";
685
725
  const world = document.createElement("div");
@@ -779,32 +819,10 @@ function createDomCanvas(host, options) {
779
819
  element.animate(keyframes, { duration: period * 1e3, iterations: Infinity })
780
820
  );
781
821
  };
782
- const updateNode = (element, rect) => {
783
- nodes.set(element, { element, rect });
784
- element.style.left = `${rect.x}px`;
785
- element.style.top = `${rect.y}px`;
786
- element.style.width = `${rect.width}px`;
787
- element.style.height = `${rect.height}px`;
788
- };
789
822
  apply(base.camera());
790
823
  return {
791
824
  ...base.api,
792
825
  mode: "dom",
793
- addNode(element, rect) {
794
- styleNode(element);
795
- element.style.position = "absolute";
796
- updateNode(element, rect);
797
- world.append(element);
798
- },
799
- updateNode,
800
- removeNode(element) {
801
- clearEffect(element);
802
- nodes.delete(element);
803
- selection.delete(element);
804
- outlinedNodes.delete(element);
805
- element.style.outline = "";
806
- element.remove();
807
- },
808
826
  getElementRect: elementRect,
809
827
  setSelection(elements) {
810
828
  selection = new Set(elements);
@@ -1019,458 +1037,354 @@ var Atlas = class {
1019
1037
  }
1020
1038
  };
1021
1039
 
1022
- // src/capture.ts
1023
- function captureRegion(device, source, region, width, height) {
1024
- const texture = device.createTexture({
1025
- size: { width, height },
1026
- format: "rgba8unorm",
1027
- usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
1028
- });
1029
- try {
1030
- device.queue.drawElementImageToTexture(
1031
- {
1032
- source,
1033
- sourceX: region.x,
1034
- sourceY: region.y,
1035
- sourceWidth: region.width,
1036
- sourceHeight: region.height
1037
- },
1038
- { texture, size: { width, height }, premultipliedAlpha: false }
1039
- );
1040
- } catch (error) {
1041
- texture.destroy();
1042
- throw error;
1043
- }
1044
- return readTexture(device, texture, width, height);
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;
1045
1094
  }
1046
- async function readTexture(device, texture, width, height) {
1047
- const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
1048
- const buffer = device.createBuffer({
1049
- size: bytesPerRow * height,
1050
- usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
1051
- });
1052
- const encoder = device.createCommandEncoder();
1053
- encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, { width, height });
1054
- device.queue.submit([encoder.finish()]);
1055
- try {
1056
- await buffer.mapAsync(GPUMapMode.READ);
1057
- const rows = new Uint8Array(buffer.getMappedRange());
1058
- const pixels = new Uint8ClampedArray(width * height * 4);
1059
- for (let y = 0; y < height; y++) {
1060
- pixels.set(rows.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
1061
- }
1062
- return new ImageData(pixels, width, height);
1063
- } finally {
1064
- buffer.destroy();
1065
- texture.destroy();
1066
- }
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;
1067
1103
  }
1068
-
1069
- // src/composite-shader.ts
1070
- var KIND = { snapshot: 0, placeholder: 1, ring: 2 };
1071
- var prelude = (
1072
- /* wgsl */
1073
- `
1074
- struct View {
1075
- size: vec2f, // canvas size in CSS pixels
1076
- origin: vec2f, // camera position, world units
1077
- zoom: f32,
1078
- dpr: f32,
1079
- atlasSize: f32,
1080
- time: f32, // seconds since the canvas started
1081
- placeholder: vec4f, // premultiplied color for nodes without a snapshot
1082
- pointer: vec4f, // pointer and last press in world units; far away when there is none
1083
- pressTime: f32, // seconds since the last press
1104
+ function setThumb(node, slot, now) {
1105
+ node.thumb = slot;
1106
+ node.thumbStale = false;
1107
+ node.thumbAt = now;
1084
1108
  }
1085
-
1086
- @group(0) @binding(0) var<uniform> view: View;
1087
- @group(0) @binding(1) var atlas: texture_2d_array<f32>;
1088
- @group(0) @binding(2) var atlasSampler: sampler;
1089
-
1090
- struct Instance {
1091
- @location(0) rect: vec4f, // world x, y, width, height
1092
- @location(1) uv: vec4f, // atlas texels x0, y0, x1, y1; for a ring, its premultiplied color
1093
- @location(2) info: vec4f, // layer, kind (0 snapshot, 1 placeholder, 2 ring), ring width or effect pad
1094
- @location(3) params: vec4f, // an effect's parameters
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;
1117
+ }
1118
+ node.dirty = false;
1119
+ node.wholeStale = false;
1095
1120
  }
1096
-
1097
- struct Varyings {
1098
- @builtin(position) position: vec4f,
1099
- @location(0) texel: vec2f,
1100
- @location(1) @interpolate(flat) bounds: vec4f,
1101
- @location(2) @interpolate(flat) info: vec2f,
1102
- @location(3) @interpolate(flat) color: vec4f,
1103
- @location(4) uv: vec2f, // 0 to 1 across the node, past that in an effect's padding
1104
- @location(5) @interpolate(flat) size: vec2f,
1105
- @location(6) @interpolate(flat) params: vec4f,
1106
- @location(7) @interpolate(flat) corner: vec2f, // the node's top left in world units
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;
1107
1127
  }
1128
+ var isCurrentTile = (tile, detailScale) => tile.scale === detailScale && !tile.stale;
1129
+ var hasFreshThumb = (atlas, node) => atlas.isValid(node.thumb) && !node.thumbStale;
1108
1130
 
1109
- @vertex
1110
- fn vs(@builtin(vertex_index) vertex: u32, instance: Instance) -> Varyings {
1111
- let corner = vec2f(f32(vertex & 1u), f32(vertex >> 1u));
1112
- // A ring's quad reaches past its element by the ring's width, an effect's by its padding.
1113
- let ring = instance.info.y > ${KIND.ring - 0.5};
1114
- let grow = select(instance.info.z, instance.info.z / view.zoom, ring);
1115
- let start = instance.rect.xy - grow;
1116
- let end = instance.rect.xy + instance.rect.zw + grow;
1117
- // Snap edges to device pixels so a snapshot drawn at its raster scale stays crisp.
1118
- let scale = view.zoom * view.dpr;
1119
- let topLeft = round((start - view.origin) * scale) / view.dpr;
1120
- let bottomRight = round((end - view.origin) * scale) / view.dpr;
1121
- let point = mix(topLeft, bottomRight, corner);
1122
- var out: Varyings;
1123
- 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);
1124
- if (ring) {
1125
- // World units from the element's top left, and the element's own edges.
1126
- out.texel = mix(vec2f(-grow), instance.rect.zw + grow, corner);
1127
- out.bounds = vec4f(0.0, 0.0, instance.rect.zw);
1128
- out.color = instance.uv;
1129
- } else {
1130
- let pad = grow * (instance.uv.zw - instance.uv.xy) / instance.rect.zw;
1131
- out.texel = mix(instance.uv.xy - pad, instance.uv.zw + pad, corner);
1132
- out.color = instance.uv;
1133
- // Half a texel inside the node's rectangle: the outermost sample center that cannot blend
1134
- // with whatever sits next to it in the atlas.
1135
- out.bounds = vec4f(instance.uv.xy + 0.5, instance.uv.zw - 0.5);
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);
1136
1149
  }
1137
- out.info = instance.info.xy;
1138
- out.uv = mix(-grow / instance.rect.zw, 1.0 + grow / instance.rect.zw, corner);
1139
- out.size = instance.rect.zw;
1140
- out.corner = instance.rect.xy;
1141
- out.params = instance.params;
1142
- return out;
1143
- }
1144
- `
1145
- );
1146
- var compositeShader = (
1147
- /* wgsl */
1148
- `${prelude}
1149
- @fragment
1150
- fn fs(in: Varyings) -> @location(0) vec4f {
1151
- // Derivatives first: WGSL only allows them in uniform control flow.
1152
- let dx = dpdx(in.texel);
1153
- let dy = dpdy(in.texel);
1154
- if (in.info.y > ${KIND.ring - 0.5}) {
1155
- // The ring covers what lies outside the element; the element itself shows through.
1156
- let outside = max(in.bounds.xy - in.texel, in.texel - in.bounds.zw);
1157
- return select(vec4f(0.0), in.color, max(outside.x, outside.y) > 0.0);
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);
1158
1175
  }
1159
- if (in.info.y > ${KIND.placeholder - 0.5}) {
1160
- return view.placeholder;
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()];
1161
1179
  }
1162
- let layer = i32(in.info.x);
1163
- // Texels covered by this pixel along each screen axis.
1164
- let footprint = vec2f(length(vec2f(dx.x, dy.x)), length(vec2f(dx.y, dy.y)));
1165
- let taps = vec2u(clamp(ceil(footprint), vec2f(1.0), vec2f(4.0)));
1166
- var color = vec4f(0.0);
1167
- for (var j = 0u; j < taps.y; j++) {
1168
- for (var i = 0u; i < taps.x; i++) {
1169
- let offset = (vec2f(f32(i), f32(j)) + 0.5) / vec2f(taps) - 0.5;
1170
- let texel = clamp(in.texel + offset * footprint, in.bounds.xy, in.bounds.zw);
1171
- color += textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, layer, 0.0);
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;
1172
1210
  }
1211
+ this.positioned = false;
1212
+ this.position();
1213
+ schedule();
1173
1214
  }
1174
- return color / f32(taps.x * taps.y);
1175
- }
1176
- `
1177
- );
1178
- var effectShader = (wgsl) => (
1179
- /* wgsl */
1180
- `${prelude}
1181
- struct EffectInput {
1182
- uv: vec2f,
1183
- size: vec2f,
1184
- time: f32,
1185
- zoom: f32,
1186
- params: vec4f,
1187
- pointer: vec2f,
1188
- press: vec2f,
1189
- sincePress: f32,
1190
- }
1191
-
1192
- var<private> sourceRect: vec4f;
1193
- var<private> sourceLayer: i32;
1194
-
1195
- fn source(uv: vec2f) -> vec4f {
1196
- if (any(uv < vec2f(0.0)) || any(uv > vec2f(1.0))) {
1197
- 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)));
1198
1219
  }
1199
- let texel = clamp(mix(sourceRect.xy, sourceRect.zw, uv), sourceRect.xy + 0.5, sourceRect.zw - 0.5);
1200
- return textureSampleLevel(atlas, atlasSampler, texel / view.atlasSize, sourceLayer, 0.0);
1201
- }
1202
-
1203
- fn hash(p: vec2f) -> f32 {
1204
- return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453);
1205
- }
1206
-
1207
- fn noise(p: vec2f) -> f32 {
1208
- let i = floor(p);
1209
- let f = fract(p);
1210
- let u = f * f * (3.0 - 2.0 * f);
1211
- return mix(
1212
- mix(hash(i), hash(i + vec2f(1.0, 0.0)), u.x),
1213
- mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x),
1214
- u.y,
1215
- );
1216
- }
1217
-
1218
- ${wgsl}
1219
-
1220
- @fragment
1221
- fn fs(in: Varyings) -> @location(0) vec4f {
1222
- sourceRect = in.color;
1223
- sourceLayer = i32(in.info.x);
1224
- let pointer = (view.pointer.xy - in.corner) / in.size;
1225
- let press = (view.pointer.zw - in.corner) / in.size;
1226
- return effect(
1227
- EffectInput(in.uv, in.size, view.time, view.zoom, in.params, pointer, press, view.pressTime),
1228
- );
1229
- }
1230
- `
1231
- );
1232
-
1233
- // src/gpu-renderer.ts
1234
- var FLOATS_PER_INSTANCE = 16;
1235
- var CompositeRenderer = class {
1236
- constructor(device, context, format, atlas, background, placeholder, onEffectReady) {
1237
- this.device = device;
1238
- this.context = context;
1239
- this.format = format;
1240
- this.atlas = atlas;
1241
- this.background = background;
1242
- this.placeholder = placeholder;
1243
- this.onEffectReady = onEffectReady;
1244
- this.bindGroupLayout = device.createBindGroupLayout({
1245
- entries: [
1246
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: {} },
1247
- {
1248
- binding: 1,
1249
- visibility: GPUShaderStage.FRAGMENT,
1250
- texture: { viewDimension: "2d-array" }
1251
- },
1252
- { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }
1253
- ]
1254
- });
1255
- this.sampler = device.createSampler({ magFilter: "linear", minFilter: "linear" });
1256
- this.pipeline = device.createRenderPipeline(
1257
- this.pipelineDescriptor("gpucanvas composite", compositeShader)
1258
- );
1259
- this.uniforms = device.createBuffer({
1260
- size: this.uniformData.byteLength,
1261
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1262
- });
1263
- this.bindGroup = device.createBindGroup({
1264
- layout: this.bindGroupLayout,
1265
- entries: [
1266
- { binding: 0, resource: { buffer: this.uniforms } },
1267
- { binding: 1, resource: atlas.view },
1268
- { binding: 2, resource: this.sampler }
1269
- ]
1270
- });
1271
- this.instanceBuffer = this.createInstanceBuffer();
1272
- }
1273
- device;
1274
- context;
1275
- format;
1276
- atlas;
1277
- background;
1278
- placeholder;
1279
- onEffectReady;
1280
- instances = new Float32Array(FLOATS_PER_INSTANCE * 4096);
1281
- count = 0;
1282
- runs = [];
1283
- instanceBuffer;
1284
- uniforms;
1285
- uniformData = new Float32Array(20);
1286
- pipeline;
1287
- bindGroup;
1288
- bindGroupLayout;
1289
- sampler;
1290
- /** Effect pipelines, null while one compiles. Compiling never blocks a frame. */
1291
- effects = /* @__PURE__ */ new Map();
1292
- failed = /* @__PURE__ */ new Set();
1293
- pipelineDescriptor(label, code) {
1294
- const module = this.device.createShaderModule({ label, code });
1295
- return {
1296
- label,
1297
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
1298
- vertex: {
1299
- module,
1300
- entryPoint: "vs",
1301
- buffers: [
1302
- {
1303
- arrayStride: FLOATS_PER_INSTANCE * 4,
1304
- stepMode: "instance",
1305
- attributes: [
1306
- { shaderLocation: 0, offset: 0, format: "float32x4" },
1307
- { shaderLocation: 1, offset: 16, format: "float32x4" },
1308
- { shaderLocation: 2, offset: 32, format: "float32x4" },
1309
- { shaderLocation: 3, offset: 48, format: "float32x4" }
1310
- ]
1311
- }
1312
- ]
1313
- },
1314
- fragment: {
1315
- module,
1316
- entryPoint: "fs",
1317
- targets: [
1318
- {
1319
- format: this.format,
1320
- // Snapshots are requested premultiplied.
1321
- blend: {
1322
- color: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
1323
- alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" }
1324
- }
1325
- }
1326
- ]
1327
- },
1328
- primitive: { topology: "triangle-strip" }
1329
- };
1330
- }
1331
- /** Whether `effect` can draw this frame. The first ask starts compiling it. */
1332
- hasEffect(effect) {
1333
- if (this.effects.has(effect)) return this.effects.get(effect) !== null;
1334
- this.effects.set(effect, null);
1335
- this.device.createRenderPipelineAsync(
1336
- this.pipelineDescriptor(`gpucanvas effect ${effect.name}`, effectShader(effect.wgsl))
1337
- ).then((pipeline) => {
1338
- this.effects.set(effect, pipeline);
1339
- this.onEffectReady();
1340
- }).catch((error) => {
1341
- this.failed.add(effect);
1342
- console.error(`[gpucanvas] effect "${effect.name}" failed`, error);
1343
- });
1344
- return false;
1345
- }
1346
- /** Whether `effect`'s pipeline failed to compile; it never draws then. */
1347
- effectFailed(effect) {
1348
- return this.failed.has(effect);
1349
- }
1350
- /** Starts a frame with room for at least `capacity` quads. */
1351
- begin(capacity) {
1352
- const floats = capacity * FLOATS_PER_INSTANCE;
1353
- if (this.instances.length < floats) {
1354
- this.instances = new Float32Array(Math.max(floats, this.instances.length * 2));
1355
- this.instanceBuffer.destroy();
1356
- 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();
1357
1249
  }
1358
- this.count = 0;
1359
- this.runs.length = 0;
1360
1250
  }
1361
- /** Room for the next quad, in the run of `effect`; returns its first float. */
1362
- next(effect) {
1363
- const last = this.runs.at(-1);
1364
- if (last && last.effect === effect) last.count++;
1365
- else this.runs.push({ effect, start: this.count, count: 1 });
1366
- return this.count++ * FLOATS_PER_INSTANCE;
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();
1367
1256
  }
1368
- writeRect(offset, rect) {
1369
- const data = this.instances;
1370
- data[offset] = rect.x;
1371
- data[offset + 1] = rect.y;
1372
- data[offset + 2] = rect.width;
1373
- data[offset + 3] = rect.height;
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();
1374
1264
  }
1375
- writeSlot(offset, slot) {
1376
- const data = this.instances;
1377
- this.atlas.touch(slot.layer);
1378
- data[offset + 4] = slot.x;
1379
- data[offset + 5] = slot.y;
1380
- data[offset + 6] = slot.x + slot.width;
1381
- data[offset + 7] = slot.y + slot.height;
1382
- data[offset + 8] = slot.layer;
1383
- data[offset + 9] = KIND.snapshot;
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);
1384
1271
  }
1385
- /** `rect` showing the snapshot in `slot`, or the placeholder color while there is none. */
1386
- quad(rect, slot) {
1387
- const offset = this.next(null);
1388
- this.writeRect(offset, rect);
1389
- if (slot) this.writeSlot(offset, slot);
1390
- else this.instances[offset + 9] = KIND.placeholder;
1391
- this.instances[offset + 10] = 0;
1272
+ 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);
1392
1284
  }
1393
- /** `rect` drawn through `effect` from the snapshot in `slot`, reaching `pad` past its edges. */
1394
- effectQuad(rect, slot, effect, pad, params) {
1395
- const offset = this.next(effect);
1396
- this.writeRect(offset, rect);
1397
- this.writeSlot(offset, slot);
1398
- this.instances[offset + 10] = pad;
1399
- this.instances.set(params, offset + 12);
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);
1400
1289
  }
1401
- /** An outline `width` CSS pixels wide just outside `rect`, in a premultiplied color. */
1402
- ring(rect, color, width) {
1403
- const offset = this.next(null);
1404
- this.writeRect(offset, rect);
1405
- this.instances.set(color, offset + 4);
1406
- this.instances[offset + 9] = KIND.ring;
1407
- this.instances[offset + 10] = width;
1290
+ nodeOf(target) {
1291
+ return nodeOf(this.o.nodes, target);
1408
1292
  }
1409
- /** Draws the quads listed since `begin`. */
1410
- draw(camera, cssWidth, cssHeight, dpr, effects) {
1411
- const { device, count, runs } = this;
1412
- this.uniformData.set([
1413
- cssWidth,
1414
- cssHeight,
1415
- camera.x,
1416
- camera.y,
1417
- camera.zoom,
1418
- dpr,
1419
- this.atlas.size,
1420
- effects.time,
1421
- ...this.placeholder,
1422
- effects.pointer.x,
1423
- effects.pointer.y,
1424
- effects.press.x,
1425
- effects.press.y,
1426
- effects.sincePress
1427
- ]);
1428
- device.queue.writeBuffer(this.uniforms, 0, this.uniformData);
1429
- if (count > 0) {
1430
- device.queue.writeBuffer(
1431
- this.instanceBuffer,
1432
- 0,
1433
- this.instances,
1434
- 0,
1435
- count * FLOATS_PER_INSTANCE
1436
- );
1437
- }
1438
- const [r, g, b] = this.background;
1439
- const encoder = device.createCommandEncoder();
1440
- const pass = encoder.beginRenderPass({
1441
- colorAttachments: [
1442
- {
1443
- view: this.context.getCurrentTexture().createView(),
1444
- clearValue: { r, g, b, a: 1 },
1445
- loadOp: "clear",
1446
- storeOp: "store"
1447
- }
1448
- ]
1449
- });
1450
- if (count > 0) {
1451
- pass.setBindGroup(0, this.bindGroup);
1452
- pass.setVertexBuffer(0, this.instanceBuffer);
1453
- let current = null;
1454
- for (const run of runs) {
1455
- const pipeline = run.effect && this.effects.get(run.effect) || this.pipeline;
1456
- if (pipeline !== current) pass.setPipeline(current = pipeline);
1457
- pass.draw(4, run.count, 0, run.start);
1458
- }
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;
1459
1309
  }
1460
- pass.end();
1461
- device.queue.submit([encoder.finish()]);
1462
- }
1463
- destroy() {
1464
- this.instanceBuffer.destroy();
1465
- this.uniforms.destroy();
1466
- }
1467
- createInstanceBuffer() {
1468
- return this.device.createBuffer({
1469
- label: "gpucanvas instances",
1470
- size: this.instances.byteLength,
1471
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
1472
- });
1473
- }
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);
1474
1388
  };
1475
1389
 
1476
1390
  // src/page.ts
@@ -1485,6 +1399,8 @@ var Page = class {
1485
1399
  promotedFrame = -1;
1486
1400
  /** Registered for hit testing with `updateElementGeometry`. */
1487
1401
  registered = false;
1402
+ /** The atlas slot of the page's last whole snapshot, reused when it is drawn again that size. */
1403
+ wholeSlot = null;
1488
1404
  shelves;
1489
1405
  constructor(width, height) {
1490
1406
  this.width = width;
@@ -1527,228 +1443,172 @@ var Page = class {
1527
1443
  }
1528
1444
  };
1529
1445
 
1530
- // 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
1531
1553
  var MIN_RASTER_SCALE = 1 / 64;
1532
1554
  var MAX_RASTER_SCALE = 2;
1533
1555
  var MAX_DETAIL_SCALE = 64;
1534
1556
  var TILE_PIXELS = 512;
1535
1557
  var CONTENT_SETTLE_MS = 150;
1536
- var SMALL_NODE_PX = 200;
1537
- var SMALL_REFRESH_MS = 250;
1538
- var SETTLE_MS = 120;
1539
- var USE_EVENTS = ["click", "input", "change", "keydown"];
1540
- var USED_MS = 1e3;
1541
- var LINGER_MS = 300;
1542
1558
  var MAX_OVERSAMPLING = 4;
1543
- var DEFAULT_PAGE_SIZE = 2048;
1544
- var THUMBNAIL_SCALE = 1 / 16;
1545
- var THUMBNAIL_REFRESH_MS = 1e3;
1546
- var MAX_PROMOTIONS_PER_FRAME = 8;
1547
- var SLOW_FRAME_MS = 22;
1548
- var FAST_FRAME_MS = 12;
1549
- var MAX_CAPTURE_TRIES = 20;
1550
1559
  var MISSING = 0;
1551
1560
  var DIRTY = 1;
1552
1561
  var BLURRY = 2;
1553
1562
  var OVERSAMPLED = 3;
1554
- var CANVAS_TRANSFORM_HIT_TESTS = typeof HTMLCanvasElement !== "undefined" && "getElementImageDefaultSize" in HTMLCanvasElement.prototype;
1555
- function supportsGpuCanvas() {
1556
- 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;
1557
- }
1558
1563
  function rasterScaleFor(deviceScale) {
1559
1564
  const scale = 2 ** Math.ceil(Math.log2(deviceScale) - 0.14);
1560
1565
  return Math.min(MAX_DETAIL_SCALE, Math.max(MIN_RASTER_SCALE, scale));
1561
1566
  }
1562
- function cssColorToRgba(color) {
1563
- const context = document.createElement("canvas").getContext("2d");
1564
- context.fillStyle = color;
1565
- context.fillRect(0, 0, 1, 1);
1566
- const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
1567
- return [r / 255, g / 255, b / 255, a / 255];
1568
- }
1569
- var premultiplied = ([r, g, b, a]) => [r * a, g * a, b * a, a];
1570
1567
  var distanceToCenter = (rect, view) => {
1571
1568
  const a = center(rect);
1572
1569
  const b = center(view);
1573
1570
  return Math.hypot(a.x - b.x, a.y - b.y);
1574
1571
  };
1575
- async function createGpuCanvas(host, options) {
1576
- const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
1577
- if (!adapter) throw new Error("gpucanvas: no WebGPU adapter");
1578
- const device = await adapter.requestDevice();
1579
- const canvas = document.createElement("canvas");
1580
- canvas.setAttribute("content", "drawable");
1581
- markSurface(canvas);
1582
- Object.assign(canvas.style, { display: "block", width: "100%", height: "100%" });
1583
- const context = canvas.getContext("webgpu");
1584
- const format = navigator.gpu.getPreferredCanvasFormat();
1585
- try {
1586
- if (!context) throw new Error("gpucanvas: no WebGPU canvas context");
1587
- context.configure({ device, format, alphaMode: "opaque" });
1588
- } catch (error) {
1589
- device.destroy();
1590
- throw error;
1572
+ var Planner = class {
1573
+ constructor(atlas, lift, schedule) {
1574
+ this.atlas = atlas;
1575
+ this.lift = lift;
1576
+ this.schedule = schedule;
1591
1577
  }
1592
- const base = createBase(host, options, () => {
1593
- lastCameraChange = performance.now();
1594
- setLive(null);
1595
- clearTimeout(settleTimer);
1596
- settleTimer = window.setTimeout(() => {
1597
- pickLive();
1598
- schedule();
1599
- }, SETTLE_MS);
1600
- schedule();
1601
- });
1602
- const { surface, nodes } = base;
1603
- surface.append(canvas);
1604
- const atlasSize = Math.min(options.atlasSize ?? 4096, device.limits.maxTextureDimension2D);
1605
- const atlas = new Atlas(
1606
- device,
1607
- atlasSize,
1608
- Math.min(options.atlasLayers ?? 5, device.limits.maxTextureArrayLayers),
1609
- function* () {
1610
- const onScreen = (tile) => intersects(tile.rect, frameView);
1611
- for (const node of visible) yield node.slot;
1612
- for (const node of visible) {
1613
- for (const tile of node.tiles) {
1614
- if (tile.scale === detailScale && !tile.stale && onScreen(tile)) yield tile.slot;
1615
- }
1616
- }
1617
- for (const node of visible) {
1618
- yield node.preview;
1619
- for (const tile of node.tiles) if (onScreen(tile)) yield tile.slot;
1620
- }
1621
- }
1622
- );
1623
- const pageSize = Math.min(options.pageSize ?? DEFAULT_PAGE_SIZE, atlasSize);
1624
- const maxRasterCalls = options.maxRastersPerFrame ?? 12;
1625
- const rasterTimeBudgetMs = options.rasterTimeBudgetMs ?? 6;
1626
- const rasterPixelBudget = options.rasterPixelBudget ?? 24e6;
1627
- const colorsFor = (css) => {
1628
- const background = cssColorToRgba(css);
1629
- const shade = background[0] + background[1] + background[2] > 1.5 ? 0.92 : 1.25;
1630
- const placeholder = background.map(
1631
- (channel) => Math.min(1, channel * shade + (shade > 1 ? 0.02 : 0))
1632
- );
1633
- placeholder[3] = 1;
1634
- return [background, placeholder];
1635
- };
1636
- const renderer = new CompositeRenderer(
1637
- device,
1638
- context,
1639
- format,
1640
- atlas,
1641
- ...colorsFor(options.background ?? "#f4f4f5"),
1642
- () => schedule()
1643
- );
1644
- const selectionColor = premultiplied(
1645
- cssColorToRgba(options.selectionColor ?? DEFAULT_SELECTION_COLOR)
1646
- );
1647
- const hoverColor = options.hoverColor ? premultiplied(cssColorToRgba(options.hoverColor)) : selectionColor;
1648
- const effects = /* @__PURE__ */ new Map();
1649
- const startedAt = performance.now();
1650
- const FAR_AWAY = { x: -1e7, y: -1e7 };
1651
- let effectsMoving = false;
1652
- let selection = /* @__PURE__ */ new Set();
1653
- let hovered = null;
1654
- const ringRects = /* @__PURE__ */ new Map();
1655
- const ringRect = (element) => {
1656
- const own = nodes.get(element);
1657
- if (own) return own.rect;
1658
- const at = element.isConnected ? nodeOfElement(nodes, element) : null;
1659
- if (!at) return null;
1660
- const node = nodes.get(at);
1661
- let rect = ringRects.get(element);
1662
- if (!rect) ringRects.set(element, rect = rectInNode(at, node.rect, element));
1663
- return rect;
1664
- };
1665
- const pages = /* @__PURE__ */ new Map();
1666
- const pageByElement = /* @__PURE__ */ new Map();
1667
- let openPage = null;
1668
- let lastCameraChange = -Infinity;
1669
- let frameNumber = 0;
1670
- let promotionsPerFrame = 2;
1671
- let lastFrameAt = 0;
1672
- let rafId = 0;
1673
- let destroyed = false;
1674
- let live = null;
1675
- let livePositioned = false;
1676
- let lingering = null;
1677
- let lingerTimer = 0;
1678
- let focused = null;
1679
- let pointer = null;
1680
- let settleTimer = 0;
1681
- const stats = {
1682
- visible: 0,
1683
- pending: 0,
1684
- rasterizedLastFrame: 0,
1685
- rasterCallsLastFrame: 0,
1686
- rasterMs: 0,
1687
- frameMs: 0,
1688
- frames: 0,
1689
- lostPixels: 0,
1690
- lostDetail: 0,
1691
- onThumbnail: 0,
1692
- /** Last plan's nodes per need class: missing, dirty, blurry, oversampled. */
1693
- needs: [0, 0, 0, 0]
1694
- };
1695
- const visible = [];
1696
- const plans = /* @__PURE__ */ new Map();
1697
- const schedule = () => {
1698
- if (rafId || destroyed) return;
1699
- rafId = requestAnimationFrame(() => {
1700
- rafId = 0;
1701
- frameNumber++;
1702
- frame(false);
1703
- });
1704
- };
1705
- const canvasDpr = () => canvas.width / Math.max(1, canvas.clientWidth);
1706
- const resizeObserver = new ResizeObserver(([entry]) => {
1707
- const size = entry.devicePixelContentBoxSize?.[0];
1708
- canvas.width = Math.max(
1709
- 1,
1710
- size ? size.inlineSize : Math.round(entry.contentRect.width * devicePixelRatio)
1711
- );
1712
- canvas.height = Math.max(
1713
- 1,
1714
- size ? size.blockSize : Math.round(entry.contentRect.height * devicePixelRatio)
1715
- );
1716
- schedule();
1717
- });
1718
- resizeObserver.observe(canvas, { box: "device-pixel-content-box" });
1719
- const transitioning = /* @__PURE__ */ new Map();
1720
- const captures = [];
1721
- const heldNodes = () => [live, lingering, focused, ...transitioning.keys()];
1722
- const isPinned = (page) => captures.some((capture) => capture.node.page === page) || heldNodes().some((node) => node?.page === page);
1723
- let detailScale = 1;
1724
- let frameView = { x: 0, y: 0, width: 0, height: 0 };
1725
- let contentTimer = 0;
1726
- const scheduleAfterContentSettles = () => {
1727
- clearTimeout(contentTimer);
1728
- contentTimer = window.setTimeout(schedule, CONTENT_SETTLE_MS + 10);
1729
- };
1730
- let refreshTimer = 0;
1731
- let refreshAt = Infinity;
1732
- const smallOnScreen = (node) => Math.max(node.rect.width, node.rect.height) * base.camera().zoom < SMALL_NODE_PX;
1733
- const refreshWait = (node, now) => smallOnScreen(node) ? node.refreshedAt + SMALL_REFRESH_MS - now : 0;
1734
- const refreshLater = (wait) => {
1735
- const at = performance.now() + wait;
1736
- if (at >= refreshAt) return;
1737
- clearTimeout(refreshTimer);
1738
- refreshAt = at;
1739
- refreshTimer = window.setTimeout(() => {
1740
- refreshAt = Infinity;
1741
- schedule();
1742
- }, wait);
1743
- };
1744
- const planFrame = (view, desiredScale, deviceScale, moving) => {
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);
1745
1604
  const now = performance.now();
1746
- detailScale = Math.min(deviceScale, MAX_DETAIL_SCALE);
1747
- frameView = view;
1605
+ this.zoom = zoom;
1606
+ this.detailScale = Math.min(deviceScale, MAX_DETAIL_SCALE);
1607
+ this.view = view;
1748
1608
  visible.length = 0;
1749
1609
  plans.clear();
1750
- stats.needs = [0, 0, 0, 0];
1751
- for (const node of nodes.values()) {
1610
+ this.needs = [0, 0, 0, 0];
1611
+ for (const node of nodes) {
1752
1612
  if (!intersects(node.rect, view)) continue;
1753
1613
  visible.push(node);
1754
1614
  const scale = Math.min(
@@ -1759,14 +1619,14 @@ async function createGpuCanvas(host, options) {
1759
1619
  );
1760
1620
  if (node.dirty) node.changedAt = now;
1761
1621
  const thumbnailEnough = scale <= THUMBNAIL_SCALE;
1762
- if (thumbnailEnough && freshThumb(node) && !node.dirty && !node.wholeStale) {
1622
+ if (thumbnailEnough && hasFreshThumb(atlas, node) && !node.dirty && !node.wholeStale) {
1763
1623
  node.slot = node.thumb;
1764
1624
  node.slotScale = THUMBNAIL_SCALE;
1765
1625
  }
1766
1626
  const animating = now - node.changedAt < CONTENT_SETTLE_MS;
1767
1627
  const deep = desiredScale > scale;
1768
- if (deep && !node.solo) lift(node);
1769
- 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) : [];
1770
1630
  if (!deep) {
1771
1631
  node.preview = null;
1772
1632
  node.tiles.length = 0;
@@ -1776,12 +1636,12 @@ async function createGpuCanvas(host, options) {
1776
1636
  if (!valid) need = atlas.isValid(node.thumb) ? DIRTY : MISSING;
1777
1637
  else if (deep) need = node.slotScale < scale / (moving ? 4 : 2) ? BLURRY : -1;
1778
1638
  else if (node.dirty || node.wholeStale) {
1779
- const wait = refreshWait(node, now);
1780
- if (wait > 0) refreshLater(wait);
1639
+ const wait = this.refreshWait(node, now);
1640
+ if (wait > 0) this.refreshLater(wait);
1781
1641
  else need = DIRTY;
1782
1642
  } else if (node.slotScale < (moving ? scale / 2 : scale)) need = BLURRY;
1783
1643
  else if (!moving && node.slotScale > scale * MAX_OVERSAMPLING) need = OVERSAMPLED;
1784
- if (need >= 0) stats.needs[need]++;
1644
+ if (need >= 0) this.needs[need]++;
1785
1645
  if (need < 0 && !node.previewTarget && jobs.length === 0) continue;
1786
1646
  node.targetScale = thumbnailEnough ? THUMBNAIL_SCALE : scale;
1787
1647
  node.priority = (need < 0 ? BLURRY : need) * 1e12 + distanceToCenter(node.rect, view);
@@ -1796,8 +1656,33 @@ async function createGpuCanvas(host, options) {
1796
1656
  plan.tiles.push(...jobs);
1797
1657
  }
1798
1658
  for (const plan of plans.values()) plan.tiles.sort((a, b) => a.priority - b.priority);
1799
- };
1800
- 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;
1801
1686
  const shown = intersection(node.rect, view);
1802
1687
  node.previewTarget = null;
1803
1688
  node.tiles = node.tiles.filter((tile) => atlas.isValid(tile.slot));
@@ -1807,13 +1692,13 @@ async function createGpuCanvas(host, options) {
1807
1692
  const scale = detailScale / 2;
1808
1693
  const stale = !node.preview || node.dirty || node.previewScale !== scale || !contains(node.previewRect, shown);
1809
1694
  if (stale) {
1810
- node.previewTarget = fitPreview(shown, scale);
1695
+ node.previewTarget = this.fitPreview(shown, scale);
1811
1696
  node.previewTargetScale = scale;
1812
1697
  }
1813
- scheduleAfterContentSettles();
1698
+ this.scheduleAfterContentSettles();
1814
1699
  return [];
1815
1700
  }
1816
- const current = node.tiles.filter((tile) => tile.scale === detailScale && !tile.stale);
1701
+ const current = node.tiles.filter((tile) => isCurrentTile(tile, detailScale));
1817
1702
  if (moving && current.length === 0) return [];
1818
1703
  const size = Math.max(1, Math.floor(TILE_PIXELS / detailScale));
1819
1704
  const jobs = [];
@@ -1842,70 +1727,107 @@ async function createGpuCanvas(host, options) {
1842
1727
  node.preview = null;
1843
1728
  }
1844
1729
  return jobs;
1845
- };
1846
- const fitPreview = (shown, scale) => {
1847
- 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;
1848
1734
  const fit = (start, size) => size <= limit ? [start, size] : [start + (size - limit) / 2, limit];
1849
1735
  const [x, width] = fit(shown.x, shown.width);
1850
1736
  const [y, height] = fit(shown.y, shown.height);
1851
1737
  return { x, y, width, height };
1852
- };
1853
- const drawSnapshot = (page, source, slot) => {
1854
- try {
1855
- device.queue.drawElementImageToTexture(
1856
- source ? {
1857
- source: page.element,
1858
- sourceX: source.x,
1859
- sourceY: source.y,
1860
- sourceWidth: source.width,
1861
- sourceHeight: source.height
1862
- } : { source: page.element },
1863
- {
1864
- texture: atlas.texture,
1865
- origin: { x: slot.x, y: slot.y, z: slot.layer },
1866
- size: { width: slot.width, height: slot.height },
1867
- premultipliedAlpha: true
1868
- }
1869
- );
1870
- return true;
1871
- } catch {
1872
- return false;
1873
- }
1874
- };
1875
- 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);
1876
- const texels = (length, scale) => Math.max(1, Math.round(length * scale));
1877
- const nodeRegion = (node) => ({
1878
- x: node.pageX,
1879
- y: node.pageY,
1880
- width: node.rect.width,
1881
- height: node.rect.height
1882
- });
1883
- const freshThumb = (node) => atlas.isValid(node.thumb) && !node.thumbStale;
1884
- const setThumb = (node, slot, now) => {
1885
- node.thumb = slot;
1886
- node.thumbStale = false;
1887
- node.thumbAt = now;
1888
- };
1889
- const drawn = (node, slot, scale, now) => {
1890
- node.slot = slot;
1891
- node.slotScale = scale;
1892
- if (scale === THUMBNAIL_SCALE) setThumb(node, slot, now);
1893
- else if (node.dirty) node.thumbStale = true;
1894
- if (node.dirty) {
1895
- node.preview = null;
1896
- 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);
1897
1798
  }
1898
- node.dirty = false;
1899
- node.wholeStale = false;
1900
- };
1901
- const pageSlots = /* @__PURE__ */ new Map();
1902
- 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;
1903
1826
  let calls = 0;
1904
1827
  let pixels = 0;
1905
1828
  let rasterized = 0;
1906
1829
  let pending = 0;
1907
- const overBudget = () => calls > 0 && (calls >= maxRasterCalls || pixels >= rasterPixelBudget || performance.now() - start >= rasterTimeBudgetMs);
1908
- 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);
1909
1831
  for (const plan of ordered) {
1910
1832
  const { page } = plan;
1911
1833
  if (!page.drawable || page.promotedFrame >= frameNumber || overBudget()) {
@@ -1913,18 +1835,18 @@ async function createGpuCanvas(host, options) {
1913
1835
  continue;
1914
1836
  }
1915
1837
  const scale = plan.nodes.reduce((max, node) => Math.max(max, node.targetScale), 0);
1916
- const members = pages.get(page);
1838
+ const members = pages.nodesOf(page);
1917
1839
  const neededArea = plan.nodes.reduce((sum, n) => sum + n.rect.width * n.rect.height, 0);
1918
1840
  const wholeFits = page.width * scale <= atlasSize / 2 && page.height * scale <= atlasSize / 2;
1919
1841
  const whole = wholeFits && (plan.nodes.length >= 6 || neededArea >= 0.25 * page.width * page.height);
1920
- const wholeSlot = whole ? slotFor(
1921
- pageSlots.get(page) ?? null,
1842
+ const wholeSlot = whole ? this.slotFor(
1843
+ page.wholeSlot,
1922
1844
  Math.ceil(page.width * scale),
1923
1845
  Math.ceil(page.height * scale),
1924
1846
  scale === THUMBNAIL_SCALE
1925
1847
  ) : null;
1926
- if (wholeSlot) pageSlots.set(page, wholeSlot);
1927
- if (wholeSlot && !drawSnapshot(page, null, wholeSlot)) {
1848
+ if (wholeSlot) page.wholeSlot = wholeSlot;
1849
+ if (wholeSlot && !this.draw(page, null, wholeSlot)) {
1928
1850
  pending += plan.nodes.length + plan.previews.length + plan.tiles.length;
1929
1851
  continue;
1930
1852
  }
@@ -1941,7 +1863,7 @@ async function createGpuCanvas(host, options) {
1941
1863
  width: node.rect.width * scale,
1942
1864
  height: node.rect.height * scale
1943
1865
  };
1944
- drawn(node, part, scale, start);
1866
+ recordWhole(node, part, scale, start);
1945
1867
  }
1946
1868
  rasterized += members.size;
1947
1869
  } else {
@@ -1951,42 +1873,37 @@ async function createGpuCanvas(host, options) {
1951
1873
  continue;
1952
1874
  }
1953
1875
  const thumbnail = node.targetScale === THUMBNAIL_SCALE;
1954
- const slot = slotFor(
1876
+ const slot = this.slotFor(
1955
1877
  thumbnail ? node.thumb : node.slot,
1956
1878
  texels(node.rect.width, node.targetScale),
1957
1879
  texels(node.rect.height, node.targetScale),
1958
1880
  thumbnail
1959
1881
  );
1960
- if (!slot || !drawSnapshot(page, nodeRegion(node), slot)) {
1882
+ if (!slot || !this.draw(page, nodeRegion(node), slot)) {
1961
1883
  pending++;
1962
1884
  continue;
1963
1885
  }
1964
1886
  calls++;
1965
1887
  pixels += slot.width * slot.height;
1966
- drawn(node, slot, node.targetScale, start);
1888
+ recordWhole(node, slot, node.targetScale, start);
1967
1889
  rasterized++;
1968
1890
  }
1969
1891
  }
1970
1892
  const warm = /* @__PURE__ */ new Set();
1971
1893
  const drawRegion = (node, rect, scale2, previous) => {
1972
1894
  if (!warm.has(scale2) && overBudget()) return null;
1973
- const slot = slotFor(previous, texels(rect.width, scale2), texels(rect.height, scale2));
1895
+ const slot = this.slotFor(previous, texels(rect.width, scale2), texels(rect.height, scale2));
1974
1896
  const region = {
1975
1897
  x: node.pageX + rect.x - node.rect.x,
1976
1898
  y: node.pageY + rect.y - node.rect.y,
1977
1899
  width: rect.width,
1978
1900
  height: rect.height
1979
1901
  };
1980
- if (!slot || !drawSnapshot(page, region, slot)) return null;
1902
+ if (!slot || !this.draw(page, region, slot)) return null;
1981
1903
  calls++;
1982
1904
  warm.add(scale2);
1983
1905
  pixels += slot.width * slot.height;
1984
- if (node.dirty) {
1985
- node.dirty = false;
1986
- node.wholeStale = true;
1987
- node.thumbStale = true;
1988
- node.refreshedAt = start;
1989
- }
1906
+ recordPartial(node, start);
1990
1907
  return slot;
1991
1908
  };
1992
1909
  for (const node of plan.previews) {
@@ -2017,34 +1934,709 @@ async function createGpuCanvas(host, options) {
2017
1934
  }
2018
1935
  pending += tilesLeft;
2019
1936
  for (const node of members) {
2020
- if (freshThumb(node) || node.dirty) continue;
1937
+ if (hasFreshThumb(atlas, node) || node.dirty) continue;
2021
1938
  if (atlas.isValid(node.thumb) && start - node.thumbAt < THUMBNAIL_REFRESH_MS) continue;
2022
- const slot = slotFor(
1939
+ const slot = this.slotFor(
2023
1940
  node.thumb,
2024
1941
  texels(node.rect.width, THUMBNAIL_SCALE),
2025
1942
  texels(node.rect.height, THUMBNAIL_SCALE),
2026
1943
  true
2027
1944
  );
2028
- if (!slot || !drawSnapshot(page, nodeRegion(node), slot)) continue;
1945
+ if (!slot || !this.draw(page, nodeRegion(node), slot)) continue;
2029
1946
  calls++;
2030
1947
  setThumb(node, slot, start);
2031
1948
  }
2032
1949
  const done = plan.nodes.every((node) => !node.dirty && atlas.isValid(node.slot)) && plan.previews.every((node) => node.previewTarget === null) && tilesLeft === 0;
2033
1950
  if (done && !isPinned(page)) page.setDrawable(false, frameNumber);
2034
1951
  }
2035
- stats.rasterizedLastFrame = rasterized;
2036
- stats.rasterCallsLastFrame = calls;
2037
- 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();
2038
2630
  };
2631
+ const lift = (node) => relocate(node, () => pages.lift(node));
2632
+ const planner = new Planner(atlas, lift, schedule);
2039
2633
  const composite = () => {
2040
- let instances2 = visible.length;
2041
- for (const node of visible) instances2 += node.tiles.length + (node.preview ? 1 : 0);
2042
- instances2 += selection.size + 1;
2043
- renderer.begin(instances2);
2634
+ const { visible, detailScale, view } = planner;
2635
+ renderer.begin();
2044
2636
  effectsMoving = false;
2045
2637
  const pushRing = (element, color, width) => {
2046
2638
  const rect = ringRect(element);
2047
- if (rect && intersects(rect, frameView)) renderer.ring(rect, color, width);
2639
+ if (rect && intersects(rect, view)) renderer.ring(rect, color, width);
2048
2640
  };
2049
2641
  let onThumbnail = 0;
2050
2642
  for (const node of visible) {
@@ -2068,7 +2660,7 @@ async function createGpuCanvas(host, options) {
2068
2660
  effectsMoving = true;
2069
2661
  }
2070
2662
  renderer.quad(node.rect, image);
2071
- const current = (tile) => tile.scale === detailScale && !tile.stale;
2663
+ const current = (tile) => isCurrentTile(tile, detailScale);
2072
2664
  for (const tile of node.tiles)
2073
2665
  if (!current(tile) && atlas.isValid(tile.slot)) renderer.quad(tile.rect, tile.slot);
2074
2666
  if (node.preview && atlas.isValid(node.preview))
@@ -2085,9 +2677,9 @@ async function createGpuCanvas(host, options) {
2085
2677
  const camera = base.camera();
2086
2678
  renderer.draw(camera, cssWidth, canvas.clientHeight, dpr, {
2087
2679
  time: (now - startedAt) / 1e3,
2088
- pointer: pointer ? screenToWorld(camera, pointer) : FAR_AWAY,
2089
- press,
2090
- 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)
2091
2683
  });
2092
2684
  };
2093
2685
  const frame = (inPaint) => {
@@ -2098,7 +2690,8 @@ async function createGpuCanvas(host, options) {
2098
2690
  const camera = base.camera();
2099
2691
  const view = base.view();
2100
2692
  const moving = start - lastCameraChange < SETTLE_MS;
2101
- 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;
2102
2695
  if (!inPaint && plans.size > 0) {
2103
2696
  const interval = start - lastFrameAt;
2104
2697
  if (interval < 100) {
@@ -2108,67 +2701,44 @@ async function createGpuCanvas(host, options) {
2108
2701
  }
2109
2702
  lastFrameAt = start;
2110
2703
  let promoted = 0;
2111
- for (const plan of [...plans.values()].sort((a, b) => a.priority - b.priority)) {
2704
+ for (const plan of ordered) {
2112
2705
  if (plan.page.drawable) continue;
2113
2706
  if (promoted++ >= promotionsPerFrame) break;
2114
2707
  plan.page.setDrawable(true, frameNumber);
2115
2708
  }
2116
- awaitingPaint = true;
2117
- canvas.requestPaint();
2709
+ requestPaint();
2118
2710
  return;
2119
2711
  }
2120
2712
  let pending = 0;
2121
2713
  stats.rasterizedLastFrame = 0;
2122
2714
  stats.rasterCallsLastFrame = 0;
2123
- if (inPaint && plans.size > 0) pending = rasterize(start);
2124
- for (const page of pages.keys()) {
2125
- if (page.drawable && !plans.has(page) && page.promotedFrame < frameNumber && !isPinned(page))
2126
- 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;
2127
2720
  }
2721
+ pages.parkIdle(plans, frameNumber, isPinned);
2128
2722
  stats.rasterMs = performance.now() - start;
2129
2723
  composite();
2130
2724
  stats.frames++;
2131
- positionLivePage();
2132
- stats.visible = visible.length;
2725
+ live.position();
2726
+ stats.visible = planner.visible.length;
2133
2727
  stats.pending = pending;
2134
2728
  stats.frameMs = performance.now() - start;
2135
2729
  if (pending > 0 || effectsMoving) schedule();
2136
2730
  };
2137
- let awaitingPaint = false;
2138
- const drawCaptures = () => {
2139
- for (const capture of [...captures]) {
2140
- const { node, scale } = capture;
2141
- let image;
2142
- try {
2143
- image = captureRegion(
2144
- device,
2145
- node.page.element,
2146
- nodeRegion(node),
2147
- texels(node.rect.width, scale),
2148
- texels(node.rect.height, scale)
2149
- );
2150
- } catch (error) {
2151
- if (++capture.tries > MAX_CAPTURE_TRIES) {
2152
- captures.splice(captures.indexOf(capture), 1);
2153
- capture.reject(error instanceof Error ? error : new Error(String(error)));
2154
- }
2155
- continue;
2156
- }
2157
- captures.splice(captures.indexOf(capture), 1);
2158
- image.then(capture.resolve, capture.reject);
2159
- }
2160
- if (captures.length) {
2161
- awaitingPaint = true;
2162
- canvas.requestPaint();
2163
- } else schedule();
2164
- };
2165
2731
  canvas.onpaint = (event) => {
2166
- if (captures.length) drawCaptures();
2732
+ if (captures.length) {
2733
+ rasterizer.capture(captures);
2734
+ if (captures.length) requestPaint();
2735
+ else schedule();
2736
+ }
2167
2737
  let changed = false;
2168
2738
  for (const element of event.changedElements) {
2169
- const page = pageByElement.get(element);
2739
+ const page = pages.pageOf(element);
2170
2740
  if (!page) continue;
2171
- for (const node of heldNodes()) {
2741
+ for (const node of live.held()) {
2172
2742
  if (node?.page === page) {
2173
2743
  node.dirty = true;
2174
2744
  changed = true;
@@ -2180,20 +2750,15 @@ async function createGpuCanvas(host, options) {
2180
2750
  awaitingPaint = false;
2181
2751
  frame(true);
2182
2752
  };
2183
- const nodeOf = (target) => {
2184
- const element = target instanceof Element ? target : target.parentElement;
2185
- const node = element && nodeOfElement(nodes, element);
2186
- return node ? nodes.get(node) : void 0;
2187
- };
2188
2753
  const mutations = new MutationObserver((records) => {
2189
2754
  ringRects.clear();
2190
2755
  let changed = false;
2191
2756
  for (const record of records) {
2192
2757
  const target = record.target;
2193
- 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)) {
2194
2759
  continue;
2195
2760
  }
2196
- const node = nodeOf(record.target);
2761
+ const node = nodeOf(nodes, record.target);
2197
2762
  if (node && !node.dirty) {
2198
2763
  node.dirty = true;
2199
2764
  if (!changed) changed = intersects(node.rect, base.view());
@@ -2207,135 +2772,8 @@ async function createGpuCanvas(host, options) {
2207
2772
  characterData: true,
2208
2773
  attributes: true
2209
2774
  });
2210
- const unpin = (page) => {
2211
- if (isPinned(page)) return;
2212
- if (page.registered) canvas.clearElementGeometry(page.element);
2213
- page.registered = false;
2214
- page.element.style.transform = "";
2215
- schedule();
2216
- };
2217
- const letGo = (node) => {
2218
- node.dirty = true;
2219
- unpin(node.page);
2220
- };
2221
- const positionLivePage = () => {
2222
- if (!live || livePositioned) return;
2223
- const camera = base.camera();
2224
- const { zoom } = camera;
2225
- const x = (live.rect.x - camera.x - live.pageX) * zoom;
2226
- const y = (live.rect.y - camera.y - live.pageY) * zoom;
2227
- try {
2228
- if (CANVAS_TRANSFORM_HIT_TESTS) {
2229
- canvas.updateElementGeometry(live.page.element, {
2230
- canvasTransform: new DOMMatrix([zoom, 0, 0, zoom, x, y])
2231
- });
2232
- } else {
2233
- const dpr = canvasDpr();
2234
- const drawn2 = new DOMMatrix([zoom, 0, 0, zoom, x * dpr, y * dpr]);
2235
- live.page.element.style.transform = canvas.getElementTransform(live.page.element, drawn2).toString();
2236
- }
2237
- live.element.style.pointerEvents = "auto";
2238
- livePositioned = true;
2239
- } catch {
2240
- schedule();
2241
- }
2242
- };
2243
- const setLive = (node) => {
2244
- if (node === live) return;
2245
- const previous = live;
2246
- live = node;
2247
- if (previous) {
2248
- previous.element.style.pointerEvents = "none";
2249
- clearTimeout(lingerTimer);
2250
- const earlier = lingering;
2251
- lingering = previous;
2252
- if (earlier && earlier !== previous) letGo(earlier);
2253
- lingerTimer = window.setTimeout(() => {
2254
- const last = lingering;
2255
- if (!last) return;
2256
- lingering = null;
2257
- letGo(last);
2258
- }, LINGER_MS);
2259
- unpin(previous.page);
2260
- }
2261
- if (!node) return;
2262
- const { page } = node;
2263
- page.setDrawable(true, frameNumber);
2264
- if (!page.registered) {
2265
- canvas.updateElementGeometry(page.element, {});
2266
- page.registered = true;
2267
- }
2268
- livePositioned = false;
2269
- positionLivePage();
2270
- schedule();
2271
- };
2272
- const pickLive = () => {
2273
- if (!pointer) return setLive(null);
2274
- setLive(base.nodeAt(screenToWorld(base.camera(), pointer)));
2275
- };
2276
- const onPointerMove = (event) => {
2277
- pointer = pointIn(canvas, event);
2278
- if (event.buttons === 0) pickLive();
2279
- };
2280
- const onPointerLeave = () => {
2281
- pointer = null;
2282
- setLive(null);
2283
- };
2284
- let press = FAR_AWAY;
2285
- let pressedAt = -Infinity;
2286
- let tapped = false;
2287
- const onPointerDown = (event) => {
2288
- const at = pointIn(canvas, event);
2289
- press = screenToWorld(base.camera(), at);
2290
- pressedAt = performance.now();
2291
- if (event.pointerType !== "mouse") {
2292
- pointer = at;
2293
- pickLive();
2294
- tapped = true;
2295
- }
2296
- if (effects.size) schedule();
2297
- };
2298
- const onClick = (event) => {
2299
- if (!tapped) return;
2300
- tapped = false;
2301
- const point = screenToWorld(base.camera(), pointIn(canvas, event));
2302
- const top = base.nodeAt(point);
2303
- if (!top || top.element.contains(event.target)) return;
2304
- let target = elementInNode(top.element, top.rect, point);
2305
- while (target && !(target instanceof HTMLElement)) target = target.parentElement;
2306
- if (!(target instanceof HTMLElement)) return;
2307
- target.focus({ preventScroll: true });
2308
- target.dispatchEvent(
2309
- new MouseEvent("click", {
2310
- bubbles: true,
2311
- cancelable: true,
2312
- composed: true,
2313
- view: window,
2314
- clientX: event.clientX,
2315
- clientY: event.clientY
2316
- })
2317
- );
2318
- };
2319
- const onFocusIn = (event) => {
2320
- const node = nodeOf(event.target) ?? null;
2321
- if (node === focused) return;
2322
- const previous = focused;
2323
- focused = node;
2324
- if (previous) letGo(previous);
2325
- if (node) {
2326
- node.page.setDrawable(true, frameNumber);
2327
- node.dirty = true;
2328
- schedule();
2329
- }
2330
- };
2331
- const onFocusOut = (event) => {
2332
- if (event.relatedTarget && canvas.contains(event.relatedTarget)) return;
2333
- const previous = focused;
2334
- focused = null;
2335
- if (previous) letGo(previous);
2336
- };
2337
2775
  const onResourceLoad = (event) => {
2338
- const node = nodeOf(event.target);
2776
+ const node = nodeOf(nodes, event.target);
2339
2777
  if (!node) return;
2340
2778
  node.dirty = true;
2341
2779
  schedule();
@@ -2346,177 +2784,9 @@ async function createGpuCanvas(host, options) {
2346
2784
  schedule();
2347
2785
  };
2348
2786
  document.fonts.addEventListener("loadingdone", onFontsLoaded);
2349
- surface.addEventListener("pointermove", onPointerMove);
2350
- surface.addEventListener("pointerleave", onPointerLeave);
2351
- surface.addEventListener("pointerdown", onPointerDown, true);
2352
- surface.addEventListener("click", onClick);
2353
- canvas.addEventListener("focusin", onFocusIn);
2354
- canvas.addEventListener("focusout", onFocusOut);
2355
- const usedAt = /* @__PURE__ */ new WeakMap();
2356
- const onUse = (event) => {
2357
- const node = nodeOf(event.target);
2358
- if (node) usedAt.set(node, performance.now());
2359
- };
2360
- const transitionMs = (element) => {
2361
- const style = getComputedStyle(element);
2362
- const seconds = (list) => list.split(",").map((value) => parseFloat(value) || 0);
2363
- const durations = seconds(style.transitionDuration);
2364
- const delays = seconds(style.transitionDelay);
2365
- const longest = Math.max(...durations.map((d, i) => d + (delays[i % delays.length] ?? 0)));
2366
- return Math.min(1e3, longest * 1e3);
2367
- };
2368
- const onTransitionRun = (event) => {
2369
- const node = nodeOf(event.target);
2370
- const now = performance.now();
2371
- if (!node || now - (usedAt.get(node) ?? -Infinity) > USED_MS) return;
2372
- if (!intersects(node.rect, base.view())) return;
2373
- if (smallOnScreen(node)) return;
2374
- const ms = transitionMs(event.target) + 32;
2375
- const end = now + ms;
2376
- if ((transitioning.get(node) ?? 0) >= end) return;
2377
- transitioning.set(node, end);
2378
- node.dirty = true;
2379
- schedule();
2380
- setTimeout(() => {
2381
- if (destroyed || transitioning.get(node) !== end) return;
2382
- transitioning.delete(node);
2383
- letGo(node);
2384
- schedule();
2385
- }, ms);
2386
- };
2387
- for (const type of USE_EVENTS) canvas.addEventListener(type, onUse, true);
2388
- canvas.addEventListener("transitionrun", onTransitionRun);
2389
- const newPage = (width, height) => {
2390
- const page = new Page(width, height);
2391
- pages.set(page, /* @__PURE__ */ new Set());
2392
- pageByElement.set(page.element, page);
2393
- canvas.append(page.element);
2394
- return page;
2395
- };
2396
- const dropPage = (page) => {
2397
- pages.delete(page);
2398
- pageByElement.delete(page.element);
2399
- pageSlots.delete(page);
2400
- page.element.remove();
2401
- };
2402
- const placeNode = (element, rect) => {
2403
- let slot = openPage?.place(rect.width, rect.height) ?? null;
2404
- if (!slot) {
2405
- const page = newPage(
2406
- Math.max(pageSize, Math.ceil(rect.width)),
2407
- Math.max(pageSize, Math.ceil(rect.height))
2408
- );
2409
- if (page.width === pageSize && page.height === pageSize) openPage = page;
2410
- slot = page.place(rect.width, rect.height);
2411
- }
2412
- Object.assign(element.style, {
2413
- position: "absolute",
2414
- left: `${slot.x}px`,
2415
- top: `${slot.y}px`,
2416
- width: `${rect.width}px`,
2417
- height: `${rect.height}px`
2418
- });
2419
- slot.page.element.append(element);
2420
- return slot;
2421
- };
2422
- const releaseNode = (node) => {
2423
- const members = pages.get(node.page);
2424
- members.delete(node);
2425
- if (members.size === 0 && node.page !== openPage) dropPage(node.page);
2426
- };
2427
- const lift = (node) => {
2428
- const previous = node.page;
2429
- const wasLive = live === node;
2430
- if (wasLive) setLive(null);
2431
- const page = newPage(Math.ceil(node.rect.width), Math.ceil(node.rect.height));
2432
- page.place(node.rect.width, node.rect.height);
2433
- const target = page.element;
2434
- if (target.moveBefore) target.moveBefore(node.element, null);
2435
- else target.append(node.element);
2436
- node.element.style.left = "0px";
2437
- node.element.style.top = "0px";
2438
- releaseNode(node);
2439
- node.page = page;
2440
- node.pageX = 0;
2441
- node.pageY = 0;
2442
- node.solo = true;
2443
- pages.get(page).add(node);
2444
- if (pages.has(previous) && !isPinned(previous)) {
2445
- unpin(previous);
2446
- previous.setDrawable(false, frameNumber);
2447
- }
2448
- if (wasLive) pickLive();
2449
- };
2450
- const attach = (node) => {
2451
- const slot = placeNode(node.element, node.rect);
2452
- node.page = slot.page;
2453
- node.pageX = slot.x;
2454
- node.pageY = slot.y;
2455
- node.slot = null;
2456
- node.thumb = null;
2457
- node.solo = false;
2458
- node.dirty = true;
2459
- pages.get(slot.page).add(node);
2460
- };
2461
2787
  return {
2462
2788
  ...base.api,
2463
2789
  mode: "gpu",
2464
- addNode(element, rect) {
2465
- styleNode(element);
2466
- element.style.pointerEvents = "none";
2467
- const node = {
2468
- element,
2469
- rect,
2470
- slotScale: 0,
2471
- thumb: null,
2472
- thumbStale: false,
2473
- thumbAt: -Infinity,
2474
- shown: 0,
2475
- shownFrame: -1,
2476
- targetScale: 0,
2477
- tiles: [],
2478
- preview: null,
2479
- previewRect: null,
2480
- previewScale: 0,
2481
- previewTarget: null,
2482
- previewTargetScale: 0,
2483
- changedAt: -Infinity,
2484
- refreshedAt: -Infinity,
2485
- wholeStale: false,
2486
- solo: false,
2487
- priority: 0
2488
- };
2489
- attach(node);
2490
- nodes.set(element, node);
2491
- schedule();
2492
- },
2493
- updateNode(element, rect) {
2494
- const node = nodes.get(element);
2495
- if (!node) return;
2496
- const resized = node.rect.width !== rect.width || node.rect.height !== rect.height;
2497
- node.rect = rect;
2498
- ringRects.clear();
2499
- if (resized) {
2500
- if (live === node) setLive(null);
2501
- releaseNode(node);
2502
- attach(node);
2503
- }
2504
- schedule();
2505
- },
2506
- removeNode(element) {
2507
- const node = nodes.get(element);
2508
- if (!node) return;
2509
- if (live === node) setLive(null);
2510
- if (lingering === node) lingering = null;
2511
- if (focused === node) focused = null;
2512
- transitioning.delete(node);
2513
- effects.delete(element);
2514
- selection.delete(element);
2515
- nodes.delete(element);
2516
- releaseNode(node);
2517
- element.remove();
2518
- schedule();
2519
- },
2520
2790
  getElementRect(element) {
2521
2791
  const rect = ringRect(element);
2522
2792
  return rect ? { ...rect } : null;
@@ -2550,8 +2820,7 @@ async function createGpuCanvas(host, options) {
2550
2820
  return new Promise((resolve, reject) => {
2551
2821
  captures.push({ node, scale, resolve, reject, tries: 0 });
2552
2822
  node.page.setDrawable(true, frameNumber);
2553
- awaitingPaint = true;
2554
- canvas.requestPaint();
2823
+ requestPaint();
2555
2824
  });
2556
2825
  },
2557
2826
  setBackground(color) {
@@ -2567,7 +2836,7 @@ async function createGpuCanvas(host, options) {
2567
2836
  rasterCallsLastFrame: stats.rasterCallsLastFrame,
2568
2837
  rasterMs: stats.rasterMs,
2569
2838
  pages: pages.size,
2570
- drawablePages: [...pages.keys()].filter((page) => page.drawable).length,
2839
+ drawablePages: [...pages.all()].filter((page) => page.drawable).length,
2571
2840
  atlasLayersUsed: atlas.usedLayers,
2572
2841
  frameMs: stats.frameMs,
2573
2842
  frames: stats.frames,
@@ -2575,26 +2844,17 @@ async function createGpuCanvas(host, options) {
2575
2844
  lostDetail: stats.lostDetail,
2576
2845
  thumbnailFill: atlas.pinnedFill,
2577
2846
  onThumbnail: stats.onThumbnail,
2578
- needs: stats.needs
2847
+ needs: planner.needs
2579
2848
  }),
2580
2849
  destroy() {
2581
2850
  destroyed = true;
2582
2851
  cancelAnimationFrame(rafId);
2583
2852
  clearTimeout(settleTimer);
2584
- clearTimeout(lingerTimer);
2585
- clearTimeout(contentTimer);
2586
- clearTimeout(refreshTimer);
2853
+ live.destroy();
2854
+ planner.destroy();
2587
2855
  mutations.disconnect();
2588
2856
  document.fonts.removeEventListener("loadingdone", onFontsLoaded);
2589
2857
  canvas.removeEventListener("load", onResourceLoad, true);
2590
- surface.removeEventListener("pointermove", onPointerMove);
2591
- surface.removeEventListener("pointerleave", onPointerLeave);
2592
- surface.removeEventListener("pointerdown", onPointerDown, true);
2593
- surface.removeEventListener("click", onClick);
2594
- canvas.removeEventListener("focusin", onFocusIn);
2595
- canvas.removeEventListener("focusout", onFocusOut);
2596
- canvas.removeEventListener("transitionrun", onTransitionRun);
2597
- for (const type of USE_EVENTS) canvas.removeEventListener(type, onUse, true);
2598
2858
  resizeObserver.disconnect();
2599
2859
  canvas.onpaint = null;
2600
2860
  base.destroy();
@@ -2662,6 +2922,7 @@ var round = (rect) => ({
2662
2922
  width: Math.round(rect.width),
2663
2923
  height: Math.round(rect.height)
2664
2924
  });
2925
+ var MAX_CAPTURE_PX = 8192;
2665
2926
  function createAgentView(canvas) {
2666
2927
  const ids = /* @__PURE__ */ new WeakMap();
2667
2928
  const elements = /* @__PURE__ */ new Map();
@@ -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
  }
@@ -2816,6 +3083,16 @@ function createLayers(canvas, pick) {
2816
3083
 
2817
3084
  // src/overlay.ts
2818
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
+ };
2819
3096
  var HANDLE_PX = 8;
2820
3097
  var CURSORS = {
2821
3098
  nw: "nwse-resize",
@@ -2844,7 +3121,7 @@ function createOverlay(surface, color) {
2844
3121
  inset: "0",
2845
3122
  overflow: "hidden",
2846
3123
  pointerEvents: "none",
2847
- zIndex: "2"
3124
+ zIndex: SURFACE_Z.overlay
2848
3125
  });
2849
3126
  const make = (style) => {
2850
3127
  const element = document.createElement("div");
@@ -2910,12 +3187,11 @@ function createOverlay(surface, color) {
2910
3187
  }
2911
3188
  if (state.handles) {
2912
3189
  HANDLES.forEach((handle, i) => {
2913
- const fx = handle.includes("w") ? 0 : handle.includes("e") ? 1 : 0.5;
2914
- const fy = handle.includes("n") ? 0 : handle.includes("s") ? 1 : 0.5;
3190
+ const anchor = HANDLE_ANCHORS[handle];
2915
3191
  place(
2916
3192
  handles[i],
2917
- rect.x + rect.width * fx - HANDLE_PX / 2,
2918
- 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
2919
3195
  );
2920
3196
  });
2921
3197
  }
@@ -3002,7 +3278,7 @@ function guidesFor(rect, targets, edges) {
3002
3278
  }
3003
3279
 
3004
3280
  // src/editor.ts
3005
- function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world) => canvas.getNodesIn(world)) {
3281
+ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR) {
3006
3282
  const box = document.createElement("div");
3007
3283
  Object.assign(box.style, {
3008
3284
  position: "absolute",
@@ -3010,11 +3286,11 @@ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world)
3010
3286
  pointerEvents: "none",
3011
3287
  border: `1px solid ${color}`,
3012
3288
  background: `color-mix(in srgb, ${color} 12%, transparent)`,
3013
- zIndex: "1"
3289
+ zIndex: SURFACE_Z.marquee
3014
3290
  });
3015
3291
  canvas.surface.append(box);
3016
3292
  return {
3017
- /** 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. */
3018
3294
  update(a, b) {
3019
3295
  const x = Math.min(a.x, b.x);
3020
3296
  const y = Math.min(a.y, b.y);
@@ -3028,13 +3304,11 @@ function createMarquee(canvas, color = DEFAULT_SELECTION_COLOR, select = (world)
3028
3304
  height: `${height}px`
3029
3305
  });
3030
3306
  const camera = canvas.getCamera();
3031
- const hits = select({
3307
+ return {
3032
3308
  ...screenToWorld(camera, { x, y }),
3033
3309
  width: width / camera.zoom,
3034
3310
  height: height / camera.zoom
3035
- });
3036
- canvas.setSelection(hits);
3037
- return hits;
3311
+ };
3038
3312
  },
3039
3313
  hide() {
3040
3314
  box.style.display = "none";
@@ -3067,11 +3341,7 @@ function bindEditor(canvas, options = {}) {
3067
3341
  const layers = createLayers(canvas, pick);
3068
3342
  const transforms = !!onTransform && trigger === "click";
3069
3343
  const overlay = createOverlay(surface, color);
3070
- let marqueeLayers = [];
3071
- const box = marquee ? createMarquee(canvas, color, (world) => {
3072
- marqueeLayers = layers.layersIn(scopes.at(-1) ?? null, world);
3073
- return marqueeLayers.flat();
3074
- }) : null;
3344
+ const box = marquee ? createMarquee(canvas, color) : null;
3075
3345
  let selection = [];
3076
3346
  let scopes = [];
3077
3347
  let hovered = null;
@@ -3081,7 +3351,8 @@ function bindEditor(canvas, options = {}) {
3081
3351
  let swallowClick = false;
3082
3352
  let keepFocus = false;
3083
3353
  const commit = (next, nextScopes = scopes) => {
3084
- 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]));
3085
3356
  scopes = selection.length ? nextScopes : [];
3086
3357
  canvas.setSelection(selection.flat());
3087
3358
  onChange?.(selection);
@@ -3142,14 +3413,13 @@ function bindEditor(canvas, options = {}) {
3142
3413
  });
3143
3414
  if (selection.length || hovered || gesture) draw();
3144
3415
  };
3145
- const emit = (phase) => {
3146
- if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
3416
+ const emit = (transform, phase) => {
3147
3417
  onTransform?.({
3148
3418
  phase,
3149
- kind: gesture.kind,
3150
- layers: gesture.layers,
3151
- from: gesture.from,
3152
- 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
3153
3423
  });
3154
3424
  };
3155
3425
  const snapTargets = (moving) => {
@@ -3165,7 +3435,7 @@ function bindEditor(canvas, options = {}) {
3165
3435
  const from = selection.map((layer) => layerRect(canvas, layer));
3166
3436
  if (from.some((rect) => !rect)) return;
3167
3437
  const rects = from;
3168
- gesture = {
3438
+ const transform = {
3169
3439
  kind: handle ? "resize" : "move",
3170
3440
  start,
3171
3441
  handle,
@@ -3176,8 +3446,9 @@ function bindEditor(canvas, options = {}) {
3176
3446
  targets: snapTargets(selection),
3177
3447
  guides: []
3178
3448
  };
3449
+ gesture = transform;
3179
3450
  setHovered(null);
3180
- emit("start");
3451
+ emit(transform, "start");
3181
3452
  };
3182
3453
  const updateTransform = (point, keepRatio) => {
3183
3454
  if (gesture?.kind !== "move" && gesture?.kind !== "resize") return;
@@ -3198,27 +3469,28 @@ function bindEditor(canvas, options = {}) {
3198
3469
  let top = bounds.y;
3199
3470
  let right = bounds.x + bounds.width;
3200
3471
  let bottom = bounds.y + bounds.height;
3201
- if (handle.includes("w")) left = Math.min(left + dx, right - 1);
3202
- if (handle.includes("e")) right = Math.max(right + dx, left + 1);
3203
- if (handle.includes("n")) top = Math.min(top + dy, bottom - 1);
3204
- if (handle.includes("s")) bottom = Math.max(bottom + dy, top + 1);
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);
3205
3477
  next = { x: left, y: top, width: right - left, height: bottom - top };
3206
- const corner = handle.length === 2;
3478
+ const corner = anchor.x !== 0.5 && anchor.y !== 0.5;
3207
3479
  if (keepRatio && corner) {
3208
3480
  const scale = Math.max(next.width / bounds.width, next.height / bounds.height);
3209
3481
  const width = bounds.width * scale;
3210
3482
  const height = bounds.height * scale;
3211
3483
  next = {
3212
- x: handle.includes("w") ? right - width : left,
3213
- y: handle.includes("n") ? bottom - height : top,
3484
+ x: anchor.x === 0 ? right - width : left,
3485
+ y: anchor.y === 0 ? bottom - height : top,
3214
3486
  width,
3215
3487
  height
3216
3488
  };
3217
3489
  gesture.guides = [];
3218
3490
  } else {
3219
3491
  const edges = {
3220
- x: handle.includes("w") ? [0] : handle.includes("e") ? [1] : [],
3221
- 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]
3222
3494
  };
3223
3495
  const snapped = snapRect(next, gesture.targets, threshold, edges);
3224
3496
  if (edges.x[0] === 0)
@@ -3238,7 +3510,7 @@ function bindEditor(canvas, options = {}) {
3238
3510
  width: rect.width * sx,
3239
3511
  height: rect.height * sy
3240
3512
  }));
3241
- emit("move");
3513
+ emit(gesture, "move");
3242
3514
  draw();
3243
3515
  };
3244
3516
  const onPointerDown = (event) => {
@@ -3267,7 +3539,7 @@ function bindEditor(canvas, options = {}) {
3267
3539
  if (!target) {
3268
3540
  if (event.shiftKey && box) {
3269
3541
  take();
3270
- gesture = { kind: "marquee", start: point };
3542
+ gesture = { kind: "marquee", start: point, scopes };
3271
3543
  return;
3272
3544
  }
3273
3545
  if (selection.length) commit([], []);
@@ -3291,35 +3563,27 @@ function bindEditor(canvas, options = {}) {
3291
3563
  if (Math.hypot(pointer.x - gesture.start.x, pointer.y - gesture.start.y) <= CLICK_SLOP)
3292
3564
  return;
3293
3565
  if (gesture.inside || trigger !== "click" && box) {
3294
- gesture = { kind: "marquee", start: gesture.start };
3566
+ gesture = { kind: "marquee", start: gesture.start, scopes };
3295
3567
  } else if (trigger === "click" && transforms) beginTransform(gesture.start, null);
3296
3568
  else return;
3297
3569
  }
3298
3570
  if (gesture?.kind === "marquee") {
3299
- box.update(gesture.start, pointer);
3300
- selection = marqueeLayers;
3301
- onChange?.(selection);
3302
- draw();
3571
+ const world = box.update(gesture.start, pointer);
3572
+ commit(layers.layersIn(gesture.scopes.at(-1) ?? null, world), gesture.scopes);
3303
3573
  } else updateTransform(pointer, event.shiftKey);
3304
3574
  };
3305
3575
  const onPointerUp = () => {
3306
3576
  const done = gesture;
3307
3577
  gesture = null;
3308
3578
  if (!done) return;
3309
- if (done.kind === "marquee") {
3310
- box.hide();
3311
- commit(marqueeLayers);
3312
- } else if (done.kind === "press") {
3579
+ if (done.kind === "marquee") box.hide();
3580
+ else if (done.kind === "press") {
3313
3581
  const { layer, scopes: inside } = done.target;
3314
3582
  if (!done.additive) commit([layer], inside);
3315
3583
  else if (layerIn(layer, selection))
3316
3584
  commit(selection.filter((other) => !sameLayer(other, layer)));
3317
3585
  else commit([...selection, layer], inside);
3318
- } else {
3319
- gesture = done;
3320
- emit("end");
3321
- gesture = null;
3322
- }
3586
+ } else emit(done, "end");
3323
3587
  updateHover();
3324
3588
  draw();
3325
3589
  };
@@ -3383,7 +3647,7 @@ function bindEditor(canvas, options = {}) {
3383
3647
  }
3384
3648
  if (event.key === "Escape") {
3385
3649
  if (gesture?.kind === "move" || gesture?.kind === "resize") {
3386
- emit("cancel");
3650
+ emit(gesture, "cancel");
3387
3651
  gesture = null;
3388
3652
  } else if (scopes.length) up();
3389
3653
  else if (selection.length) commit([], []);
@@ -3466,7 +3730,7 @@ function createMarks(canvas, { color = DEFAULT_SELECTION_COLOR } = {}) {
3466
3730
  inset: "0",
3467
3731
  overflow: "hidden",
3468
3732
  pointerEvents: "none",
3469
- zIndex: "1"
3733
+ zIndex: SURFACE_Z.marks
3470
3734
  });
3471
3735
  const svg = document.createElementNS(SVG, "svg");
3472
3736
  Object.assign(svg.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
@@ -3518,21 +3782,22 @@ function createMarks(canvas, { color = DEFAULT_SELECTION_COLOR } = {}) {
3518
3782
  root.append(element);
3519
3783
  return element;
3520
3784
  };
3521
- const marks = /* @__PURE__ */ new Set();
3785
+ const marks = /* @__PURE__ */ new Map();
3522
3786
  let frame = 0;
3523
3787
  const loop = () => {
3524
3788
  frame = marks.size ? requestAnimationFrame(loop) : 0;
3525
- for (const draw of marks) draw();
3789
+ for (const draw of marks.keys()) draw();
3526
3790
  };
3527
3791
  const add = (draw, remove) => {
3528
- marks.add(draw);
3529
- if (!frame) frame = requestAnimationFrame(loop);
3530
- return {
3792
+ const mark = {
3531
3793
  remove() {
3532
3794
  marks.delete(draw);
3533
3795
  remove();
3534
3796
  }
3535
3797
  };
3798
+ marks.set(draw, mark.remove);
3799
+ if (!frame) frame = requestAnimationFrame(loop);
3800
+ return mark;
3536
3801
  };
3537
3802
  return {
3538
3803
  /** An arrow from one element (or world point) to another, with an optional label. */
@@ -3683,9 +3948,7 @@ function createMarks(canvas, { color = DEFAULT_SELECTION_COLOR } = {}) {
3683
3948
  },
3684
3949
  /** Removes every mark. */
3685
3950
  clear() {
3686
- for (const draw of [...marks]) marks.delete(draw);
3687
- root.querySelectorAll(":scope > div").forEach((element) => element.remove());
3688
- svg.querySelectorAll(":scope > path").forEach((element) => element.remove());
3951
+ for (const remove of [...marks.values()]) remove();
3689
3952
  },
3690
3953
  destroy() {
3691
3954
  cancelAnimationFrame(frame);
@@ -3754,6 +4017,7 @@ function recordFilmstrip(canvas, node, {
3754
4017
  const frames = [];
3755
4018
  let stopped = false;
3756
4019
  let timer = 0;
4020
+ let warned = false;
3757
4021
  const take = () => {
3758
4022
  canvas.snapshot(node, { scale }).then((image) => {
3759
4023
  if (stopped) return;
@@ -3761,7 +4025,10 @@ function recordFilmstrip(canvas, node, {
3761
4025
  frames.push(frame);
3762
4026
  if (frames.length > max) frames.shift();
3763
4027
  onFrame?.(frame, frames);
3764
- }).catch(() => {
4028
+ }).catch((error) => {
4029
+ if (warned) return;
4030
+ warned = true;
4031
+ console.warn("gpucanvas: filmstrip snapshot failed", error);
3765
4032
  });
3766
4033
  };
3767
4034
  const observer = new MutationObserver((records) => {
@@ -3834,6 +4101,7 @@ async function createGpuCanvas2(host, options = {}) {
3834
4101
  return await createGpuCanvas(host, options);
3835
4102
  } catch (error) {
3836
4103
  if (mode === "gpu") throw error;
4104
+ console.warn("gpucanvas: the GPU backend failed to start, using DOM", error);
3837
4105
  return createDomCanvas(host, options);
3838
4106
  }
3839
4107
  }
@@ -3858,4 +4126,4 @@ export {
3858
4126
  swapNodes,
3859
4127
  createGpuCanvas2 as createGpuCanvas
3860
4128
  };
3861
- //# sourceMappingURL=chunk-IJ3R6X75.js.map
4129
+ //# sourceMappingURL=chunk-IIRGNX76.js.map