@vectojs/core 1.38.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -11,14 +11,14 @@ import {
11
11
  parseColorToRGBA,
12
12
  sanitizeUrl,
13
13
  setRendererDevMode
14
- } from "./chunk-LWEMD34D.mjs";
14
+ } from "./chunk-RUM2KEDI.mjs";
15
15
  import {
16
16
  Entity,
17
17
  MSDFTextEntity,
18
18
  SVGEntity,
19
19
  VectoJSEvent,
20
20
  contentLineInHint
21
- } from "./chunk-QVTCKWDO.mjs";
21
+ } from "./chunk-7PYD5UDX.mjs";
22
22
 
23
23
  // src/tree/ComputeParticleEntity.ts
24
24
  var PARTICLE_STRIDE_FLOATS = 8;
@@ -224,14 +224,14 @@ var ComputeParticleEntity = class extends Entity {
224
224
  const bounceDamping = Math.max(0, Math.min(1, this.bounceDamping));
225
225
  const maxVelocity = Math.max(1, this.maxVelocity);
226
226
  for (let i = 0; i < this.maxParticles; i++) {
227
- const offset = i * 8;
228
- let px = this.particleData[offset];
229
- let py = this.particleData[offset + 1];
230
- let vx = this.particleData[offset + 2];
231
- let vy = this.particleData[offset + 3];
232
- const ox = this.particleData[offset + 4];
233
- const oy = this.particleData[offset + 5];
234
- const life = this.particleData[offset + 7];
227
+ const offset = i * PARTICLE_STRIDE_FLOATS;
228
+ let px = this.particleData[offset + PARTICLE_OFFSET_POSITION_X];
229
+ let py = this.particleData[offset + PARTICLE_OFFSET_POSITION_Y];
230
+ let vx = this.particleData[offset + PARTICLE_OFFSET_VELOCITY_X];
231
+ let vy = this.particleData[offset + PARTICLE_OFFSET_VELOCITY_Y];
232
+ const ox = this.particleData[offset + PARTICLE_OFFSET_ORIGIN_X];
233
+ const oy = this.particleData[offset + PARTICLE_OFFSET_ORIGIN_Y];
234
+ const life = this.particleData[offset + PARTICLE_OFFSET_LIFE];
235
235
  if (isNaN(px)) px = ox;
236
236
  if (isNaN(py)) py = oy;
237
237
  if (isNaN(vx)) vx = 0;
@@ -289,11 +289,11 @@ var ComputeParticleEntity = class extends Entity {
289
289
  if (life >= 0) {
290
290
  nlife = Math.max(0, life - safeDt * 0.5);
291
291
  }
292
- this.particleData[offset] = npx;
293
- this.particleData[offset + 1] = npy;
294
- this.particleData[offset + 2] = nvx;
295
- this.particleData[offset + 3] = nvy;
296
- this.particleData[offset + 7] = nlife;
292
+ this.particleData[offset + PARTICLE_OFFSET_POSITION_X] = npx;
293
+ this.particleData[offset + PARTICLE_OFFSET_POSITION_Y] = npy;
294
+ this.particleData[offset + PARTICLE_OFFSET_VELOCITY_X] = nvx;
295
+ this.particleData[offset + PARTICLE_OFFSET_VELOCITY_Y] = nvy;
296
+ this.particleData[offset + PARTICLE_OFFSET_LIFE] = nlife;
297
297
  }
298
298
  this.pendingExplosion = null;
299
299
  }
@@ -593,7 +593,12 @@ var ContentGridProjector = class {
593
593
  lineIndex === 0
594
594
  );
595
595
  const reusable = existingLines[domIndex];
596
- if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
596
+ if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature) {
597
+ const indexLabel = `${lineIndex}`;
598
+ if (reusable.dataset.vectoGridLine !== indexLabel) {
599
+ reusable.dataset.vectoGridLine = indexLabel;
600
+ reusable.style.top = `${(projectedLine?.y ?? lineIndex * grid.lineHeight) + baseline - cssLineBoxBaseline(lineFont, lineHeight)}px`;
601
+ }
597
602
  continue;
598
603
  }
599
604
  if (selectionLine !== null && selectionLine === lineIndex) rebuiltSelectionLine = true;
@@ -779,6 +784,12 @@ function projectionCaretAt(root, absoluteOffset, affinity) {
779
784
  }
780
785
 
781
786
  // src/tree/scene/A11yProjectionManager.ts
