@vectojs/core 1.39.1 → 1.40.1
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/{chunk-7PYD5UDX.mjs → chunk-CIRZ3S2Z.mjs} +48 -3
- package/dist/{chunk-POPXUPKR.js → chunk-HC5EZOZJ.js} +80 -35
- package/dist/components/VirtualizedSetAggregate.d.ts +110 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1052 -206
- package/dist/index.mjs +851 -5
- package/dist/text.js +2 -2
- package/dist/text.mjs +1 -1
- package/dist/tree/Entity.d.ts +59 -1
- package/dist/tree/Scene.d.ts +196 -1
- package/dist/tree/scene/HitResult.d.ts +50 -0
- package/dist/tree/scene/HitTester.d.ts +30 -0
- package/dist/tree/scene/ProjectionBackend.d.ts +64 -0
- package/dist/tree/scene/ProjectionPolicy.d.ts +162 -0
- package/dist/tree/scene/SemanticProjectionPolicy.d.ts +119 -0
- package/dist/wasm/vectojs_core.wasm +0 -0
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
SVGEntity,
|
|
19
19
|
VectoJSEvent,
|
|
20
20
|
contentLineInHint
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-CIRZ3S2Z.mjs";
|
|
22
22
|
|
|
23
23
|
// src/tree/ComputeParticleEntity.ts
|
|
24
24
|
var PARTICLE_STRIDE_FLOATS = 8;
|
|
@@ -1598,6 +1598,62 @@ var HitTester = class {
|
|
|
1598
1598
|
findEntityAt(x, y, frame, width, height) {
|
|
1599
1599
|
const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
|
|
1600
1600
|
if (overlayHit) return overlayHit;
|
|
1601
|
+
return this.findMainTreeHit(x, y, frame, width, height);
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* The merged hit list (RFC5 §2, CTX-0600): the canvas spatial test plus
|
|
1605
|
+
* caller-observed DOM-native candidates (mirror / portal / `dom-visual`
|
|
1606
|
+
* extension point for CTX-0598) as ONE ordered candidate list.
|
|
1607
|
+
*
|
|
1608
|
+
* Query API only — no dispatch change: `findEntityAt` keeps its single
|
|
1609
|
+
* topmost answer byte-for-byte. Coordinates enter in scene space (callers
|
|
1610
|
+
* map client coordinates first, e.g. `Scene.findHitsAtClient`); backend
|
|
1611
|
+
* attribution follows. DOM candidates pass through the same
|
|
1612
|
+
* `disabled` / `pointerEvents: 'none'` predicate as the canvas paths
|
|
1613
|
+
* (RFC5 §2 rule 4); the browser's hit test is their geometric authority,
|
|
1614
|
+
* so scene-space `isPointInside` is not re-checked.
|
|
1615
|
+
*
|
|
1616
|
+
* Overlay order is authoritative (RFC5 §2 rule 2): overlay-subtree
|
|
1617
|
+
* candidates sort above main-tree candidates, DOM-native backends above
|
|
1618
|
+
* canvas within one subtree. Sorted descending by `priority`; ties keep
|
|
1619
|
+
* insertion order (canvas overlay, canvas main, then DOM in caller order).
|
|
1620
|
+
*/
|
|
1621
|
+
findHitsAt(x, y, frame, width, height, domCandidates = []) {
|
|
1622
|
+
const results = [];
|
|
1623
|
+
const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
|
|
1624
|
+
if (overlayHit) results.push(this.toHitResult(overlayHit, "canvas", x, y));
|
|
1625
|
+
const mainHit = this.findMainTreeHit(x, y, frame, width, height);
|
|
1626
|
+
if (mainHit) results.push(this.toHitResult(mainHit, "canvas", x, y));
|
|
1627
|
+
for (const candidate of domCandidates) {
|
|
1628
|
+
if (!this.isHitEligible(candidate.node, x, y)) continue;
|
|
1629
|
+
results.push(this.toHitResult(candidate.node, candidate.backend, x, y));
|
|
1630
|
+
}
|
|
1631
|
+
results.sort((a, b) => b.priority - a.priority);
|
|
1632
|
+
return results;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Attribute one candidate: scene-space point plus node-local point, with
|
|
1636
|
+
* the compositor-layer priority (overlay subtree above main tree,
|
|
1637
|
+
* DOM-native backends above canvas within one subtree).
|
|
1638
|
+
*/
|
|
1639
|
+
toHitResult(node, backend, x, y) {
|
|
1640
|
+
return {
|
|
1641
|
+
node,
|
|
1642
|
+
backend,
|
|
1643
|
+
localPoint: node.worldToLocal(x, y),
|
|
1644
|
+
worldPoint: { x, y },
|
|
1645
|
+
priority: (this.isInOverlaySubtree(node) ? 2 : 0) + (backend === "canvas" ? 0 : 1)
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/** Whether `node` lives under the overlay root (drawn above the main tree). */
|
|
1649
|
+
isInOverlaySubtree(node) {
|
|
1650
|
+
for (let current = node; current; current = current.parent) {
|
|
1651
|
+
if (current === this.overlayRoot) return true;
|
|
1652
|
+
}
|
|
1653
|
+
return false;
|
|
1654
|
+
}
|
|
1655
|
+
/** Main-tree arm of {@link findEntityAt}, shared with {@link findHitsAt}. */
|
|
1656
|
+
findMainTreeHit(x, y, frame, width, height) {
|
|
1601
1657
|
if (this.backends.hit && this.ensureHitGrid(frame, width, height)) {
|
|
1602
1658
|
return this.findEntityAtWasm(x, y);
|
|
1603
1659
|
}
|
|
@@ -3501,6 +3557,207 @@ function normalizeChord(input) {
|
|
|
3501
3557
|
return ordered.join("+");
|
|
3502
3558
|
}
|
|
3503
3559
|
|
|
3560
|
+
// src/tree/scene/SemanticProjectionPolicy.ts
|
|
3561
|
+
var DEFAULT_SEMANTIC_PROJECTION_POLICY = {
|
|
3562
|
+
choose: () => "project"
|
|
3563
|
+
};
|
|
3564
|
+
function getSemanticProjectionCapabilities() {
|
|
3565
|
+
return {
|
|
3566
|
+
htmlInCanvas: supportsHTMLInCanvas(),
|
|
3567
|
+
canvasTextRecovery: false
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
var CONTROL_SEMANTIC_ROLES = /* @__PURE__ */ new Set([
|
|
3571
|
+
"button",
|
|
3572
|
+
"link",
|
|
3573
|
+
"checkbox",
|
|
3574
|
+
"radio",
|
|
3575
|
+
"switch",
|
|
3576
|
+
"slider",
|
|
3577
|
+
"spinbutton",
|
|
3578
|
+
"combobox",
|
|
3579
|
+
"listbox",
|
|
3580
|
+
"option",
|
|
3581
|
+
"menuitem",
|
|
3582
|
+
"menuitemcheckbox",
|
|
3583
|
+
"menuitemradio",
|
|
3584
|
+
"tab",
|
|
3585
|
+
"textbox",
|
|
3586
|
+
"searchbox",
|
|
3587
|
+
"progressbar",
|
|
3588
|
+
"scrollbar",
|
|
3589
|
+
"meter"
|
|
3590
|
+
]);
|
|
3591
|
+
function isDeferrableSemanticNode(node) {
|
|
3592
|
+
const attrs = node.getA11yAttributes();
|
|
3593
|
+
if (attrs.tag !== void 0 && attrs.tag !== "div") return false;
|
|
3594
|
+
if (attrs.role !== void 0 && CONTROL_SEMANTIC_ROLES.has(attrs.role)) return false;
|
|
3595
|
+
if (typeof attrs.tabIndex === "number" && attrs.tabIndex >= 0) return false;
|
|
3596
|
+
const projection = node.getContentProjection?.();
|
|
3597
|
+
if (projection?.selectable) return false;
|
|
3598
|
+
return true;
|
|
3599
|
+
}
|
|
3600
|
+
function describeHTMLInCanvasSupport() {
|
|
3601
|
+
if (typeof document === "undefined") return { supported: false, reason: "no-dom" };
|
|
3602
|
+
try {
|
|
3603
|
+
const canvas = document.createElement("canvas");
|
|
3604
|
+
const ctx = canvas.getContext("2d");
|
|
3605
|
+
const native2D = typeof CanvasRenderingContext2D !== "undefined" && ctx instanceof CanvasRenderingContext2D;
|
|
3606
|
+
if (native2D && typeof ctx["drawElementImage"] === "function") {
|
|
3607
|
+
return { supported: true };
|
|
3608
|
+
}
|
|
3609
|
+
} catch {
|
|
3610
|
+
return { supported: false, reason: "no-backend" };
|
|
3611
|
+
}
|
|
3612
|
+
return { supported: false, reason: "no-backend" };
|
|
3613
|
+
}
|
|
3614
|
+
function supportsHTMLInCanvas() {
|
|
3615
|
+
return describeHTMLInCanvasSupport().supported;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
// src/tree/scene/ProjectionPolicy.ts
|
|
3619
|
+
var DEFAULT_PROJECTION_CAPABILITIES = {
|
|
3620
|
+
/** Static text (short): findability already covered by content projection. */
|
|
3621
|
+
text: {
|
|
3622
|
+
domSupported: true,
|
|
3623
|
+
nativeValue: "medium",
|
|
3624
|
+
domCost: "low",
|
|
3625
|
+
canvasCost: "low",
|
|
3626
|
+
autoDefault: "canvas"
|
|
3627
|
+
},
|
|
3628
|
+
/** Long/selectable text, Markdown blocks: selection, Ctrl+F, translation. */
|
|
3629
|
+
prose: {
|
|
3630
|
+
domSupported: true,
|
|
3631
|
+
nativeValue: "high",
|
|
3632
|
+
domCost: "medium",
|
|
3633
|
+
canvasCost: "high",
|
|
3634
|
+
autoDefault: "dom"
|
|
3635
|
+
},
|
|
3636
|
+
/** Code blocks: selectable source, but canvas carriers already serve selection — rests canvas until measured (RFC4 §6 hybrid mapping). */
|
|
3637
|
+
code: {
|
|
3638
|
+
domSupported: true,
|
|
3639
|
+
nativeValue: "medium",
|
|
3640
|
+
domCost: "medium",
|
|
3641
|
+
canvasCost: "high",
|
|
3642
|
+
autoDefault: "canvas"
|
|
3643
|
+
},
|
|
3644
|
+
/** Text input / editable: IME, caret, clipboard, BiDi come free in DOM. */
|
|
3645
|
+
input: {
|
|
3646
|
+
domSupported: true,
|
|
3647
|
+
nativeValue: "high",
|
|
3648
|
+
domCost: "low",
|
|
3649
|
+
canvasCost: "prohibitive",
|
|
3650
|
+
autoDefault: "dom"
|
|
3651
|
+
},
|
|
3652
|
+
/** Button / Link: cheap either way, native wins on focus + AT clicks. */
|
|
3653
|
+
button: {
|
|
3654
|
+
domSupported: true,
|
|
3655
|
+
nativeValue: "medium",
|
|
3656
|
+
domCost: "low",
|
|
3657
|
+
canvasCost: "low",
|
|
3658
|
+
autoDefault: "dom"
|
|
3659
|
+
},
|
|
3660
|
+
link: {
|
|
3661
|
+
domSupported: true,
|
|
3662
|
+
nativeValue: "medium",
|
|
3663
|
+
domCost: "low",
|
|
3664
|
+
canvasCost: "low",
|
|
3665
|
+
autoDefault: "dom"
|
|
3666
|
+
},
|
|
3667
|
+
/** Select / Dropdown / Checkbox / Radio: popup, keyboard, form semantics. */
|
|
3668
|
+
select: {
|
|
3669
|
+
domSupported: true,
|
|
3670
|
+
nativeValue: "high",
|
|
3671
|
+
domCost: "low",
|
|
3672
|
+
canvasCost: "high",
|
|
3673
|
+
autoDefault: "dom"
|
|
3674
|
+
},
|
|
3675
|
+
/** Container / layout group: no direct representation, children negotiate. */
|
|
3676
|
+
container: {
|
|
3677
|
+
domSupported: true,
|
|
3678
|
+
nativeValue: "none",
|
|
3679
|
+
domCost: "n/a",
|
|
3680
|
+
canvasCost: "low",
|
|
3681
|
+
autoDefault: "canvas"
|
|
3682
|
+
},
|
|
3683
|
+
/** Transform / camera rig: owns space, not pixels — never materialized. */
|
|
3684
|
+
transform: {
|
|
3685
|
+
domSupported: true,
|
|
3686
|
+
nativeValue: "none",
|
|
3687
|
+
domCost: "n/a",
|
|
3688
|
+
canvasCost: "n/a",
|
|
3689
|
+
autoDefault: "canvas"
|
|
3690
|
+
},
|
|
3691
|
+
/** Image / video frame: blit is cheap, one element per item is not. */
|
|
3692
|
+
image: {
|
|
3693
|
+
domSupported: true,
|
|
3694
|
+
nativeValue: "low",
|
|
3695
|
+
domCost: "medium",
|
|
3696
|
+
canvasCost: "low",
|
|
3697
|
+
autoDefault: "canvas"
|
|
3698
|
+
},
|
|
3699
|
+
/** Particles / danmaku / chart glyphs: bulk stays canvas, unconditionally. */
|
|
3700
|
+
particles: {
|
|
3701
|
+
domSupported: true,
|
|
3702
|
+
nativeValue: "none",
|
|
3703
|
+
domCost: "prohibitive",
|
|
3704
|
+
canvasCost: "low",
|
|
3705
|
+
autoDefault: "canvas"
|
|
3706
|
+
},
|
|
3707
|
+
/** Graph nodes/edges (interactive, moderate count): canvas until measured. */
|
|
3708
|
+
graph: {
|
|
3709
|
+
domSupported: true,
|
|
3710
|
+
nativeValue: "low",
|
|
3711
|
+
domCost: "medium",
|
|
3712
|
+
canvasCost: "medium",
|
|
3713
|
+
autoDefault: "canvas"
|
|
3714
|
+
},
|
|
3715
|
+
/** Custom shader / GPU particles: no DOM representation exists. */
|
|
3716
|
+
shader: {
|
|
3717
|
+
domSupported: false,
|
|
3718
|
+
nativeValue: "none",
|
|
3719
|
+
domCost: "unsupported",
|
|
3720
|
+
canvasCost: "low",
|
|
3721
|
+
autoDefault: "canvas"
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
var UNKNOWN_PROJECTION_CAPABILITY = {
|
|
3725
|
+
domSupported: false,
|
|
3726
|
+
nativeValue: "none",
|
|
3727
|
+
domCost: "unsupported",
|
|
3728
|
+
canvasCost: "low",
|
|
3729
|
+
autoDefault: "canvas"
|
|
3730
|
+
};
|
|
3731
|
+
var PROJECTION_AUTO_HYSTERESIS_FRAMES = 3;
|
|
3732
|
+
var PROJECTION_AUTO_DOM_BUDGET = 500;
|
|
3733
|
+
function resolveProjection(want, cap, ctx) {
|
|
3734
|
+
if (want === "canvas") return { resolved: "canvas", reason: null };
|
|
3735
|
+
if (!ctx.hasDOM || !ctx.domBackendMounted) {
|
|
3736
|
+
return { resolved: "canvas", reason: "no-dom-backend" };
|
|
3737
|
+
}
|
|
3738
|
+
if (!cap.domSupported) return { resolved: "canvas", reason: "unsupported-kind" };
|
|
3739
|
+
if (want === "dom") {
|
|
3740
|
+
if (cap.domCost === "prohibitive") return { resolved: "canvas", reason: "prohibitive-cost" };
|
|
3741
|
+
return { resolved: "dom", reason: null };
|
|
3742
|
+
}
|
|
3743
|
+
if (cap.nativeValue === "high" && cap.domCost !== "prohibitive") {
|
|
3744
|
+
return { resolved: "dom", reason: null };
|
|
3745
|
+
}
|
|
3746
|
+
if (cap.canvasCost === "prohibitive" && cap.domSupported) {
|
|
3747
|
+
return { resolved: "dom", reason: null };
|
|
3748
|
+
}
|
|
3749
|
+
if (cap.autoDefault === "dom") return { resolved: "dom", reason: null };
|
|
3750
|
+
return { resolved: "canvas", reason: null };
|
|
3751
|
+
}
|
|
3752
|
+
function hysteresisVote(current, desired, consecutive, hysteresisFrames) {
|
|
3753
|
+
if (current === void 0 || current === desired) {
|
|
3754
|
+
return { resolved: desired, consecutive: 0, flipped: false };
|
|
3755
|
+
}
|
|
3756
|
+
const next = consecutive + 1;
|
|
3757
|
+
if (next >= hysteresisFrames) return { resolved: desired, consecutive: 0, flipped: true };
|
|
3758
|
+
return { resolved: current, consecutive: next, flipped: false };
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3504
3761
|
// src/tree/Scene.ts
|
|
3505
3762
|
var RANGE_VALUE_ROLES = /* @__PURE__ */ new Set(["slider", "spinbutton", "progressbar", "scrollbar", "meter"]);
|
|
3506
3763
|
var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
|
|
@@ -3524,7 +3781,8 @@ var KEYBOARD_OWNING_ROLES = /* @__PURE__ */ new Set([
|
|
|
3524
3781
|
]);
|
|
3525
3782
|
function ownsKeyboard(el) {
|
|
3526
3783
|
if (!el) return false;
|
|
3527
|
-
if (el === document.body || el === document.documentElement)
|
|
3784
|
+
if (typeof document !== "undefined" && (el === document.body || el === document.documentElement))
|
|
3785
|
+
return false;
|
|
3528
3786
|
if (el.hasAttribute("data-vecto-a11y-root")) return false;
|
|
3529
3787
|
const tag = el.tagName;
|
|
3530
3788
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
@@ -3560,10 +3818,13 @@ var SCENE_OPTION_KEYS = [
|
|
|
3560
3818
|
"maxFPS",
|
|
3561
3819
|
"particleBackend",
|
|
3562
3820
|
"pointBackend",
|
|
3821
|
+
"projectionAutoDomBudget",
|
|
3822
|
+
"projectionHysteresisFrames",
|
|
3563
3823
|
"readingDirection",
|
|
3564
3824
|
"renderer",
|
|
3565
3825
|
"renderMode",
|
|
3566
3826
|
"respectReducedMotion",
|
|
3827
|
+
"semanticProjectionPolicy",
|
|
3567
3828
|
"userTiming"
|
|
3568
3829
|
];
|
|
3569
3830
|
var SCENE_FIELD_NOT_OPTION = {
|
|
@@ -4061,6 +4322,26 @@ var Scene = class _Scene {
|
|
|
4061
4322
|
* frame. See {@link SceneOptions.a11ySyncInterval}.
|
|
4062
4323
|
*/
|
|
4063
4324
|
a11ySyncInterval = 0;
|
|
4325
|
+
/**
|
|
4326
|
+
* Per-node semantic projection policy (RFC3 §5, CTX-0599). Consulted once
|
|
4327
|
+
* per node by {@link shouldProjectA11y}; the default projects everything
|
|
4328
|
+
* the legacy predicate projects, so reassigning nothing changes nothing.
|
|
4329
|
+
* See {@link SceneOptions.semanticProjectionPolicy}.
|
|
4330
|
+
*/
|
|
4331
|
+
semanticProjectionPolicy = DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
4332
|
+
/**
|
|
4333
|
+
* Consecutive syncs an `'auto'`-policy node must keep voting for the other
|
|
4334
|
+
* backend before its sticky resolution flips (RFC4 §3 rule 3, CTX-0601).
|
|
4335
|
+
* Documented tunable — see {@link PROJECTION_AUTO_HYSTERESIS_FRAMES}.
|
|
4336
|
+
*/
|
|
4337
|
+
projectionHysteresisFrames = PROJECTION_AUTO_HYSTERESIS_FRAMES;
|
|
4338
|
+
/**
|
|
4339
|
+
* Maximum `'auto'`-resolved DOM residents per scene per frame (RFC4 §4
|
|
4340
|
+
* particle-row backstop: bulk-count nodes stay canvas). Explicit `'dom'`
|
|
4341
|
+
* requests bypass it. Documented tunable — see
|
|
4342
|
+
* {@link PROJECTION_AUTO_DOM_BUDGET}.
|
|
4343
|
+
*/
|
|
4344
|
+
projectionAutoDomBudget = PROJECTION_AUTO_DOM_BUDGET;
|
|
4064
4345
|
/** Timestamp of the last a11y sync, for throttling. */
|
|
4065
4346
|
lastA11ySync = -Infinity;
|
|
4066
4347
|
/** True if we skipped an a11y sync during animation and need to sync when at rest. */
|
|
@@ -4291,6 +4572,30 @@ var Scene = class _Scene {
|
|
|
4291
4572
|
activePortalsThisFrame = /* @__PURE__ */ new Set();
|
|
4292
4573
|
activePortalsPrevFrame = /* @__PURE__ */ new Set();
|
|
4293
4574
|
portalEntities = /* @__PURE__ */ new Map();
|
|
4575
|
+
/**
|
|
4576
|
+
* Generic projection backends (RFC1 §5 vocabulary, `tree/scene/ProjectionBackend.ts`).
|
|
4577
|
+
* Render-independent: the interface exchanges only node identity, world
|
|
4578
|
+
* matrix, and lifecycle calls, so registering a DOM backend adds no
|
|
4579
|
+
* `HTMLElement` surface to core. `@vectojs/dom` owns the only implementation.
|
|
4580
|
+
*/
|
|
4581
|
+
projectionBackends = [];
|
|
4582
|
+
/** Per-frame seen-sets driving {@link pruneProjectionBackends} (portal-precedent). */
|
|
4583
|
+
domSeenPrevFrame = /* @__PURE__ */ new Set();
|
|
4584
|
+
domSeenThisFrame = /* @__PURE__ */ new Set();
|
|
4585
|
+
domSeenNodes = /* @__PURE__ */ new Map();
|
|
4586
|
+
/**
|
|
4587
|
+
* Negotiation state (RFC4 §3, CTX-0601): last resolution per node (the
|
|
4588
|
+
* per-scene queryable surface behind {@link getProjectionResolutions}),
|
|
4589
|
+
* hysteresis votes, per-kind capability overrides, and explicit gesture
|
|
4590
|
+
* pins. Plain data only — no `HTMLElement` surface in core.
|
|
4591
|
+
*/
|
|
4592
|
+
projectionResolutions = /* @__PURE__ */ new Map();
|
|
4593
|
+
projectionHysteresis = /* @__PURE__ */ new Map();
|
|
4594
|
+
projectionCapabilityOverrides = /* @__PURE__ */ new Map();
|
|
4595
|
+
projectionGesturePins = /* @__PURE__ */ new Map();
|
|
4596
|
+
/** Frame id the `'auto'` DOM budget count belongs to (bulk backstop). */
|
|
4597
|
+
projectionBudgetFrame = -1;
|
|
4598
|
+
projectionDomAutoCount = 0;
|
|
4294
4599
|
renderOrderCounter = 0;
|
|
4295
4600
|
// --- domain: render-scheduler — authoritative frame counter ---
|
|
4296
4601
|
/**
|
|
@@ -4954,6 +5259,9 @@ var Scene = class _Scene {
|
|
|
4954
5259
|
this.phases.userTiming = options.userTiming ?? false;
|
|
4955
5260
|
this.particleBackend = options.particleBackend ?? "auto";
|
|
4956
5261
|
this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
|
|
5262
|
+
this.semanticProjectionPolicy = options.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
5263
|
+
this.projectionHysteresisFrames = options.projectionHysteresisFrames ?? PROJECTION_AUTO_HYSTERESIS_FRAMES;
|
|
5264
|
+
this.projectionAutoDomBudget = options.projectionAutoDomBudget ?? PROJECTION_AUTO_DOM_BUDGET;
|
|
4957
5265
|
this.contentProjectionEnabled = options.contentProjection ?? true;
|
|
4958
5266
|
this.contentProjectionMargin = options.contentProjectionMargin;
|
|
4959
5267
|
this.contentSemanticMargin = options.contentSemanticMargin;
|
|
@@ -5305,6 +5613,37 @@ var Scene = class _Scene {
|
|
|
5305
5613
|
findEntityAt(x, y) {
|
|
5306
5614
|
return this._hitTester.findEntityAt(x, y, this.currentFrame, this.width, this.height);
|
|
5307
5615
|
}
|
|
5616
|
+
// --- domain: hit-test — merged HitResult query (RFC5 §2, CTX-0600) ---
|
|
5617
|
+
/**
|
|
5618
|
+
* The merged hit list for a scene-space point: the canvas spatial test
|
|
5619
|
+
* plus caller-observed DOM-native candidates (mirror / portal /
|
|
5620
|
+
* `dom-visual` extension point for CTX-0598) as ONE ordered candidate
|
|
5621
|
+
* list (`HitResult`, overlay order authoritative).
|
|
5622
|
+
*
|
|
5623
|
+
* Query API alongside {@link findEntityAt} — no dispatch change:
|
|
5624
|
+
* `findEntityAt` keeps its single topmost answer byte-for-byte, keyboard
|
|
5625
|
+
* and AT flows are untouched. For browser pointer coordinates use
|
|
5626
|
+
* {@link findHitsAtClient}, which maps through {@link clientToScene} first.
|
|
5627
|
+
*/
|
|
5628
|
+
findHitsAt(x, y, domCandidates = []) {
|
|
5629
|
+
return this._hitTester.findHitsAt(
|
|
5630
|
+
x,
|
|
5631
|
+
y,
|
|
5632
|
+
this.currentFrame,
|
|
5633
|
+
this.width,
|
|
5634
|
+
this.height,
|
|
5635
|
+
domCandidates
|
|
5636
|
+
);
|
|
5637
|
+
}
|
|
5638
|
+
/**
|
|
5639
|
+
* {@link findHitsAt} for browser viewport coordinates: maps through
|
|
5640
|
+
* {@link clientToScene} so coordinates enter in scene space (RFC5 §2
|
|
5641
|
+
* rule 3) and backend attribution follows.
|
|
5642
|
+
*/
|
|
5643
|
+
findHitsAtClient(clientX, clientY, domCandidates = []) {
|
|
5644
|
+
const point = this.clientToScene(clientX, clientY);
|
|
5645
|
+
return this.findHitsAt(point.x, point.y, domCandidates);
|
|
5646
|
+
}
|
|
5308
5647
|
// --- domain: hit-test — client-to-scene mapping ---
|
|
5309
5648
|
/** Convert browser viewport coordinates into this Scene's logical coordinates. */
|
|
5310
5649
|
clientToScene(clientX, clientY) {
|
|
@@ -5345,6 +5684,15 @@ var Scene = class _Scene {
|
|
|
5345
5684
|
this.activePortalsThisFrame.delete(node.id);
|
|
5346
5685
|
this.activePortalsPrevFrame.delete(node.id);
|
|
5347
5686
|
}
|
|
5687
|
+
if (node.domPolicy === "dom" || node.domResident) {
|
|
5688
|
+
for (const backend of this.projectionBackends) backend.unmount(node);
|
|
5689
|
+
this.domSeenThisFrame.delete(node.id);
|
|
5690
|
+
this.domSeenPrevFrame.delete(node.id);
|
|
5691
|
+
this.domSeenNodes.delete(node.id);
|
|
5692
|
+
}
|
|
5693
|
+
this.projectionResolutions.delete(node.id);
|
|
5694
|
+
this.projectionHysteresis.delete(node.id);
|
|
5695
|
+
this.projectionGesturePins.delete(node.id);
|
|
5348
5696
|
const contentEl = this.contentElements.get(node.id);
|
|
5349
5697
|
if (contentEl) {
|
|
5350
5698
|
this._contentProjection.clearGridState(node.id, contentEl);
|
|
@@ -5456,6 +5804,15 @@ var Scene = class _Scene {
|
|
|
5456
5804
|
while (this.overlayRoot.children.length > 0) {
|
|
5457
5805
|
this.destroyEntitySubtree(this.overlayRoot.children.at(-1));
|
|
5458
5806
|
}
|
|
5807
|
+
for (const node of this.domSeenNodes.values()) {
|
|
5808
|
+
for (const backend of this.projectionBackends) backend.unmount(node);
|
|
5809
|
+
}
|
|
5810
|
+
this.domSeenNodes.clear();
|
|
5811
|
+
this.domSeenThisFrame.clear();
|
|
5812
|
+
this.domSeenPrevFrame.clear();
|
|
5813
|
+
this.projectionResolutions.clear();
|
|
5814
|
+
this.projectionHysteresis.clear();
|
|
5815
|
+
this.projectionGesturePins.clear();
|
|
5459
5816
|
if (typeof window !== "undefined" && !this.disableWindowResize) {
|
|
5460
5817
|
window.removeEventListener("resize", this.resizeHandler);
|
|
5461
5818
|
}
|
|
@@ -5954,15 +6311,48 @@ var Scene = class _Scene {
|
|
|
5954
6311
|
shouldProjectA11y(node) {
|
|
5955
6312
|
if (!node.interactive) return false;
|
|
5956
6313
|
if (!(node.width > 0 || node.a11yFullViewport)) return false;
|
|
6314
|
+
if (this.resolveProjectionFor(node) === "dom") return false;
|
|
5957
6315
|
switch (node.a11yProjection) {
|
|
5958
6316
|
case "never":
|
|
5959
6317
|
return false;
|
|
5960
6318
|
case "onDemand":
|
|
5961
|
-
return this.a11yEngaged(node);
|
|
6319
|
+
return this.a11yEngaged(node) && this.resolveSemanticProjection(node);
|
|
5962
6320
|
default:
|
|
5963
|
-
return
|
|
6321
|
+
return this.resolveSemanticProjection(node);
|
|
5964
6322
|
}
|
|
5965
6323
|
}
|
|
6324
|
+
/**
|
|
6325
|
+
* Policy half of {@link shouldProjectA11y} (RFC3 §5, CTX-0599).
|
|
6326
|
+
*
|
|
6327
|
+
* The legacy gates above (interactive, box, `a11yProjection` engagement)
|
|
6328
|
+
* are unchanged; this only maps a {@link SemanticProjectionPolicy} decision
|
|
6329
|
+
* onto project/suppress. Out of the box the default policy returns
|
|
6330
|
+
* `'project'`, so behaviour is identical to having no policy.
|
|
6331
|
+
*
|
|
6332
|
+
* `'defer-to-browser'` always falls back to projection today: no deferral
|
|
6333
|
+
* backend exists (`supportsHTMLInCanvas()` is `false`), so deferring would
|
|
6334
|
+
* silently drop semantics — the exact failure RFC §3 rules out. When a
|
|
6335
|
+
* backend lands, only allow-listed plain display text
|
|
6336
|
+
* ({@link isDeferrableSemanticNode}; never controls) may actually defer. A
|
|
6337
|
+
* throwing policy likewise falls back to projection: a policy must never be
|
|
6338
|
+
* able to drop semantics by accident.
|
|
6339
|
+
*/
|
|
6340
|
+
resolveSemanticProjection(node) {
|
|
6341
|
+
const policy = this.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
6342
|
+
let decision;
|
|
6343
|
+
try {
|
|
6344
|
+
decision = policy.choose(node, getSemanticProjectionCapabilities(), {
|
|
6345
|
+
hasDOM: this.a11yRoot !== null
|
|
6346
|
+
});
|
|
6347
|
+
} catch {
|
|
6348
|
+
decision = "project";
|
|
6349
|
+
}
|
|
6350
|
+
if (decision === "never") return false;
|
|
6351
|
+
if (decision === "defer-to-browser" && isDeferrableSemanticNode(node) && supportsHTMLInCanvas()) {
|
|
6352
|
+
return false;
|
|
6353
|
+
}
|
|
6354
|
+
return true;
|
|
6355
|
+
}
|
|
5966
6356
|
/**
|
|
5967
6357
|
* Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
|
|
5968
6358
|
* deserve a shadow node.
|
|
@@ -6131,14 +6521,17 @@ var Scene = class _Scene {
|
|
|
6131
6521
|
if (e.target === capEl && typeof capEl.setPointerCapture === "function") {
|
|
6132
6522
|
capEl.setPointerCapture(e.pointerId);
|
|
6133
6523
|
}
|
|
6524
|
+
this.pinProjectionForGesture(node);
|
|
6134
6525
|
node.dispatchEvent(new VectoJSEvent("pointerdown", node, e));
|
|
6135
6526
|
});
|
|
6136
6527
|
el.addEventListener("pointerup", (e) => {
|
|
6137
6528
|
releasePointer(e);
|
|
6529
|
+
this.unpinProjectionForGesture(node);
|
|
6138
6530
|
node.dispatchEvent(new VectoJSEvent("pointerup", node, e));
|
|
6139
6531
|
});
|
|
6140
6532
|
el.addEventListener("pointercancel", (e) => {
|
|
6141
6533
|
releasePointer(e);
|
|
6534
|
+
this.unpinProjectionForGesture(node);
|
|
6142
6535
|
node.dispatchEvent(new VectoJSEvent("pointercancel", node, e));
|
|
6143
6536
|
});
|
|
6144
6537
|
el.addEventListener(
|
|
@@ -7099,6 +7492,197 @@ var Scene = class _Scene {
|
|
|
7099
7492
|
this.activePortalsPrevFrame = new Set(this.activePortalsThisFrame);
|
|
7100
7493
|
this.activePortalsThisFrame.clear();
|
|
7101
7494
|
}
|
|
7495
|
+
/**
|
|
7496
|
+
* Register a generic projection backend (RFC1 §5, RFC2 P1 CTX-0598).
|
|
7497
|
+
* Registration is idempotent per backend instance. Core drives
|
|
7498
|
+
* mount/update/unmount from the render walk; the backend owns all
|
|
7499
|
+
* medium-specific state.
|
|
7500
|
+
*/
|
|
7501
|
+
addProjectionBackend(backend) {
|
|
7502
|
+
if (!this.projectionBackends.includes(backend)) this.projectionBackends.push(backend);
|
|
7503
|
+
return this;
|
|
7504
|
+
}
|
|
7505
|
+
/**
|
|
7506
|
+
* Remove a previously registered projection backend by instance or
|
|
7507
|
+
* {@link ProjectionBackendKind | kind}. Removing does not unmount resident
|
|
7508
|
+
* nodes — unmount the backend first if teardown order matters.
|
|
7509
|
+
*/
|
|
7510
|
+
removeProjectionBackend(backend) {
|
|
7511
|
+
this.projectionBackends = this.projectionBackends.filter(
|
|
7512
|
+
(b) => typeof backend === "string" ? b.kind !== backend : b !== backend
|
|
7513
|
+
);
|
|
7514
|
+
return this;
|
|
7515
|
+
}
|
|
7516
|
+
/**
|
|
7517
|
+
* Scene-level projection capabilities (RFC4 §3 rule 2, CTX-0601).
|
|
7518
|
+
*
|
|
7519
|
+
* Plain-data feature detection in the shape of the proposed
|
|
7520
|
+
* `scene.inputCapabilities` (input-dispatch-contract-v2 §4): apps and
|
|
7521
|
+
* devtools query where a node _would_ materialize and why, without touching
|
|
7522
|
+
* any backend. Additive only — no existing surface moves.
|
|
7523
|
+
*/
|
|
7524
|
+
get projectionCapabilities() {
|
|
7525
|
+
return {
|
|
7526
|
+
hasDOM: typeof document !== "undefined",
|
|
7527
|
+
domBackendMounted: this.projectionBackends.some((b) => b.kind === "dom"),
|
|
7528
|
+
backends: this.projectionBackends.map((b) => b.kind),
|
|
7529
|
+
autoHysteresisFrames: this.projectionHysteresisFrames,
|
|
7530
|
+
autoDomBudget: this.projectionAutoDomBudget
|
|
7531
|
+
};
|
|
7532
|
+
}
|
|
7533
|
+
/**
|
|
7534
|
+
* Register (or replace) the Capability Matrix row for one `domKind`
|
|
7535
|
+
* (RFC4 §4, CTX-0601). Custom kinds start at the unknown-kind default
|
|
7536
|
+
* (`unsupported-kind` → canvas); registering a row is how a custom backend
|
|
7537
|
+
* kind opts into `'auto'` resolution and how tests drive oscillating costs.
|
|
7538
|
+
*/
|
|
7539
|
+
registerProjectionCapability(domKind, row) {
|
|
7540
|
+
this.projectionCapabilityOverrides.set(domKind, row);
|
|
7541
|
+
return this;
|
|
7542
|
+
}
|
|
7543
|
+
/** Matrix row for a node's `domKind` (override wins, unknown → canvas). */
|
|
7544
|
+
getProjectionCapability(node) {
|
|
7545
|
+
return this.projectionCapabilityOverrides.get(node.domKind) ?? DEFAULT_PROJECTION_CAPABILITIES[node.domKind] ?? UNKNOWN_PROJECTION_CAPABILITY;
|
|
7546
|
+
}
|
|
7547
|
+
/**
|
|
7548
|
+
* Pin a node's backend for the duration of an active gesture (RFC4 §5 +
|
|
7549
|
+
* input-dispatch-contract-v2 §4 gesture stickiness: no mid-gesture handoff).
|
|
7550
|
+
* The a11y-mirror pointerdown/up/cancel listeners maintain this; DOM-side
|
|
7551
|
+
* gestures are consulted through
|
|
7552
|
+
* {@link ProjectionBackend.hasActiveGesture | backend.hasActiveGesture}.
|
|
7553
|
+
* Refcounted so multi-pointer gestures on one node unpin exactly once.
|
|
7554
|
+
*/
|
|
7555
|
+
pinProjectionForGesture(node) {
|
|
7556
|
+
this.projectionGesturePins.set(node.id, (this.projectionGesturePins.get(node.id) ?? 0) + 1);
|
|
7557
|
+
}
|
|
7558
|
+
/** Release one gesture pin taken by {@link pinProjectionForGesture}. */
|
|
7559
|
+
unpinProjectionForGesture(node) {
|
|
7560
|
+
const count = (this.projectionGesturePins.get(node.id) ?? 0) - 1;
|
|
7561
|
+
if (count <= 0) this.projectionGesturePins.delete(node.id);
|
|
7562
|
+
else this.projectionGesturePins.set(node.id, count);
|
|
7563
|
+
}
|
|
7564
|
+
/** Whether `node` currently owns an active gesture on a core mirror. */
|
|
7565
|
+
isProjectionPinned(node) {
|
|
7566
|
+
return (this.projectionGesturePins.get(node.id) ?? 0) > 0;
|
|
7567
|
+
}
|
|
7568
|
+
/**
|
|
7569
|
+
* Last negotiation for one node, if it was ever resolved on this scene.
|
|
7570
|
+
* Accepts the node or its id; returns the stored record (not a copy — treat
|
|
7571
|
+
* as read-only).
|
|
7572
|
+
*/
|
|
7573
|
+
getProjectionResolution(node) {
|
|
7574
|
+
return this.projectionResolutions.get(typeof node === "string" ? node : node.id);
|
|
7575
|
+
}
|
|
7576
|
+
/** Every node's last negotiation, in first-resolution order. */
|
|
7577
|
+
getProjectionResolutions() {
|
|
7578
|
+
return [...this.projectionResolutions.values()];
|
|
7579
|
+
}
|
|
7580
|
+
/**
|
|
7581
|
+
* Resolve one node's `domPolicy` to its effective backend (RFC4 §3, CTX-0601).
|
|
7582
|
+
*
|
|
7583
|
+
* Memoized per main frame (`currentFrame`, bumped once per authoritative
|
|
7584
|
+
* render): the render walk and the later a11y sync must agree within a
|
|
7585
|
+
* frame, or a node could lose its mirror the same frame it gains an element.
|
|
7586
|
+
* `'auto'` applies, in order: no-DOM short-circuit → pure negotiation →
|
|
7587
|
+
* gesture/focus hard bars (a pinned node keeps its current backend, never
|
|
7588
|
+
* flips mid-gesture, focus is preserved-or-moved never dropped to `body` per
|
|
7589
|
+
* input-dispatch-contract-v2 §4) → hysteresis stickiness → bulk budget.
|
|
7590
|
+
* Every outcome is recorded on the per-scene queryable surface
|
|
7591
|
+
* ({@link getProjectionResolutions}) with its reason — fallbacks are
|
|
7592
|
+
* reported, not silent (§3 rule 2).
|
|
7593
|
+
*/
|
|
7594
|
+
resolveProjectionFor(node) {
|
|
7595
|
+
const want = node.domPolicy;
|
|
7596
|
+
if (this.projectionBackends.length === 0 && want === "canvas") return "canvas";
|
|
7597
|
+
const memo = this.projectionHysteresis.get(node.id);
|
|
7598
|
+
if (memo && memo.lastFrame === this.currentFrame) return memo.resolved;
|
|
7599
|
+
const hasDOM = typeof document !== "undefined";
|
|
7600
|
+
const domBackendMounted = this.projectionBackends.some((b) => b.kind === "dom");
|
|
7601
|
+
const outcome = resolveProjection(want, this.getProjectionCapability(node), {
|
|
7602
|
+
hasDOM,
|
|
7603
|
+
domBackendMounted
|
|
7604
|
+
});
|
|
7605
|
+
let resolved = outcome.resolved;
|
|
7606
|
+
let reason = outcome.reason;
|
|
7607
|
+
if (want === "auto" && memo !== void 0) {
|
|
7608
|
+
if (resolved !== memo.resolved) {
|
|
7609
|
+
const gesturePinned = this.isProjectionPinned(node) || this.projectionBackends.some((b) => b.hasActiveGesture?.(node) === true);
|
|
7610
|
+
if (gesturePinned) {
|
|
7611
|
+
resolved = memo.resolved;
|
|
7612
|
+
reason = "active-gesture";
|
|
7613
|
+
} else if (this.ownsProjectionFocus(node)) {
|
|
7614
|
+
resolved = memo.resolved;
|
|
7615
|
+
reason = "focus-pinned";
|
|
7616
|
+
} else {
|
|
7617
|
+
const vote = hysteresisVote(
|
|
7618
|
+
memo.resolved,
|
|
7619
|
+
resolved,
|
|
7620
|
+
memo.consecutive,
|
|
7621
|
+
this.projectionHysteresisFrames
|
|
7622
|
+
);
|
|
7623
|
+
resolved = vote.resolved;
|
|
7624
|
+
memo.consecutive = vote.consecutive;
|
|
7625
|
+
reason = vote.flipped ? reason : "hysteresis";
|
|
7626
|
+
}
|
|
7627
|
+
} else {
|
|
7628
|
+
memo.consecutive = 0;
|
|
7629
|
+
}
|
|
7630
|
+
}
|
|
7631
|
+
if (want === "auto" && resolved === "dom") {
|
|
7632
|
+
if (this.projectionBudgetFrame !== this.currentFrame) {
|
|
7633
|
+
this.projectionBudgetFrame = this.currentFrame;
|
|
7634
|
+
this.projectionDomAutoCount = 0;
|
|
7635
|
+
}
|
|
7636
|
+
this.projectionDomAutoCount += 1;
|
|
7637
|
+
if (this.projectionDomAutoCount > this.projectionAutoDomBudget) {
|
|
7638
|
+
resolved = "canvas";
|
|
7639
|
+
reason = "bulk-budget";
|
|
7640
|
+
}
|
|
7641
|
+
}
|
|
7642
|
+
this.projectionHysteresis.set(node.id, {
|
|
7643
|
+
resolved,
|
|
7644
|
+
consecutive: memo?.consecutive ?? 0,
|
|
7645
|
+
lastFrame: this.currentFrame
|
|
7646
|
+
});
|
|
7647
|
+
const prev = this.projectionResolutions.get(node.id);
|
|
7648
|
+
if (!prev || prev.want !== want || prev.resolved !== resolved || prev.reason !== reason) {
|
|
7649
|
+
this.projectionResolutions.set(node.id, { nodeId: node.id, want, resolved, reason });
|
|
7650
|
+
}
|
|
7651
|
+
return resolved;
|
|
7652
|
+
}
|
|
7653
|
+
/**
|
|
7654
|
+
* Whether `node`'s a11y mirror currently owns browser focus (RFC4 §5 focus
|
|
7655
|
+
* rule, input-dispatch-contract-v2 §4: focus stays DOM-based; flipping
|
|
7656
|
+
* `projection` must preserve or deliberately move focus, never drop it to
|
|
7657
|
+
* `body`). A focused node keeps its backend until blur — the actual move, if
|
|
7658
|
+
* any, goes through the existing sentinels (`preserveFocusOnRemoval`, the
|
|
7659
|
+
* DOM backend's own fallback), never a bare removal.
|
|
7660
|
+
*/
|
|
7661
|
+
ownsProjectionFocus(node) {
|
|
7662
|
+
if (typeof document === "undefined") return false;
|
|
7663
|
+
const mirror = this.a11yElements.get(node.id);
|
|
7664
|
+
if (!mirror || mirror !== this.focusedA11yElement) return false;
|
|
7665
|
+
return document.activeElement === mirror;
|
|
7666
|
+
}
|
|
7667
|
+
/**
|
|
7668
|
+
* RFC2 P1 (CTX-0598): unmount DOM-projection residents the walk did not see
|
|
7669
|
+
* this frame (viewport-culled, removed without `remove()`, policy-flipped).
|
|
7670
|
+
* Mirrors {@link reconcilePortals} above; unmount is idempotent per the
|
|
7671
|
+
* `ProjectionBackend` contract so notifying every backend is safe.
|
|
7672
|
+
*/
|
|
7673
|
+
pruneProjectionBackends() {
|
|
7674
|
+
for (const id of this.domSeenPrevFrame) {
|
|
7675
|
+
if (!this.domSeenThisFrame.has(id)) {
|
|
7676
|
+
const node = this.domSeenNodes.get(id);
|
|
7677
|
+
if (node) {
|
|
7678
|
+
for (const backend of this.projectionBackends) backend.unmount(node);
|
|
7679
|
+
this.domSeenNodes.delete(id);
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
}
|
|
7683
|
+
this.domSeenPrevFrame = new Set(this.domSeenThisFrame);
|
|
7684
|
+
this.domSeenThisFrame.clear();
|
|
7685
|
+
}
|
|
7102
7686
|
// --- domain: render-scheduler — the loop and the render walk ---
|
|
7103
7687
|
/**
|
|
7104
7688
|
* The frame-rate cap actually in effect: the explicit {@link maxFPS}, further
|
|
@@ -7176,6 +7760,13 @@ var Scene = class _Scene {
|
|
|
7176
7760
|
/**
|
|
7177
7761
|
* Render the entire scene graph onto the specified renderer.
|
|
7178
7762
|
*
|
|
7763
|
+
* This is the `CanvasProjection` row of {@link ProjectionBackend}:
|
|
7764
|
+
* the per-frame materialization of scene semantics into pixels through the
|
|
7765
|
+
* backend-agnostic `IRenderer` contract. (The `A11yProjection` /
|
|
7766
|
+
* `ContentProjection` rows run on the same cadence via {@link Scene.syncA11y}
|
|
7767
|
+
* below; the `DOMProjection` row lives in `@vectojs/dom` and is driven from
|
|
7768
|
+
* the render walk further below.)
|
|
7769
|
+
*
|
|
7179
7770
|
* Main-frame causal order is a correctness contract:
|
|
7180
7771
|
*
|
|
7181
7772
|
* 1. Browser/input callbacks finish before the scheduled frame begins.
|
|
@@ -7468,6 +8059,16 @@ var Scene = class _Scene {
|
|
|
7468
8059
|
visible = maxX >= 0 && minX <= vw && maxY >= 0 && minY <= vh;
|
|
7469
8060
|
}
|
|
7470
8061
|
if (!visible && node.children.length === 0) return;
|
|
8062
|
+
if (isMainRenderer && this.projectionBackends.length > 0 && this.resolveProjectionFor(node) === "dom" && visible) {
|
|
8063
|
+
for (const backend of this.projectionBackends) {
|
|
8064
|
+
backend.update(node, { a, b, c, d, e: te, f: tf });
|
|
8065
|
+
}
|
|
8066
|
+
if (node.domResident) {
|
|
8067
|
+
this.domSeenThisFrame.add(node.id);
|
|
8068
|
+
this.domSeenNodes.set(node.id, node);
|
|
8069
|
+
if (node.children.length === 0) return;
|
|
8070
|
+
}
|
|
8071
|
+
}
|
|
7471
8072
|
if (node.children.length === 0 && node.scaleX === node.scaleY) {
|
|
7472
8073
|
const bc = node.getBatchCircle();
|
|
7473
8074
|
if (bc) {
|
|
@@ -7597,6 +8198,7 @@ var Scene = class _Scene {
|
|
|
7597
8198
|
this.frameHadAnimation = walkHadAnimation;
|
|
7598
8199
|
this.frameHadInteractive = walkHadInteractive;
|
|
7599
8200
|
this.reconcilePortals();
|
|
8201
|
+
this.pruneProjectionBackends();
|
|
7600
8202
|
}
|
|
7601
8203
|
const flushTiming = this.phases.userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.flush) : null;
|
|
7602
8204
|
const flushT0 = this.phases.enabled ? performance.now() : 0;
|
|
@@ -8033,6 +8635,238 @@ var TextEntity = class extends Entity {
|
|
|
8033
8635
|
}
|
|
8034
8636
|
};
|
|
8035
8637
|
|
|
8638
|
+
// src/components/VirtualizedSetAggregate.ts
|
|
8639
|
+
var VirtualizedSetAggregate = class extends Entity {
|
|
8640
|
+
_items;
|
|
8641
|
+
_label;
|
|
8642
|
+
_role;
|
|
8643
|
+
_itemRole;
|
|
8644
|
+
_rowHeight;
|
|
8645
|
+
_visibleCapacity;
|
|
8646
|
+
_onActivate;
|
|
8647
|
+
/** First item index currently bound to the pool (the scroll window). */
|
|
8648
|
+
_visibleStart = 0;
|
|
8649
|
+
/** Item id owning the roving tab stop / keyboard focus. */
|
|
8650
|
+
_activeId = null;
|
|
8651
|
+
_selectedId = null;
|
|
8652
|
+
/** One focusable hotspot per visible slot; re-bound, never rebuilt per item. */
|
|
8653
|
+
_hotspots = [];
|
|
8654
|
+
constructor(opts = {}) {
|
|
8655
|
+
super();
|
|
8656
|
+
this._items = opts.items ?? [];
|
|
8657
|
+
this._label = opts.label ?? "Items";
|
|
8658
|
+
this._role = opts.role ?? "list";
|
|
8659
|
+
this._itemRole = opts.itemRole ?? "listitem";
|
|
8660
|
+
this._rowHeight = opts.rowHeight ?? 28;
|
|
8661
|
+
this._visibleCapacity = opts.visibleCapacity;
|
|
8662
|
+
this._onActivate = opts.onActivate;
|
|
8663
|
+
this.width = opts.width ?? 0;
|
|
8664
|
+
this.height = opts.height ?? 0;
|
|
8665
|
+
this.interactive = true;
|
|
8666
|
+
this._syncHotspots();
|
|
8667
|
+
}
|
|
8668
|
+
/** Full item count — the number the container label states. */
|
|
8669
|
+
get itemCount() {
|
|
8670
|
+
return this._items.length;
|
|
8671
|
+
}
|
|
8672
|
+
/** Live pool size (bounded by the visible capacity, never by the item count). */
|
|
8673
|
+
get poolSize() {
|
|
8674
|
+
return this._hotspots.length;
|
|
8675
|
+
}
|
|
8676
|
+
/** First item index currently bound to the pool. */
|
|
8677
|
+
get visibleStart() {
|
|
8678
|
+
return this._visibleStart;
|
|
8679
|
+
}
|
|
8680
|
+
getA11yAttributes() {
|
|
8681
|
+
return {
|
|
8682
|
+
role: this._role,
|
|
8683
|
+
label: `${this._label}, ${this._items.length} items`,
|
|
8684
|
+
pointerEvents: "none"
|
|
8685
|
+
};
|
|
8686
|
+
}
|
|
8687
|
+
render(_renderer) {
|
|
8688
|
+
}
|
|
8689
|
+
/** Axis-aligned hit-test against the container box (the `UIComponent` default). */
|
|
8690
|
+
isPointInside(globalX, globalY) {
|
|
8691
|
+
const local = this.worldToLocal(globalX, globalY);
|
|
8692
|
+
if (!local) return false;
|
|
8693
|
+
return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
|
|
8694
|
+
}
|
|
8695
|
+
/** Replace the item set; the pool re-binds and the label count follows. */
|
|
8696
|
+
setItems(items) {
|
|
8697
|
+
this._items = items;
|
|
8698
|
+
if (this._activeId !== null && !items.some((item) => item.id === this._activeId)) {
|
|
8699
|
+
this._activeId = null;
|
|
8700
|
+
}
|
|
8701
|
+
if (this._selectedId !== null && !items.some((item) => item.id === this._selectedId)) {
|
|
8702
|
+
this._selectedId = null;
|
|
8703
|
+
}
|
|
8704
|
+
this._visibleStart = Math.max(0, Math.min(this._visibleStart, Math.max(0, items.length - 1)));
|
|
8705
|
+
this._syncHotspots();
|
|
8706
|
+
this.scene?.markDirty();
|
|
8707
|
+
}
|
|
8708
|
+
/** Move the pool window to start at `start` (the scroll position). */
|
|
8709
|
+
setVisibleStart(start) {
|
|
8710
|
+
const clamped = Math.max(0, Math.min(start, Math.max(0, this._items.length - 1)));
|
|
8711
|
+
if (clamped === this._visibleStart) return;
|
|
8712
|
+
this._visibleStart = clamped;
|
|
8713
|
+
this._syncHotspots();
|
|
8714
|
+
this.scene?.markDirty();
|
|
8715
|
+
}
|
|
8716
|
+
/**
|
|
8717
|
+
* Keep one hotspot per visible slot, positioned over it. The pool is sized
|
|
8718
|
+
* to the viewport (capacity), each slot re-bound to whatever item currently
|
|
8719
|
+
* occupies it — the `Tree._syncHotspots` shape, minus tree structure.
|
|
8720
|
+
*/
|
|
8721
|
+
_syncHotspots() {
|
|
8722
|
+
const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
|
|
8723
|
+
const need = Math.max(0, Math.min(capacity, this._items.length - this._visibleStart));
|
|
8724
|
+
while (this._hotspots.length < need) {
|
|
8725
|
+
const hotspot = new AggregateItemHotspot(this);
|
|
8726
|
+
this._hotspots.push(hotspot);
|
|
8727
|
+
this.add(hotspot);
|
|
8728
|
+
}
|
|
8729
|
+
while (this._hotspots.length > need) {
|
|
8730
|
+
const hotspot = this._hotspots.pop();
|
|
8731
|
+
this.scene?.detachA11y(hotspot);
|
|
8732
|
+
this.remove(hotspot);
|
|
8733
|
+
}
|
|
8734
|
+
for (let slot = 0; slot < need; slot++) {
|
|
8735
|
+
const index = this._visibleStart + slot;
|
|
8736
|
+
const hotspot = this._hotspots[slot];
|
|
8737
|
+
hotspot.bind(index, this._items[index]);
|
|
8738
|
+
hotspot.x = 0;
|
|
8739
|
+
hotspot.y = slot * this._rowHeight;
|
|
8740
|
+
hotspot.width = this.width;
|
|
8741
|
+
hotspot.height = this._rowHeight;
|
|
8742
|
+
}
|
|
8743
|
+
}
|
|
8744
|
+
/** Whether `id` owns the roving tab stop: active, else selected, else first. */
|
|
8745
|
+
isTabStop(id) {
|
|
8746
|
+
const anchor = (this._activeId !== null && this._items.some((item) => item.id === this._activeId) ? this._activeId : null) ?? (this._selectedId !== null && this._items.some((item) => item.id === this._selectedId) ? this._selectedId : null) ?? this._items[0]?.id;
|
|
8747
|
+
return id === anchor;
|
|
8748
|
+
}
|
|
8749
|
+
isSelected(id) {
|
|
8750
|
+
return this._selectedId === id;
|
|
8751
|
+
}
|
|
8752
|
+
/** Item role the hotspots announce (kept on the parent: one source of truth). */
|
|
8753
|
+
get itemRole() {
|
|
8754
|
+
return this._itemRole;
|
|
8755
|
+
}
|
|
8756
|
+
/** Activate an item (pointer tap-through or Enter/Space): selects and notifies. */
|
|
8757
|
+
activateItem(id, focusIt = false) {
|
|
8758
|
+
const index = this._items.findIndex((item) => item.id === id);
|
|
8759
|
+
if (index === -1) return;
|
|
8760
|
+
this._activeId = id;
|
|
8761
|
+
this._selectedId = id;
|
|
8762
|
+
this._onActivate?.(this._items[index], index);
|
|
8763
|
+
if (focusIt) this.focusItem(id);
|
|
8764
|
+
this.scene?.markDirty();
|
|
8765
|
+
}
|
|
8766
|
+
/** Move keyboard focus to the hotspot currently bound to `id`, if any. */
|
|
8767
|
+
focusItem(id) {
|
|
8768
|
+
this._hotspots.find((hotspot) => hotspot.itemId === id)?.focus();
|
|
8769
|
+
}
|
|
8770
|
+
/**
|
|
8771
|
+
* Aggregate keyboard model: Up/Down move the active item (scrolling the pool
|
|
8772
|
+
* window so it stays bound), Home/End jump, Enter/Space activate. The active
|
|
8773
|
+
* item is focused as it moves so focus is never stranded on a re-bound slot.
|
|
8774
|
+
*/
|
|
8775
|
+
handleItemKey(e, id) {
|
|
8776
|
+
const index = this._items.findIndex((item) => item.id === id);
|
|
8777
|
+
if (index === -1) return;
|
|
8778
|
+
let next = -1;
|
|
8779
|
+
switch (e.key) {
|
|
8780
|
+
case "ArrowDown":
|
|
8781
|
+
next = Math.min(index + 1, this._items.length - 1);
|
|
8782
|
+
break;
|
|
8783
|
+
case "ArrowUp":
|
|
8784
|
+
next = Math.max(index - 1, 0);
|
|
8785
|
+
break;
|
|
8786
|
+
case "Home":
|
|
8787
|
+
next = 0;
|
|
8788
|
+
break;
|
|
8789
|
+
case "End":
|
|
8790
|
+
next = this._items.length - 1;
|
|
8791
|
+
break;
|
|
8792
|
+
case "Enter":
|
|
8793
|
+
case " ":
|
|
8794
|
+
e.preventDefault();
|
|
8795
|
+
this.activateItem(id, true);
|
|
8796
|
+
return;
|
|
8797
|
+
default:
|
|
8798
|
+
return;
|
|
8799
|
+
}
|
|
8800
|
+
e.preventDefault();
|
|
8801
|
+
this._activeId = this._items[next].id;
|
|
8802
|
+
this._ensureVisible(next);
|
|
8803
|
+
this.focusItem(this._activeId);
|
|
8804
|
+
this.scene?.markDirty();
|
|
8805
|
+
}
|
|
8806
|
+
/** Scroll the pool window just enough to keep `index` bound. */
|
|
8807
|
+
_ensureVisible(index) {
|
|
8808
|
+
const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
|
|
8809
|
+
if (index < this._visibleStart) this.setVisibleStart(index);
|
|
8810
|
+
else if (index >= this._visibleStart + capacity) {
|
|
8811
|
+
this.setVisibleStart(index - capacity + 1);
|
|
8812
|
+
} else {
|
|
8813
|
+
this._syncHotspots();
|
|
8814
|
+
}
|
|
8815
|
+
}
|
|
8816
|
+
update(_dt, _time) {
|
|
8817
|
+
super.update(_dt, _time);
|
|
8818
|
+
this._syncHotspots();
|
|
8819
|
+
}
|
|
8820
|
+
};
|
|
8821
|
+
var AggregateItemHotspot = class extends Entity {
|
|
8822
|
+
constructor(aggregate) {
|
|
8823
|
+
super();
|
|
8824
|
+
this.aggregate = aggregate;
|
|
8825
|
+
this.interactive = true;
|
|
8826
|
+
this.on("click", () => this.aggregate.activateItem(this.itemId, true));
|
|
8827
|
+
this.on("keydown", (e) => this.aggregate.handleItemKey(e, this.itemId));
|
|
8828
|
+
}
|
|
8829
|
+
aggregate;
|
|
8830
|
+
itemId = "";
|
|
8831
|
+
_index = 0;
|
|
8832
|
+
_label = "";
|
|
8833
|
+
bind(index, item) {
|
|
8834
|
+
this._index = index;
|
|
8835
|
+
this.itemId = item.id;
|
|
8836
|
+
this._label = item.label;
|
|
8837
|
+
}
|
|
8838
|
+
/** The aggregate positions and sizes one pooled hotspot per visible slot. */
|
|
8839
|
+
getLayoutControlledProperties() {
|
|
8840
|
+
return ["x", "y", "width", "height"];
|
|
8841
|
+
}
|
|
8842
|
+
getA11yAttributes() {
|
|
8843
|
+
return {
|
|
8844
|
+
role: this.aggregate.itemRole,
|
|
8845
|
+
label: this._label,
|
|
8846
|
+
// 1-based position within the FULL set, not the pool: without these a
|
|
8847
|
+
// virtualized window announces the pool position as the set position.
|
|
8848
|
+
posInSet: this._index + 1,
|
|
8849
|
+
setSize: this.aggregate.itemCount,
|
|
8850
|
+
selected: this.aggregate.isSelected(this.itemId),
|
|
8851
|
+
// Roving tabindex: only the active item is a tab stop; arrows move within.
|
|
8852
|
+
tabIndex: this.aggregate.isTabStop(this.itemId) ? 0 : -1,
|
|
8853
|
+
// The owner keeps the pointer (canvas handling or an underlying
|
|
8854
|
+
// selectable-text mirror); this hotspot exists for semantics + keyboard
|
|
8855
|
+
// focus, so it opts out of hit-testing. Keyboard focus and
|
|
8856
|
+
// AT-synthesized `click` still work under `pointer-events:none`.
|
|
8857
|
+
pointerEvents: "none"
|
|
8858
|
+
};
|
|
8859
|
+
}
|
|
8860
|
+
render() {
|
|
8861
|
+
}
|
|
8862
|
+
/** Axis-aligned hit-test against the hotspot slot (the `UIComponent` default). */
|
|
8863
|
+
isPointInside(globalX, globalY) {
|
|
8864
|
+
const local = this.worldToLocal(globalX, globalY);
|
|
8865
|
+
if (!local) return false;
|
|
8866
|
+
return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
|
|
8867
|
+
}
|
|
8868
|
+
};
|
|
8869
|
+
|
|
8036
8870
|
// src/components/GridTextEntity.ts
|
|
8037
8871
|
var GridTextEntity = class extends Entity {
|
|
8038
8872
|
fontSize;
|
|
@@ -8728,6 +9562,8 @@ export {
|
|
|
8728
9562
|
Circle,
|
|
8729
9563
|
ComputeParticleEntity,
|
|
8730
9564
|
DEFAULT_CONTENT_SEMANTIC_BUDGET,
|
|
9565
|
+
DEFAULT_PROJECTION_CAPABILITIES,
|
|
9566
|
+
DEFAULT_SEMANTIC_PROJECTION_POLICY,
|
|
8731
9567
|
DOMPortalEntity,
|
|
8732
9568
|
Entity,
|
|
8733
9569
|
GlyphRasterAtlas,
|
|
@@ -8744,6 +9580,8 @@ export {
|
|
|
8744
9580
|
PARTICLE_OFFSET_VELOCITY_X,
|
|
8745
9581
|
PARTICLE_OFFSET_VELOCITY_Y,
|
|
8746
9582
|
PARTICLE_STRIDE_FLOATS,
|
|
9583
|
+
PROJECTION_AUTO_DOM_BUDGET,
|
|
9584
|
+
PROJECTION_AUTO_HYSTERESIS_FRAMES,
|
|
8747
9585
|
REDUCED_MOTION_FPS,
|
|
8748
9586
|
Rect,
|
|
8749
9587
|
SCENE_OPTION_KEYS,
|
|
@@ -8753,14 +9591,20 @@ export {
|
|
|
8753
9591
|
SplineEntity,
|
|
8754
9592
|
TextEntity,
|
|
8755
9593
|
TextRasterCache,
|
|
9594
|
+
UNKNOWN_PROJECTION_CAPABILITY,
|
|
8756
9595
|
VECTO_USER_TIMING,
|
|
8757
9596
|
VectoJSEvent,
|
|
9597
|
+
VirtualizedSetAggregate,
|
|
8758
9598
|
WebGPUParticleSystemManager,
|
|
8759
9599
|
beginVectoUserTiming,
|
|
8760
9600
|
contentLineInHint,
|
|
8761
9601
|
createWebGLPointRenderer,
|
|
9602
|
+
describeHTMLInCanvasSupport,
|
|
8762
9603
|
endVectoUserTiming,
|
|
9604
|
+
getSemanticProjectionCapabilities,
|
|
9605
|
+
hysteresisVote,
|
|
8763
9606
|
installRendererDevTraps,
|
|
9607
|
+
isDeferrableSemanticNode,
|
|
8764
9608
|
isRendererDevMode,
|
|
8765
9609
|
isSafeUrl,
|
|
8766
9610
|
loadSpline,
|
|
@@ -8769,6 +9613,8 @@ export {
|
|
|
8769
9613
|
ownsKeyboard,
|
|
8770
9614
|
parseColorToRGBA,
|
|
8771
9615
|
polySegmentToBezier,
|
|
9616
|
+
resolveProjection,
|
|
8772
9617
|
sanitizeUrl,
|
|
8773
|
-
setRendererDevMode
|
|
9618
|
+
setRendererDevMode,
|
|
9619
|
+
supportsHTMLInCanvas
|
|
8774
9620
|
};
|