787
+ function orderBandBottom(r) {
788
+ return r.container ? r.top + 4 : r.top + Math.max(Number.parseFloat(r.el.style.height) || 0, 4);
789
+ }
790
+ var byTopThenIndex = (p, q) => p.top - q.top || p.i - q.i;
791
+ var byLeftAscThenIndex = (p, q) => p.left - q.left || p.i - q.i;
792
+ var byLeftDescThenIndex = (p, q) => q.left - p.left || p.i - q.i;
782
793
  var A11yProjectionManager = class {
783
794
  /**
784
795
  * Set by anything that changes which elements exist or how they nest, and
@@ -820,6 +831,18 @@ var A11yProjectionManager = class {
820
831
  * implicit root region. See {@link sortNormalElementsVisually}.
821
832
  */
822
833
  orderRegions = /* @__PURE__ */ new Map();
834
+ /**
835
+ * Decoration records for {@link sortNormalElementsVisually}: one per ordered
836
+ * element, pooled across passes and rewritten in place — the sort runs on
837
+ * structure-change frames, where the module's zero-GC policy applies.
838
+ */
839
+ orderDecorated = [];
840
+ /** Region → decorated elements, reused by {@link sortNormalElementsVisually}. */
841
+ orderBucketByRegion = /* @__PURE__ */ new Map();
842
+ /** Pool of bucket arrays backing {@link orderBucketByRegion} across passes. */
843
+ orderBuckets = [];
844
+ /** Scratch for one visual row inside {@link sortNormalElementsVisually}. */
845
+ orderRowScratch = [];
823
846
  /** Mark the projected DOM as needing a reorder on the next pass. */
824
847
  markNeedsReorder() {
825
848
  this.needsReorder = true;
@@ -877,7 +900,8 @@ var A11yProjectionManager = class {
877
900
  this.orderCursors.set(parent, at + 1);
878
901
  const current = parent.childNodes[at];
879
902
  if (current !== expected) {
880
- const refocus = document.activeElement === expected;
903
+ const active = document.activeElement;
904
+ const refocusTarget = active && (active === expected || expected.contains(active)) ? active : null;
881
905
  if (!selectionSnapshotTaken) {
882
906
  selectionSnapshotTaken = true;
883
907
  const live = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
@@ -893,7 +917,7 @@ var A11yProjectionManager = class {
893
917
  selectionMoved = true;
894
918
  }
895
919
  parent.insertBefore(expected, current || null);
896
- if (refocus) expected.focus({ preventScroll: true });
920
+ if (refocusTarget) refocusTarget.focus({ preventScroll: true });
897
921
  }
898
922
  }
899
923
  if (selectionMoved && selection && selAnchorNode && selFocusNode) {
@@ -940,7 +964,6 @@ var A11yProjectionManager = class {
940
964
  sortNormalElementsVisually(rtl) {
941
965
  const els = this.normalElements;
942
966
  if (els.length < 2) return;
943
- const heightOf = (el) => Math.max(Number.parseFloat(el.style.height) || 0, 4);
944
967
  const members = this.orderMembers;
945
968
  const containers = this.orderContainers;
946
969
  members.clear();
@@ -951,52 +974,70 @@ var A11yProjectionManager = class {
951
974
  if (members.has(p)) containers.add(p);
952
975
  }
953
976
  }
954
- const absolute = (el) => {
977
+ const decorated = this.orderDecorated;
978
+ while (decorated.length < els.length) {
979
+ decorated.push({ el: els[0], i: 0, top: 0, left: 0, container: false });
980
+ }
981
+ for (let i = 0; i < els.length; i++) {
982
+ const rec = decorated[i];
955
983
  let top = 0;
956
984
  let left = 0;
957
- for (let node = el; node; node = node.parentElement) {
985
+ for (let node = els[i]; node; node = node.parentElement) {
958
986
  top += Number.parseFloat(node.style.top) || 0;
959
987
  left += Number.parseFloat(node.style.left) || 0;
960
988
  const parent = node.parentElement;
961
989
  if (!parent || !members.has(parent)) break;
962
990
  }
963
- return { top, left };
964
- };
965
- const decorated = els.map((el, i) => {
966
- const { top, left } = absolute(el);
967
- return { el, i, top, left, container: containers.has(el) };
968
- });
991
+ rec.el = els[i];
992
+ rec.i = i;
993
+ rec.top = top;
994
+ rec.left = left;
995
+ rec.container = containers.has(els[i]);
996
+ }
969
997
  const regions = this.orderRegions;
970
- const buckets = /* @__PURE__ */ new Map();
971
- for (const d of decorated) {
972
- const key = regions.get(d.el) ?? null;
973
- const bucket = buckets.get(key);
974
- if (bucket) bucket.push(d);
975
- else buckets.set(key, [d]);
976
- }
977
- const bandBottom = (r) => r.top + (r.container ? 4 : heightOf(r.el));
978
- const sorted = [];
998
+ const buckets = this.orderBucketByRegion;
999
+ const bucketPool = this.orderBuckets;
1000
+ let bucketsUsed = 0;
1001
+ buckets.clear();
1002
+ for (let i = 0; i < els.length; i++) {
1003
+ const rec = decorated[i];
1004
+ const key = regions.get(rec.el) ?? null;
1005
+ let bucket = buckets.get(key);
1006
+ if (!bucket) {
1007
+ bucket = bucketPool[bucketsUsed];
1008
+ if (bucket === void 0) {
1009
+ bucket = [];
1010
+ bucketPool.push(bucket);
1011
+ }
1012
+ bucketsUsed++;
1013
+ bucket.length = 0;
1014
+ buckets.set(key, bucket);
1015
+ }
1016
+ bucket.push(rec);
1017
+ }
1018
+ let out = 0;
979
1019
  for (const order of buckets.values()) {
980
- order.sort((p, q) => p.top - q.top || p.i - q.i);
1020
+ order.sort(byTopThenIndex);
981
1021
  let rowStart = 0;
982
- let rowBottom = order.length ? bandBottom(order[0]) : 0;
1022
+ let rowBottom = order.length ? orderBandBottom(order[0]) : 0;
983
1023
  const flushRow = (end) => {
984
- const row = order.slice(rowStart, end);
985
- row.sort((p, q) => (rtl ? q.left - p.left : p.left - q.left) || p.i - q.i);
986
- for (const r of row) sorted.push(r.el);
1024
+ const row = this.orderRowScratch;
1025
+ row.length = 0;
1026
+ for (let m = rowStart; m < end; m++) row.push(order[m]);
1027
+ row.sort(rtl ? byLeftDescThenIndex : byLeftAscThenIndex);
1028
+ for (const r of row) els[out++] = r.el;
987
1029
  };
988
1030
  for (let k = 1; k < order.length; k++) {
989
1031
  if (order[k].top < rowBottom) {
990
- rowBottom = Math.max(rowBottom, bandBottom(order[k]));
1032
+ rowBottom = Math.max(rowBottom, orderBandBottom(order[k]));
991
1033
  } else {
992
1034
  flushRow(k);
993
1035
  rowStart = k;
994
- rowBottom = bandBottom(order[k]);
1036
+ rowBottom = orderBandBottom(order[k]);
995
1037
  }
996
1038
  }
997
1039
  flushRow(order.length);
998
1040
  }
999
- for (let i = 0; i < sorted.length; i++) els[i] = sorted[i];
1000
1041
  }
1001
1042
  };
1002
1043
 
@@ -1327,6 +1368,8 @@ var DriverTicker = class {
1327
1368
  tD.length = tweenCount;
1328
1369
  backend.ensure(springCount, tweenCount);
1329
1370
  backend.revalidateViews();
1371
+ let springsRejected = false;
1372
+ let tweensRejected = false;
1330
1373
  if (springCount > 0) {
1331
1374
  const sv = backend.springView();
1332
1375
  for (let i = 0; i < springCount; i++) {
@@ -1342,8 +1385,7 @@ var DriverTicker = class {
1342
1385
  for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
1343
1386
  } else {
1344
1387
  for (let i = 0; i < springCount; i++) sD[i].tick(dt);
1345
- this.backends.animReason = "rejected";
1346
- this.backends.animBatchedLastFrame = false;
1388
+ springsRejected = true;
1347
1389
  }
1348
1390
  }
1349
1391
  if (tweenCount > 0) {
@@ -1361,10 +1403,13 @@ var DriverTicker = class {
1361
1403
  for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
1362
1404
  } else {
1363
1405
  for (let i = 0; i < tweenCount; i++) tD[i].tick(dt);
1364
- this.backends.animReason = "rejected";
1365
- this.backends.animBatchedLastFrame = false;
1406
+ tweensRejected = true;
1366
1407
  }
1367
1408
  }
1409
+ if (springsRejected || tweensRejected) {
1410
+ this.backends.animReason = springsRejected && tweensRejected ? "rejected" : springsRejected ? "springs-rejected" : "tweens-rejected";
1411
+ this.backends.animBatchedLastFrame = springCount > 0 && !springsRejected || tweenCount > 0 && !tweensRejected;
1412
+ }
1368
1413
  for (let i = 0; i < springCount; i++) sE[i]._applyDriverTick(sP[i], sD[i]);
1369
1414
  for (let i = 0; i < tweenCount; i++) tE[i]._applyDriverTick(tP[i], tD[i]);
1370
1415
  }
@@ -1525,9 +1570,6 @@ function intersectBounds(a, b) {
1525
1570
  height: Math.max(0, bottom - y)
1526
1571
  };
1527
1572
  }
1528
- function pointInBounds(b, x, y) {
1529
- return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
1530
- }
1531
1573
  var HitTester = class {
1532
1574
  root;
1533
1575
  overlayRoot;
@@ -1574,7 +1616,7 @@ var HitTester = class {
1574
1616
  this.backends.hitReason = "not-installed";
1575
1617
  return false;
1576
1618
  }
1577
- if (this.backends.hitGridFrame === frame) {
1619
+ if (this.backends.hitGridFrame === frame && this.backends.hitGridStructureVersion === this.backends.structureVersion) {
1578
1620
  return this.backends.hitGridOk;
1579
1621
  }
1580
1622
  let gathered = null;
@@ -1605,6 +1647,7 @@ var HitTester = class {
1605
1647
  this.slotEntity = gathered.slotEntity;
1606
1648
  this.boundless = gathered.boundless;
1607
1649
  this.backends.hitGridFrame = frame;
1650
+ this.backends.hitGridStructureVersion = this.backends.structureVersion;
1608
1651
  this.backends.hitGridOk = ok;
1609
1652
  this.backends.hitReason = ok ? "active" : "rejected";
1610
1653
  return ok;
@@ -1655,7 +1698,7 @@ var HitTester = class {
1655
1698
  const hit = this.findHitRecursively(node.children[i], x, y, childClip);
1656
1699
  if (hit) return hit;
1657
1700
  }
1658
- if (node.isPointInside && node.isPointInside(x, y) && (!clip || pointInBounds(clip, x, y)) && !this.isPointerTransparent(node)) {
1701
+ if (node.isPointInside && node.isPointInside(x, y) && this.isInsideAllClippers(node, x, y) && !this.isPointerTransparent(node)) {
1659
1702
  return node;
1660
1703
  }
1661
1704
  return null;
@@ -1667,6 +1710,25 @@ var HitTester = class {
1667
1710
  const attrs = node.getA11yAttributes();
1668
1711
  return attrs.disabled === true || attrs.pointerEvents === "none";
1669
1712
  }
1713
+ /**
1714
+ * Exact rotation-aware `clipChildren` test shared by BOTH hit paths: the
1715
+ * point must lie inside every clipChildren ancestor's local rect — the same
1716
+ * shape rendering clips to — not merely inside the ancestor's world AABB.
1717
+ * For a rotated clipper those disagree, and until both paths used the exact
1718
+ * rect a query's answer depended on which backend was active (#680). The
1719
+ * clip-stack AABB intersection in {@link findHitRecursively} remains purely
1720
+ * as a subtree-pruning pre-filter; this is the authoritative gate.
1721
+ */
1722
+ isInsideAllClippers(node, x, y) {
1723
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
1724
+ if (!ancestor.clipChildren) continue;
1725
+ const local = ancestor.worldToLocal(x, y);
1726
+ if (!local || local.x < 0 || local.y < 0 || local.x > ancestor.width || local.y > ancestor.height) {
1727
+ return false;
1728
+ }
1729
+ }
1730
+ return true;
1731
+ }
1670
1732
  /**
1671
1733
  * Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit,
1672
1734
  * applying the same visibility/input gating as {@link findHitRecursively} but
@@ -1681,14 +1743,8 @@ var HitTester = class {
1681
1743
  if (node.opacity <= 0) return false;
1682
1744
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
1683
1745
  if (ancestor.opacity <= 0) return false;
1684
- if (ancestor.clipChildren && ancestor.width > 0 && ancestor.height > 0) {
1685
- const local = ancestor.worldToLocal(x, y);
1686
- if (!local || local.x < 0 || local.y < 0 || local.x > ancestor.width || local.y > ancestor.height) {
1687
- return false;
1688
- }
1689
- }
1690
1746
  }
1691
- return true;
1747
+ return this.isInsideAllClippers(node, x, y);
1692
1748
  }
1693
1749
  };
1694
1750
 
@@ -2441,7 +2497,8 @@ var WasmTransformBackend = class {
2441
2497
  this.lastStatus = WASM_STATUS.CAPACITY;
2442
2498
  return;
2443
2499
  }
2444
- const status = kernel === "scalar" ? this.ex.compose_scalar() : this.ex.compose_simd();
2500
+ const simd = kernel === "simd" && typeof this.ex.compose_simd === "function";
2501
+ const status = simd ? this.ex.compose_simd() : this.ex.compose_scalar();
2445
2502
  this.lastStatus = status;
2446
2503
  if (status !== WASM_STATUS.OK) return;
2447
2504
  store.wa.set(this.vwa.subarray(0, n));
@@ -2463,7 +2520,8 @@ var WasmTransformBackend = class {
2463
2520
  * {@link uploadRuns} after a topology change.
2464
2521
  */
2465
2522
  runKernel(kernel = "simd") {
2466
- const status = kernel === "scalar" ? this.ex.compose_scalar() : this.ex.compose_simd();
2523
+ const simd = kernel === "simd" && typeof this.ex.compose_simd === "function";
2524
+ const status = simd ? this.ex.compose_simd() : this.ex.compose_scalar();
2467
2525
  this.lastStatus = status;
2468
2526
  return status;
2469
2527
  }
@@ -2515,21 +2573,21 @@ var WasmTransformBackend = class {
2515
2573
  /**
2516
2574
  * Compute world-space AABBs for `store` in WASM (G1+), writing back into its
2517
2575
  * `aminx/aminy/amaxx/amaxy` arrays. Uploads the local bounds, runs the AABB
2518
- * pass, reads results back. Result is bit-identical to `computeAabbsJS(store)`.
2519
- * `compose` (or `runKernel`) must have populated the world matrices first —
2520
- * this pass reads them. For the resident (no-copy) integration, write bounds
2521
- * via {@link boundsView} and read via {@link aabbView} + call
2522
- * {@link runAabbs} instead.
2576
+ * pass, reads results back. Result is bit-identical to `computeAabbsJS(store)`
2577
+ * for both kernels. `compose` (or `runKernel`) must have populated the world
2578
+ * matrices first — this pass reads them. For the resident (no-copy)
2579
+ * integration, write bounds via {@link boundsView} and read via
2580
+ * {@link aabbView} + call {@link runAabbs} instead.
2523
2581
  */
2524
- computeAabbs(store) {
2582
+ computeAabbs(store, kernel = "simd") {
2525
2583
  this.ensure(store.count, store.runCount);
2526
2584
  const n = store.count;
2527
2585
  this.vbx.set(store.bx.subarray(0, n));
2528
2586
  this.vby.set(store.by.subarray(0, n));
2529
2587
  this.vbw.set(store.bw.subarray(0, n));
2530
2588
  this.vbh.set(store.bh.subarray(0, n));
2531
- this.lastStatus = this.ex.compute_aabbs(n);
2532
- if (this.lastStatus !== WASM_STATUS.OK) return;
2589
+ const ok = this.runAabbs(n, kernel);
2590
+ if (!ok) return;
2533
2591
  store.aminx.set(this.vaminx.subarray(0, n));
2534
2592
  store.aminy.set(this.vaminy.subarray(0, n));
2535
2593
  store.amaxx.set(this.vamaxx.subarray(0, n));
@@ -2540,8 +2598,9 @@ var WasmTransformBackend = class {
2540
2598
  * composed). No upload/readback — the per-frame resident path. Returns
2541
2599
  * `false` if the kernel rejected `count` (beyond capacity, or uninitialized),
2542
2600
  * in which case {@link aabbView} still holds the previous frame's bounds. */
2543
- runAabbs(count) {
2544
- this.lastStatus = this.ex.compute_aabbs(count);
2601
+ runAabbs(count, kernel = "simd") {
2602
+ const simd = kernel === "simd" && typeof this.ex.compute_aabbs_simd === "function";
2603
+ this.lastStatus = simd ? this.ex.compute_aabbs_simd(count) : this.ex.compute_aabbs(count);
2545
2604
  return this.lastStatus === WASM_STATUS.OK;
2546
2605
  }
2547
2606
  /** Resident wasm local-bounds input views (`bx,by,bw,bh`) for the AABB pass. */
@@ -3204,6 +3263,7 @@ var WasmBackendFacade = class {
3204
3263
  setHit(backend) {
3205
3264
  this._hit = backend;
3206
3265
  this.hitGridFrame = -1;
3266
+ this.hitGridStructureVersion = -1;
3207
3267
  this.hitReason = backend ? "not-applicable" : "not-installed";
3208
3268
  }
3209
3269
  /** The installed batched-animation backend, or `null` for the JS tick. */
@@ -3260,6 +3320,13 @@ var WasmBackendFacade = class {
3260
3320
  // domain.
3261
3321
  /** Frame the hit grid was last built for; `-1` forces a rebuild. */
3262
3322
  hitGridFrame = -1;
3323
+ /**
3324
+ * Structure version the hit grid was built for; `-1` forces a rebuild.
3325
+ * Half of the cache key the comment above promises: without it, a pointer
3326
+ * query in the same frame as a structural mutation would resolve against
3327
+ * pre-mutation geometry while the JS walk sees live state.
3328
+ */
3329
+ hitGridStructureVersion = -1;
3263
3330
  /** Whether that build succeeded (did not overflow its item budget). */
3264
3331
  hitGridOk = false;
3265
3332
  /**
@@ -3399,6 +3466,38 @@ var WasmBackendFacade = class {
3399
3466
  }
3400
3467
  };
3401
3468
 
3469
+ // src/tree/scene/keyboard.ts
3470
+ function normalizeChord(input) {
3471
+ if (typeof input === "string") {
3472
+ const parts = input.split("+").map((p) => p.trim()).filter(Boolean);
3473
+ const mods = /* @__PURE__ */ new Set();
3474
+ let key2 = "";
3475
+ for (const p of parts) {
3476
+ const low = p.toLowerCase();
3477
+ if (low === "ctrl" || low === "control") mods.add("Control");
3478
+ else if (low === "alt") mods.add("Alt");
3479
+ else if (low === "shift") mods.add("Shift");
3480
+ else if (low === "meta" || low === "cmd" || low === "super") mods.add("Meta");
3481
+ else key2 = p.length === 1 ? p.toUpperCase() : p;
3482
+ }
3483
+ const ordered2 = ["Control", "Alt", "Shift", "Meta"].filter((m) => mods.has(m));
3484
+ return key2 ? [...ordered2, key2].join("+") : ordered2.join("+");
3485
+ }
3486
+ const ordered = [];
3487
+ if (input.ctrlKey) ordered.push("Control");
3488
+ if (input.altKey) ordered.push("Alt");
3489
+ if (input.shiftKey) ordered.push("Shift");
3490
+ if (input.metaKey) ordered.push("Meta");
3491
+ let key = input.key;
3492
+ if (key === " ") key = "Space";
3493
+ else if (key.length === 1) key = key.toUpperCase();
3494
+ if (["Control", "Alt", "Shift", "Meta"].includes(key)) {
3495
+ return ordered.join("+");
3496
+ }
3497
+ ordered.push(key);
3498
+ return ordered.join("+");
3499
+ }
3500
+
3402
3501
  // src/tree/Scene.ts
3403
3502
  var RANGE_VALUE_ROLES = /* @__PURE__ */ new Set(["slider", "spinbutton", "progressbar", "scrollbar", "meter"]);
3404
3503
  var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
@@ -3412,6 +3511,24 @@ var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
3412
3511
  "slider",
3413
3512
  "combobox"
3414
3513
  ]);
3514
+ var KEYBOARD_OWNING_ROLES = /* @__PURE__ */ new Set([
3515
+ ...INTERACTIVE_A11Y_ROLES,
3516
+ "option",
3517
+ "listbox",
3518
+ "textbox",
3519
+ "searchbox",
3520
+ "spinbutton"
3521
+ ]);
3522
+ function ownsKeyboard(el) {
3523
+ if (!el) return false;
3524
+ if (el === document.body || el === document.documentElement) return false;
3525
+ if (el.hasAttribute("data-vecto-a11y-root")) return false;
3526
+ const tag = el.tagName;
3527
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
3528
+ if (el.isContentEditable) return true;
3529
+ const role = el.getAttribute("role");
3530
+ return role !== null && KEYBOARD_OWNING_ROLES.has(role);
3531
+ }
3415
3532
  var A11Y_REQUIRED_OWNED = /* @__PURE__ */ new Map([
3416
3533
  ["grid", /* @__PURE__ */ new Set(["row", "rowgroup"])],
3417
3534
  ["table", /* @__PURE__ */ new Set(["row", "rowgroup"])],
@@ -3950,6 +4067,9 @@ var Scene = class _Scene {
3950
4067
  // server-side (e.g. headless layout / vector export) without jsdom.
3951
4068
  a11yRoot;
3952
4069
  a11yElements = /* @__PURE__ */ new Map();
4070
+ /** Elements that already have the synthetic Enter/Space activation handler
4071
+ * (see the syncA11y refresh pass) — install-once bookkeeping (#694). */
4072
+ keyboardActivatedA11y = /* @__PURE__ */ new WeakSet();
3953
4073
  // --- domain: content-projection — projected DOM, sync state, calibration, selection ---
3954
4074
  /** DOM nodes mirroring static text content, keyed by entity id. */
3955
4075
  contentElements = /* @__PURE__ */ new Map();
@@ -4574,6 +4694,15 @@ var Scene = class _Scene {
4574
4694
  _registerActiveDriverSubtree(entity) {
4575
4695
  this._driverTicker.registerSubtree(entity);
4576
4696
  }
4697
+ /**
4698
+ * The `dt` of the frame currently executing its update walk (the WASM-mode
4699
+ * pre-pass or the JS-mode interleaved render walk), or `null` outside it.
4700
+ * Read by `Entity._spawnDriver`: a driver spawned mid-walk onto an entity the
4701
+ * batched pass already claimed this frame must be advanced once here, or
4702
+ * `tickDrivers()`'s per-frame stamp would skip it a full frame — a one-frame
4703
+ * JS/WASM divergence. Written in {@link render} next to `_tickBatchedDrivers`.
4704
+ */
4705
+ _updateWalkDt = null;
4577
4706
  // --- domain: render-scheduler — batched driver tick (extraction 6), reads the anim backend ---
4578
4707
  /**
4579
4708
  * Advance every registered entity's active drivers for this frame, batching
@@ -4661,6 +4790,13 @@ var Scene = class _Scene {
4661
4790
  /** Element the pointer listeners are bound to (parent container if present,
4662
4791
  * else the canvas). Stored so `destroy()` detaches from the same element. */
4663
4792
  pointerEventTarget = null;
4793
+ // --- scene-level keyboard channel state (see on/off/registerShortcut) ---
4794
+ keydownHandlers = [];
4795
+ keyupHandlers = [];
4796
+ /** Chord-normalized shortcut table, matched on keydown only. */
4797
+ shortcuts = [];
4798
+ windowKeyDownHandler = null;
4799
+ windowKeyUpHandler = null;
4664
4800
  hasWarnedZeroSize = false;
4665
4801
  /**
4666
4802
  * Latch for {@link Scene.resize}'s invalid-dimension warning.
@@ -5267,6 +5403,19 @@ var Scene = class _Scene {
5267
5403
  window.removeEventListener("blur", this.contentSelectionEndListener);
5268
5404
  this.contentSelectionEndListener = null;
5269
5405
  }
5406
+ if (typeof window !== "undefined") {
5407
+ if (this.windowKeyDownHandler) {
5408
+ window.removeEventListener("keydown", this.windowKeyDownHandler);
5409
+ }
5410
+ if (this.windowKeyUpHandler) {
5411
+ window.removeEventListener("keyup", this.windowKeyUpHandler);
5412
+ }
5413
+ }
5414
+ this.windowKeyDownHandler = null;
5415
+ this.windowKeyUpHandler = null;
5416
+ this.keydownHandlers.length = 0;
5417
+ this.keyupHandlers.length = 0;
5418
+ this.shortcuts.length = 0;
5270
5419
  if (typeof window !== "undefined" && this.pointerEventTarget && typeof this.pointerEventTarget.removeEventListener === "function") {
5271
5420
  if (this.pointerMoveListener) {
5272
5421
  this.pointerEventTarget.removeEventListener("pointermove", this.pointerMoveListener);
@@ -5366,6 +5515,7 @@ var Scene = class _Scene {
5366
5515
  this.lastTime = typeof performance !== "undefined" ? performance.now() : 0;
5367
5516
  this.watchCanvasVisibility();
5368
5517
  this.scheduleFrame();
5518
+ this.attachWindowKeyListeners();
5369
5519
  const isTextFocused = this.focusedA11yElement instanceof HTMLInputElement || this.focusedA11yElement instanceof HTMLTextAreaElement;
5370
5520
  if (isTextFocused && this.renderMode === "onDemand" && !this.caretBlinkTimer) {
5371
5521
  this.caretBlinkTimer = setInterval(() => {
@@ -5433,6 +5583,132 @@ var Scene = class _Scene {
5433
5583
  }
5434
5584
  this._canvasOnScreen = true;
5435
5585
  }
5586
+ // --- domain: input — scene-level keyboard channel ---
5587
+ /**
5588
+ * Register a listener on the scene-level keyboard channel.
5589
+ *
5590
+ * Mirrors {@link Entity.on} minus the options parameter (bubble-only, and
5591
+ * there is no capture phase for a single window tap). Handlers fire only
5592
+ * when ALL of these hold:
5593
+ *
5594
+ * 1. the native event was not already `defaultPrevented`,
5595
+ * 2. it is not an auto-repeat (`e.repeat`),
5596
+ * 3. `document.activeElement` does not own the keyboard
5597
+ * (see {@link ownsKeyboard}) — typing in an `<input>` or focusing a
5598
+ * slider-role element suppresses the scene channel, unconditionally,
5599
+ * including modifier chords.
5600
+ *
5601
+ * The listeners are window-level BUBBLE-phase taps, so an entity handler
5602
+ * that calls `nativeEvent.stopPropagation()` keeps the key from ever
5603
+ * reaching this channel. Deliberately not registered in the capture phase:
5604
+ * a window-capture listener would preempt Modal's document-capture trap.
5605
+ *
5606
+ * Listeners attach at `start()` and survive `stop()`; only `destroy()`
5607
+ * removes them.
5608
+ *
5609
+ * @param event - `'keydown'` or `'keyup'`; anything else is rejected with a
5610
+ * dev warning (guards JS callers — TypeScript already narrows this).
5611
+ * @param callback - Handler invoked with a {@link SceneKeyEvent}.
5612
+ * @returns `this` for method chaining.
5613
+ */
5614
+ on(event, callback) {
5615
+ if (event !== "keydown" && event !== "keyup") {
5616
+ console.warn(
5617
+ `[VectoJS] Scene.on: unsupported event '${String(event)}'. Only 'keydown' and 'keyup' are supported.`
5618
+ );
5619
+ return this;
5620
+ }
5621
+ (event === "keydown" ? this.keydownHandlers : this.keyupHandlers).push(callback);
5622
+ return this;
5623
+ }
5624
+ /**
5625
+ * Remove a listener previously registered with {@link Scene.on}. Silent
5626
+ * no-op for unknown handlers, unsupported event names, or after
5627
+ * {@link Scene.destroy}.
5628
+ *
5629
+ * @returns `this` for method chaining.
5630
+ */
5631
+ off(event, callback) {
5632
+ if (event !== "keydown" && event !== "keyup") {
5633
+ console.warn(
5634
+ `[VectoJS] Scene.off: unsupported event '${String(event)}'. Only 'keydown' and 'keyup' are supported.`
5635
+ );
5636
+ return this;
5637
+ }
5638
+ const handlers = event === "keydown" ? this.keydownHandlers : this.keyupHandlers;
5639
+ const idx = handlers.indexOf(callback);
5640
+ if (idx !== -1) handlers.splice(idx, 1);
5641
+ return this;
5642
+ }
5643
+ /**
5644
+ * Sugar over the keyboard channel: invoke `spec.handler` whenever a
5645
+ * non-repeat `keydown` whose normalized chord matches `spec.chord`
5646
+ * (via {@link normalizeChord}) passes the same gates as {@link Scene.on} —
5647
+ * including the unconditional `ownsKeyboard(activeElement)` suppression.
5648
+ *
5649
+ * @param spec - `{ chord, handler }`; the chord is normalized once at
5650
+ * registration, so `'ctrl+n'`, `'Control+n'` and `'Ctrl+N'` are aliases.
5651
+ * @returns `this` for method chaining.
5652
+ */
5653
+ registerShortcut(spec) {
5654
+ this.shortcuts.push({ chord: normalizeChord(spec.chord), handler: spec.handler });
5655
+ return this;
5656
+ }
5657
+ /**
5658
+ * Remove a shortcut previously registered with {@link Scene.registerShortcut}.
5659
+ * Matches on normalized chord + exact handler reference; silent no-op when
5660
+ * nothing matches.
5661
+ *
5662
+ * @param spec - The same spec object (chord spelling may differ; it is
5663
+ * re-normalized before matching).
5664
+ * @returns `this` for method chaining.
5665
+ */
5666
+ unregisterShortcut(spec) {
5667
+ const chord = normalizeChord(spec.chord);
5668
+ const idx = this.shortcuts.findIndex((s) => s.chord === chord && s.handler === spec.handler);
5669
+ if (idx !== -1) this.shortcuts.splice(idx, 1);
5670
+ return this;
5671
+ }
5672
+ /** Attach the window bubble-phase keyboard listeners exactly once. */
5673
+ attachWindowKeyListeners() {
5674
+ if (this.windowKeyDownHandler || typeof window === "undefined") return;
5675
+ this.windowKeyDownHandler = (e) => this.dispatchKeyboard(e, "keydown");
5676
+ this.windowKeyUpHandler = (e) => this.dispatchKeyboard(e, "keyup");
5677
+ window.addEventListener("keydown", this.windowKeyDownHandler);
5678
+ window.addEventListener("keyup", this.windowKeyUpHandler);
5679
+ }
5680
+ /**
5681
+ * Gate a native keyboard event and fan it out to scene handlers +
5682
+ * chord-matched shortcuts. See {@link Scene.on} for the gate contract.
5683
+ */
5684
+ dispatchKeyboard(e, type) {
5685
+ if (e.defaultPrevented) return;
5686
+ if (e.repeat) return;
5687
+ const active = typeof document !== "undefined" ? document.activeElement : null;
5688
+ if (ownsKeyboard(active)) return;
5689
+ const event = {
5690
+ type,
5691
+ key: e.key,
5692
+ code: e.code,
5693
+ repeat: e.repeat,
5694
+ ctrlKey: e.ctrlKey,
5695
+ altKey: e.altKey,
5696
+ shiftKey: e.shiftKey,
5697
+ metaKey: e.metaKey,
5698
+ target: e.target,
5699
+ nativeEvent: e,
5700
+ stopPropagation: () => e.stopPropagation(),
5701
+ preventDefault: () => e.preventDefault()
5702
+ };
5703
+ const handlers = type === "keydown" ? this.keydownHandlers : this.keyupHandlers;
5704
+ for (const handler of [...handlers]) handler(event);
5705
+ if (type === "keydown") {
5706
+ const chord = normalizeChord(e);
5707
+ for (const shortcut of [...this.shortcuts]) {
5708
+ if (shortcut.chord === chord) shortcut.handler(event);
5709
+ }
5710
+ }
5711
+ }
5436
5712
  // --- domain: scene-facade — tree accessors ---
5437
5713
  /**
5438
5714
  * Manually advance the scene clock by `dt` milliseconds and render synchronously.
@@ -5772,7 +6048,9 @@ var Scene = class _Scene {
5772
6048
  }
5773
6049
  };
5774
6050
  el.addEventListener("pointerdown", (e) => {
5775
- if (typeof capEl.setPointerCapture === "function") capEl.setPointerCapture(e.pointerId);
6051
+ if (e.target === capEl && typeof capEl.setPointerCapture === "function") {
6052
+ capEl.setPointerCapture(e.pointerId);
6053
+ }
5776
6054
  node.dispatchEvent(new VectoJSEvent("pointerdown", node, e));
5777
6055
  });
5778
6056
  el.addEventListener("pointerup", (e) => {
@@ -5880,14 +6158,6 @@ var Scene = class _Scene {
5880
6158
  }
5881
6159
  node.emit("blur", {});
5882
6160
  });
5883
- if (!isNativelyFocusable(el) && attrs.role && INTERACTIVE_A11Y_ROLES.has(attrs.role)) {
5884
- el.addEventListener("keydown", (e) => {
5885
- if (e.key === "Enter" || e.key === " ") {
5886
- e.preventDefault();
5887
- node.dispatchEvent(new VectoJSEvent("click", node, e));
5888
- }
5889
- });
5890
- }
5891
6161
  if (node.a11yFullViewport) {
5892
6162
  this.a11yRoot.insertBefore(el, this.a11yRoot.firstChild);
5893
6163
  } else {
@@ -5906,7 +6176,24 @@ var Scene = class _Scene {
5906
6176
  if (renderOrder !== void 0 && el.style.zIndex !== String(renderOrder)) {
5907
6177
  el.style.zIndex = String(renderOrder);
5908
6178
  }
5909
- const implicitTabIndex = !isNativelyFocusable(el) && attrs.role && INTERACTIVE_A11Y_ROLES.has(attrs.role) ? 0 : null;
6179
+ const interactiveRole = !isNativelyFocusable(el) && !!attrs.role && INTERACTIVE_A11Y_ROLES.has(attrs.role);
6180
+ if (interactiveRole && !this.keyboardActivatedA11y.has(el)) {
6181
+ el.addEventListener("keydown", (e) => {
6182
+ if (e.key === "Enter") {
6183
+ e.preventDefault();
6184
+ node.dispatchEvent(new VectoJSEvent("click", node, e));
6185
+ } else if (e.key === " ") {
6186
+ e.preventDefault();
6187
+ }
6188
+ });
6189
+ el.addEventListener("keyup", (e) => {
6190
+ if (e.key === " ") {
6191
+ node.dispatchEvent(new VectoJSEvent("click", node, e));
6192
+ }
6193
+ });
6194
+ this.keyboardActivatedA11y.add(el);
6195
+ }
6196
+ const implicitTabIndex = interactiveRole ? 0 : null;
5910
6197
  const desiredTabIndex = attrs.tabIndex ?? implicitTabIndex;
5911
6198
  if (desiredTabIndex === null) {
5912
6199
  if (el.hasAttribute("tabindex")) el.removeAttribute("tabindex");
@@ -6848,6 +7135,7 @@ var Scene = class _Scene {
6848
7135
  this.renderOrderCounter = 0;
6849
7136
  this.a11yRenderOrders.clear();
6850
7137
  this.activePortalsThisFrame.clear();
7138
+ this._updateWalkDt = dt;
6851
7139
  this._tickBatchedDrivers(dt);
6852
7140
  }
6853
7141
  const computeEntities = this._computeEntitiesFor(this._structureVersion);
@@ -7018,6 +7306,7 @@ var Scene = class _Scene {
7018
7306
  };
7019
7307
  updateWalk(this.root);
7020
7308
  for (const overlay of this.overlayRoot.children) updateWalk(overlay);
7309
+ this._updateWalkDt = null;
7021
7310
  }
7022
7311
  const transformTiming = this.phases.userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.transform) : null;
7023
7312
  const wasmT0 = this.phases.enabled ? performance.now() : 0;
@@ -7218,6 +7507,7 @@ var Scene = class _Scene {
7218
7507
  for (const overlay of this.overlayRoot.children) {
7219
7508
  renderNode(overlay, 1, 0, 0, 1, 0, 0, 1);
7220
7509
  }
7510
+ if (isMainRenderer) this._updateWalkDt = null;
7221
7511
  if (this.phases.enabled) this.phases.record("drawWalk", performance.now() - drawT0);
7222
7512
  if (drawTiming) endVectoUserTiming(drawTiming);
7223
7513
  if (this.phases.userTiming) {
@@ -8363,6 +8653,7 @@ export {
8363
8653
  GlyphRasterAtlas,
8364
8654
  GridTextEntity,
8365
8655
  Group,
8656
+ KEYBOARD_OWNING_ROLES,
8366
8657
  MSDFTextEntity,
8367
8658
  PARTICLE_OFFSET_LIFE,
8368
8659
  PARTICLE_OFFSET_ORIGIN_X,
@@ -8394,6 +8685,8 @@ export {
8394
8685
  isSafeUrl,
8395
8686
  loadSpline,
8396
8687
  measureVectoUserTiming,
8688
+ normalizeChord,
8689
+ ownsKeyboard,
8397
8690
  parseColorToRGBA,
8398
8691
  polySegmentToBezier,
8399
8692
  sanitizeUrl,