@vectojs/core 1.39.1 → 1.40.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/{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 +1040 -206
- package/dist/index.mjs +839 -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 +195 -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;
|
|
@@ -3564,6 +3822,7 @@ var SCENE_OPTION_KEYS = [
|
|
|
3564
3822
|
"renderer",
|
|
3565
3823
|
"renderMode",
|
|
3566
3824
|
"respectReducedMotion",
|
|
3825
|
+
"semanticProjectionPolicy",
|
|
3567
3826
|
"userTiming"
|
|
3568
3827
|
];
|
|
3569
3828
|
var SCENE_FIELD_NOT_OPTION = {
|
|
@@ -4061,6 +4320,26 @@ var Scene = class _Scene {
|
|
|
4061
4320
|
* frame. See {@link SceneOptions.a11ySyncInterval}.
|
|
4062
4321
|
*/
|
|
4063
4322
|
a11ySyncInterval = 0;
|
|
4323
|
+
/**
|
|
4324
|
+
* Per-node semantic projection policy (RFC3 §5, CTX-0599). Consulted once
|
|
4325
|
+
* per node by {@link shouldProjectA11y}; the default projects everything
|
|
4326
|
+
* the legacy predicate projects, so reassigning nothing changes nothing.
|
|
4327
|
+
* See {@link SceneOptions.semanticProjectionPolicy}.
|
|
4328
|
+
*/
|
|
4329
|
+
semanticProjectionPolicy = DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
4330
|
+
/**
|
|
4331
|
+
* Consecutive syncs an `'auto'`-policy node must keep voting for the other
|
|
4332
|
+
* backend before its sticky resolution flips (RFC4 §3 rule 3, CTX-0601).
|
|
4333
|
+
* Documented tunable — see {@link PROJECTION_AUTO_HYSTERESIS_FRAMES}.
|
|
4334
|
+
*/
|
|
4335
|
+
projectionHysteresisFrames = PROJECTION_AUTO_HYSTERESIS_FRAMES;
|
|
4336
|
+
/**
|
|
4337
|
+
* Maximum `'auto'`-resolved DOM residents per scene per frame (RFC4 §4
|
|
4338
|
+
* particle-row backstop: bulk-count nodes stay canvas). Explicit `'dom'`
|
|
4339
|
+
* requests bypass it. Documented tunable — see
|
|
4340
|
+
* {@link PROJECTION_AUTO_DOM_BUDGET}.
|
|
4341
|
+
*/
|
|
4342
|
+
projectionAutoDomBudget = PROJECTION_AUTO_DOM_BUDGET;
|
|
4064
4343
|
/** Timestamp of the last a11y sync, for throttling. */
|
|
4065
4344
|
lastA11ySync = -Infinity;
|
|
4066
4345
|
/** True if we skipped an a11y sync during animation and need to sync when at rest. */
|
|
@@ -4291,6 +4570,30 @@ var Scene = class _Scene {
|
|
|
4291
4570
|
activePortalsThisFrame = /* @__PURE__ */ new Set();
|
|
4292
4571
|
activePortalsPrevFrame = /* @__PURE__ */ new Set();
|
|
4293
4572
|
portalEntities = /* @__PURE__ */ new Map();
|
|
4573
|
+
/**
|
|
4574
|
+
* Generic projection backends (RFC1 §5 vocabulary, `tree/scene/ProjectionBackend.ts`).
|
|
4575
|
+
* Render-independent: the interface exchanges only node identity, world
|
|
4576
|
+
* matrix, and lifecycle calls, so registering a DOM backend adds no
|
|
4577
|
+
* `HTMLElement` surface to core. `@vectojs/dom` owns the only implementation.
|
|
4578
|
+
*/
|
|
4579
|
+
projectionBackends = [];
|
|
4580
|
+
/** Per-frame seen-sets driving {@link pruneProjectionBackends} (portal-precedent). */
|
|
4581
|
+
domSeenPrevFrame = /* @__PURE__ */ new Set();
|
|
4582
|
+
domSeenThisFrame = /* @__PURE__ */ new Set();
|
|
4583
|
+
domSeenNodes = /* @__PURE__ */ new Map();
|
|
4584
|
+
/**
|
|
4585
|
+
* Negotiation state (RFC4 §3, CTX-0601): last resolution per node (the
|
|
4586
|
+
* per-scene queryable surface behind {@link getProjectionResolutions}),
|
|
4587
|
+
* hysteresis votes, per-kind capability overrides, and explicit gesture
|
|
4588
|
+
* pins. Plain data only — no `HTMLElement` surface in core.
|
|
4589
|
+
*/
|
|
4590
|
+
projectionResolutions = /* @__PURE__ */ new Map();
|
|
4591
|
+
projectionHysteresis = /* @__PURE__ */ new Map();
|
|
4592
|
+
projectionCapabilityOverrides = /* @__PURE__ */ new Map();
|
|
4593
|
+
projectionGesturePins = /* @__PURE__ */ new Map();
|
|
4594
|
+
/** Frame id the `'auto'` DOM budget count belongs to (bulk backstop). */
|
|
4595
|
+
projectionBudgetFrame = -1;
|
|
4596
|
+
projectionDomAutoCount = 0;
|
|
4294
4597
|
renderOrderCounter = 0;
|
|
4295
4598
|
// --- domain: render-scheduler — authoritative frame counter ---
|
|
4296
4599
|
/**
|
|
@@ -4954,6 +5257,9 @@ var Scene = class _Scene {
|
|
|
4954
5257
|
this.phases.userTiming = options.userTiming ?? false;
|
|
4955
5258
|
this.particleBackend = options.particleBackend ?? "auto";
|
|
4956
5259
|
this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
|
|
5260
|
+
this.semanticProjectionPolicy = options.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
5261
|
+
this.projectionHysteresisFrames = options.projectionHysteresisFrames ?? PROJECTION_AUTO_HYSTERESIS_FRAMES;
|
|
5262
|
+
this.projectionAutoDomBudget = options.projectionAutoDomBudget ?? PROJECTION_AUTO_DOM_BUDGET;
|
|
4957
5263
|
this.contentProjectionEnabled = options.contentProjection ?? true;
|
|
4958
5264
|
this.contentProjectionMargin = options.contentProjectionMargin;
|
|
4959
5265
|
this.contentSemanticMargin = options.contentSemanticMargin;
|
|
@@ -5305,6 +5611,37 @@ var Scene = class _Scene {
|
|
|
5305
5611
|
findEntityAt(x, y) {
|
|
5306
5612
|
return this._hitTester.findEntityAt(x, y, this.currentFrame, this.width, this.height);
|
|
5307
5613
|
}
|
|
5614
|
+
// --- domain: hit-test — merged HitResult query (RFC5 §2, CTX-0600) ---
|
|
5615
|
+
/**
|
|
5616
|
+
* The merged hit list for a scene-space point: the canvas spatial test
|
|
5617
|
+
* plus caller-observed DOM-native candidates (mirror / portal /
|
|
5618
|
+
* `dom-visual` extension point for CTX-0598) as ONE ordered candidate
|
|
5619
|
+
* list (`HitResult`, overlay order authoritative).
|
|
5620
|
+
*
|
|
5621
|
+
* Query API alongside {@link findEntityAt} — no dispatch change:
|
|
5622
|
+
* `findEntityAt` keeps its single topmost answer byte-for-byte, keyboard
|
|
5623
|
+
* and AT flows are untouched. For browser pointer coordinates use
|
|
5624
|
+
* {@link findHitsAtClient}, which maps through {@link clientToScene} first.
|
|
5625
|
+
*/
|
|
5626
|
+
findHitsAt(x, y, domCandidates = []) {
|
|
5627
|
+
return this._hitTester.findHitsAt(
|
|
5628
|
+
x,
|
|
5629
|
+
y,
|
|
5630
|
+
this.currentFrame,
|
|
5631
|
+
this.width,
|
|
5632
|
+
this.height,
|
|
5633
|
+
domCandidates
|
|
5634
|
+
);
|
|
5635
|
+
}
|
|
5636
|
+
/**
|
|
5637
|
+
* {@link findHitsAt} for browser viewport coordinates: maps through
|
|
5638
|
+
* {@link clientToScene} so coordinates enter in scene space (RFC5 §2
|
|
5639
|
+
* rule 3) and backend attribution follows.
|
|
5640
|
+
*/
|
|
5641
|
+
findHitsAtClient(clientX, clientY, domCandidates = []) {
|
|
5642
|
+
const point = this.clientToScene(clientX, clientY);
|
|
5643
|
+
return this.findHitsAt(point.x, point.y, domCandidates);
|
|
5644
|
+
}
|
|
5308
5645
|
// --- domain: hit-test — client-to-scene mapping ---
|
|
5309
5646
|
/** Convert browser viewport coordinates into this Scene's logical coordinates. */
|
|
5310
5647
|
clientToScene(clientX, clientY) {
|
|
@@ -5345,6 +5682,15 @@ var Scene = class _Scene {
|
|
|
5345
5682
|
this.activePortalsThisFrame.delete(node.id);
|
|
5346
5683
|
this.activePortalsPrevFrame.delete(node.id);
|
|
5347
5684
|
}
|
|
5685
|
+
if (node.domPolicy === "dom" || node.domResident) {
|
|
5686
|
+
for (const backend of this.projectionBackends) backend.unmount(node);
|
|
5687
|
+
this.domSeenThisFrame.delete(node.id);
|
|
5688
|
+
this.domSeenPrevFrame.delete(node.id);
|
|
5689
|
+
this.domSeenNodes.delete(node.id);
|
|
5690
|
+
}
|
|
5691
|
+
this.projectionResolutions.delete(node.id);
|
|
5692
|
+
this.projectionHysteresis.delete(node.id);
|
|
5693
|
+
this.projectionGesturePins.delete(node.id);
|
|
5348
5694
|
const contentEl = this.contentElements.get(node.id);
|
|
5349
5695
|
if (contentEl) {
|
|
5350
5696
|
this._contentProjection.clearGridState(node.id, contentEl);
|
|
@@ -5954,15 +6300,48 @@ var Scene = class _Scene {
|
|
|
5954
6300
|
shouldProjectA11y(node) {
|
|
5955
6301
|
if (!node.interactive) return false;
|
|
5956
6302
|
if (!(node.width > 0 || node.a11yFullViewport)) return false;
|
|
6303
|
+
if (node.domPolicy === "dom" || this.resolveProjectionFor(node) === "dom") return false;
|
|
5957
6304
|
switch (node.a11yProjection) {
|
|
5958
6305
|
case "never":
|
|
5959
6306
|
return false;
|
|
5960
6307
|
case "onDemand":
|
|
5961
|
-
return this.a11yEngaged(node);
|
|
6308
|
+
return this.a11yEngaged(node) && this.resolveSemanticProjection(node);
|
|
5962
6309
|
default:
|
|
5963
|
-
return
|
|
6310
|
+
return this.resolveSemanticProjection(node);
|
|
5964
6311
|
}
|
|
5965
6312
|
}
|
|
6313
|
+
/**
|
|
6314
|
+
* Policy half of {@link shouldProjectA11y} (RFC3 §5, CTX-0599).
|
|
6315
|
+
*
|
|
6316
|
+
* The legacy gates above (interactive, box, `a11yProjection` engagement)
|
|
6317
|
+
* are unchanged; this only maps a {@link SemanticProjectionPolicy} decision
|
|
6318
|
+
* onto project/suppress. Out of the box the default policy returns
|
|
6319
|
+
* `'project'`, so behaviour is identical to having no policy.
|
|
6320
|
+
*
|
|
6321
|
+
* `'defer-to-browser'` always falls back to projection today: no deferral
|
|
6322
|
+
* backend exists (`supportsHTMLInCanvas()` is `false`), so deferring would
|
|
6323
|
+
* silently drop semantics — the exact failure RFC §3 rules out. When a
|
|
6324
|
+
* backend lands, only allow-listed plain display text
|
|
6325
|
+
* ({@link isDeferrableSemanticNode}; never controls) may actually defer. A
|
|
6326
|
+
* throwing policy likewise falls back to projection: a policy must never be
|
|
6327
|
+
* able to drop semantics by accident.
|
|
6328
|
+
*/
|
|
6329
|
+
resolveSemanticProjection(node) {
|
|
6330
|
+
const policy = this.semanticProjectionPolicy ?? DEFAULT_SEMANTIC_PROJECTION_POLICY;
|
|
6331
|
+
let decision;
|
|
6332
|
+
try {
|
|
6333
|
+
decision = policy.choose(node, getSemanticProjectionCapabilities(), {
|
|
6334
|
+
hasDOM: this.a11yRoot !== null
|
|
6335
|
+
});
|
|
6336
|
+
} catch {
|
|
6337
|
+
decision = "project";
|
|
6338
|
+
}
|
|
6339
|
+
if (decision === "never") return false;
|
|
6340
|
+
if (decision === "defer-to-browser" && isDeferrableSemanticNode(node) && supportsHTMLInCanvas()) {
|
|
6341
|
+
return false;
|
|
6342
|
+
}
|
|
6343
|
+
return true;
|
|
6344
|
+
}
|
|
5966
6345
|
/**
|
|
5967
6346
|
* Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
|
|
5968
6347
|
* deserve a shadow node.
|
|
@@ -6131,14 +6510,17 @@ var Scene = class _Scene {
|
|
|
6131
6510
|
if (e.target === capEl && typeof capEl.setPointerCapture === "function") {
|
|
6132
6511
|
capEl.setPointerCapture(e.pointerId);
|
|
6133
6512
|
}
|
|
6513
|
+
this.pinProjectionForGesture(node);
|
|
6134
6514
|
node.dispatchEvent(new VectoJSEvent("pointerdown", node, e));
|
|
6135
6515
|
});
|
|
6136
6516
|
el.addEventListener("pointerup", (e) => {
|
|
6137
6517
|
releasePointer(e);
|
|
6518
|
+
this.unpinProjectionForGesture(node);
|
|
6138
6519
|
node.dispatchEvent(new VectoJSEvent("pointerup", node, e));
|
|
6139
6520
|
});
|
|
6140
6521
|
el.addEventListener("pointercancel", (e) => {
|
|
6141
6522
|
releasePointer(e);
|
|
6523
|
+
this.unpinProjectionForGesture(node);
|
|
6142
6524
|
node.dispatchEvent(new VectoJSEvent("pointercancel", node, e));
|
|
6143
6525
|
});
|
|
6144
6526
|
el.addEventListener(
|
|
@@ -7099,6 +7481,197 @@ var Scene = class _Scene {
|
|
|
7099
7481
|
this.activePortalsPrevFrame = new Set(this.activePortalsThisFrame);
|
|
7100
7482
|
this.activePortalsThisFrame.clear();
|
|
7101
7483
|
}
|
|
7484
|
+
/**
|
|
7485
|
+
* Register a generic projection backend (RFC1 §5, RFC2 P1 CTX-0598).
|
|
7486
|
+
* Registration is idempotent per backend instance. Core drives
|
|
7487
|
+
* mount/update/unmount from the render walk; the backend owns all
|
|
7488
|
+
* medium-specific state.
|
|
7489
|
+
*/
|
|
7490
|
+
addProjectionBackend(backend) {
|
|
7491
|
+
if (!this.projectionBackends.includes(backend)) this.projectionBackends.push(backend);
|
|
7492
|
+
return this;
|
|
7493
|
+
}
|
|
7494
|
+
/**
|
|
7495
|
+
* Remove a previously registered projection backend by instance or
|
|
7496
|
+
* {@link ProjectionBackendKind | kind}. Removing does not unmount resident
|
|
7497
|
+
* nodes — unmount the backend first if teardown order matters.
|
|
7498
|
+
*/
|
|
7499
|
+
removeProjectionBackend(backend) {
|
|
7500
|
+
this.projectionBackends = this.projectionBackends.filter(
|
|
7501
|
+
(b) => typeof backend === "string" ? b.kind !== backend : b !== backend
|
|
7502
|
+
);
|
|
7503
|
+
return this;
|
|
7504
|
+
}
|
|
7505
|
+
/**
|
|
7506
|
+
* Scene-level projection capabilities (RFC4 §3 rule 2, CTX-0601).
|
|
7507
|
+
*
|
|
7508
|
+
* Plain-data feature detection in the shape of the proposed
|
|
7509
|
+
* `scene.inputCapabilities` (input-dispatch-contract-v2 §4): apps and
|
|
7510
|
+
* devtools query where a node _would_ materialize and why, without touching
|
|
7511
|
+
* any backend. Additive only — no existing surface moves.
|
|
7512
|
+
*/
|
|
7513
|
+
get projectionCapabilities() {
|
|
7514
|
+
return {
|
|
7515
|
+
hasDOM: typeof document !== "undefined",
|
|
7516
|
+
domBackendMounted: this.projectionBackends.some((b) => b.kind === "dom"),
|
|
7517
|
+
backends: this.projectionBackends.map((b) => b.kind),
|
|
7518
|
+
autoHysteresisFrames: this.projectionHysteresisFrames,
|
|
7519
|
+
autoDomBudget: this.projectionAutoDomBudget
|
|
7520
|
+
};
|
|
7521
|
+
}
|
|
7522
|
+
/**
|
|
7523
|
+
* Register (or replace) the Capability Matrix row for one `domKind`
|
|
7524
|
+
* (RFC4 §4, CTX-0601). Custom kinds start at the unknown-kind default
|
|
7525
|
+
* (`unsupported-kind` → canvas); registering a row is how a custom backend
|
|
7526
|
+
* kind opts into `'auto'` resolution and how tests drive oscillating costs.
|
|
7527
|
+
*/
|
|
7528
|
+
registerProjectionCapability(domKind, row) {
|
|
7529
|
+
this.projectionCapabilityOverrides.set(domKind, row);
|
|
7530
|
+
return this;
|
|
7531
|
+
}
|
|
7532
|
+
/** Matrix row for a node's `domKind` (override wins, unknown → canvas). */
|
|
7533
|
+
getProjectionCapability(node) {
|
|
7534
|
+
return this.projectionCapabilityOverrides.get(node.domKind) ?? DEFAULT_PROJECTION_CAPABILITIES[node.domKind] ?? UNKNOWN_PROJECTION_CAPABILITY;
|
|
7535
|
+
}
|
|
7536
|
+
/**
|
|
7537
|
+
* Pin a node's backend for the duration of an active gesture (RFC4 §5 +
|
|
7538
|
+
* input-dispatch-contract-v2 §4 gesture stickiness: no mid-gesture handoff).
|
|
7539
|
+
* The a11y-mirror pointerdown/up/cancel listeners maintain this; DOM-side
|
|
7540
|
+
* gestures are consulted through
|
|
7541
|
+
* {@link ProjectionBackend.hasActiveGesture | backend.hasActiveGesture}.
|
|
7542
|
+
* Refcounted so multi-pointer gestures on one node unpin exactly once.
|
|
7543
|
+
*/
|
|
7544
|
+
pinProjectionForGesture(node) {
|
|
7545
|
+
this.projectionGesturePins.set(node.id, (this.projectionGesturePins.get(node.id) ?? 0) + 1);
|
|
7546
|
+
}
|
|
7547
|
+
/** Release one gesture pin taken by {@link pinProjectionForGesture}. */
|
|
7548
|
+
unpinProjectionForGesture(node) {
|
|
7549
|
+
const count = (this.projectionGesturePins.get(node.id) ?? 0) - 1;
|
|
7550
|
+
if (count <= 0) this.projectionGesturePins.delete(node.id);
|
|
7551
|
+
else this.projectionGesturePins.set(node.id, count);
|
|
7552
|
+
}
|
|
7553
|
+
/** Whether `node` currently owns an active gesture on a core mirror. */
|
|
7554
|
+
isProjectionPinned(node) {
|
|
7555
|
+
return (this.projectionGesturePins.get(node.id) ?? 0) > 0;
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
7558
|
+
* Last negotiation for one node, if it was ever resolved on this scene.
|
|
7559
|
+
* Accepts the node or its id; returns the stored record (not a copy — treat
|
|
7560
|
+
* as read-only).
|
|
7561
|
+
*/
|
|
7562
|
+
getProjectionResolution(node) {
|
|
7563
|
+
return this.projectionResolutions.get(typeof node === "string" ? node : node.id);
|
|
7564
|
+
}
|
|
7565
|
+
/** Every node's last negotiation, in first-resolution order. */
|
|
7566
|
+
getProjectionResolutions() {
|
|
7567
|
+
return [...this.projectionResolutions.values()];
|
|
7568
|
+
}
|
|
7569
|
+
/**
|
|
7570
|
+
* Resolve one node's `domPolicy` to its effective backend (RFC4 §3, CTX-0601).
|
|
7571
|
+
*
|
|
7572
|
+
* Memoized per main frame (`currentFrame`, bumped once per authoritative
|
|
7573
|
+
* render): the render walk and the later a11y sync must agree within a
|
|
7574
|
+
* frame, or a node could lose its mirror the same frame it gains an element.
|
|
7575
|
+
* `'auto'` applies, in order: no-DOM short-circuit → pure negotiation →
|
|
7576
|
+
* gesture/focus hard bars (a pinned node keeps its current backend, never
|
|
7577
|
+
* flips mid-gesture, focus is preserved-or-moved never dropped to `body` per
|
|
7578
|
+
* input-dispatch-contract-v2 §4) → hysteresis stickiness → bulk budget.
|
|
7579
|
+
* Every outcome is recorded on the per-scene queryable surface
|
|
7580
|
+
* ({@link getProjectionResolutions}) with its reason — fallbacks are
|
|
7581
|
+
* reported, not silent (§3 rule 2).
|
|
7582
|
+
*/
|
|
7583
|
+
resolveProjectionFor(node) {
|
|
7584
|
+
const want = node.domPolicy;
|
|
7585
|
+
if (this.projectionBackends.length === 0 && want === "canvas") return "canvas";
|
|
7586
|
+
const memo = this.projectionHysteresis.get(node.id);
|
|
7587
|
+
if (memo && memo.lastFrame === this.currentFrame) return memo.resolved;
|
|
7588
|
+
const hasDOM = typeof document !== "undefined";
|
|
7589
|
+
const domBackendMounted = this.projectionBackends.some((b) => b.kind === "dom");
|
|
7590
|
+
const outcome = resolveProjection(want, this.getProjectionCapability(node), {
|
|
7591
|
+
hasDOM,
|
|
7592
|
+
domBackendMounted
|
|
7593
|
+
});
|
|
7594
|
+
let resolved = outcome.resolved;
|
|
7595
|
+
let reason = outcome.reason;
|
|
7596
|
+
if (want === "auto" && memo !== void 0) {
|
|
7597
|
+
if (resolved !== memo.resolved) {
|
|
7598
|
+
const gesturePinned = this.isProjectionPinned(node) || this.projectionBackends.some((b) => b.hasActiveGesture?.(node) === true);
|
|
7599
|
+
if (gesturePinned) {
|
|
7600
|
+
resolved = memo.resolved;
|
|
7601
|
+
reason = "active-gesture";
|
|
7602
|
+
} else if (this.ownsProjectionFocus(node)) {
|
|
7603
|
+
resolved = memo.resolved;
|
|
7604
|
+
reason = "focus-pinned";
|
|
7605
|
+
} else {
|
|
7606
|
+
const vote = hysteresisVote(
|
|
7607
|
+
memo.resolved,
|
|
7608
|
+
resolved,
|
|
7609
|
+
memo.consecutive,
|
|
7610
|
+
this.projectionHysteresisFrames
|
|
7611
|
+
);
|
|
7612
|
+
resolved = vote.resolved;
|
|
7613
|
+
memo.consecutive = vote.consecutive;
|
|
7614
|
+
reason = vote.flipped ? reason : "hysteresis";
|
|
7615
|
+
}
|
|
7616
|
+
} else {
|
|
7617
|
+
memo.consecutive = 0;
|
|
7618
|
+
}
|
|
7619
|
+
}
|
|
7620
|
+
if (want === "auto" && resolved === "dom") {
|
|
7621
|
+
if (this.projectionBudgetFrame !== this.currentFrame) {
|
|
7622
|
+
this.projectionBudgetFrame = this.currentFrame;
|
|
7623
|
+
this.projectionDomAutoCount = 0;
|
|
7624
|
+
}
|
|
7625
|
+
this.projectionDomAutoCount += 1;
|
|
7626
|
+
if (this.projectionDomAutoCount > this.projectionAutoDomBudget) {
|
|
7627
|
+
resolved = "canvas";
|
|
7628
|
+
reason = "bulk-budget";
|
|
7629
|
+
}
|
|
7630
|
+
}
|
|
7631
|
+
this.projectionHysteresis.set(node.id, {
|
|
7632
|
+
resolved,
|
|
7633
|
+
consecutive: memo?.consecutive ?? 0,
|
|
7634
|
+
lastFrame: this.currentFrame
|
|
7635
|
+
});
|
|
7636
|
+
const prev = this.projectionResolutions.get(node.id);
|
|
7637
|
+
if (!prev || prev.want !== want || prev.resolved !== resolved || prev.reason !== reason) {
|
|
7638
|
+
this.projectionResolutions.set(node.id, { nodeId: node.id, want, resolved, reason });
|
|
7639
|
+
}
|
|
7640
|
+
return resolved;
|
|
7641
|
+
}
|
|
7642
|
+
/**
|
|
7643
|
+
* Whether `node`'s a11y mirror currently owns browser focus (RFC4 §5 focus
|
|
7644
|
+
* rule, input-dispatch-contract-v2 §4: focus stays DOM-based; flipping
|
|
7645
|
+
* `projection` must preserve or deliberately move focus, never drop it to
|
|
7646
|
+
* `body`). A focused node keeps its backend until blur — the actual move, if
|
|
7647
|
+
* any, goes through the existing sentinels (`preserveFocusOnRemoval`, the
|
|
7648
|
+
* DOM backend's own fallback), never a bare removal.
|
|
7649
|
+
*/
|
|
7650
|
+
ownsProjectionFocus(node) {
|
|
7651
|
+
if (typeof document === "undefined") return false;
|
|
7652
|
+
const mirror = this.a11yElements.get(node.id);
|
|
7653
|
+
if (!mirror || mirror !== this.focusedA11yElement) return false;
|
|
7654
|
+
return document.activeElement === mirror;
|
|
7655
|
+
}
|
|
7656
|
+
/**
|
|
7657
|
+
* RFC2 P1 (CTX-0598): unmount DOM-projection residents the walk did not see
|
|
7658
|
+
* this frame (viewport-culled, removed without `remove()`, policy-flipped).
|
|
7659
|
+
* Mirrors {@link reconcilePortals} above; unmount is idempotent per the
|
|
7660
|
+
* `ProjectionBackend` contract so notifying every backend is safe.
|
|
7661
|
+
*/
|
|
7662
|
+
pruneProjectionBackends() {
|
|
7663
|
+
for (const id of this.domSeenPrevFrame) {
|
|
7664
|
+
if (!this.domSeenThisFrame.has(id)) {
|
|
7665
|
+
const node = this.domSeenNodes.get(id);
|
|
7666
|
+
if (node) {
|
|
7667
|
+
for (const backend of this.projectionBackends) backend.unmount(node);
|
|
7668
|
+
this.domSeenNodes.delete(id);
|
|
7669
|
+
}
|
|
7670
|
+
}
|
|
7671
|
+
}
|
|
7672
|
+
this.domSeenPrevFrame = new Set(this.domSeenThisFrame);
|
|
7673
|
+
this.domSeenThisFrame.clear();
|
|
7674
|
+
}
|
|
7102
7675
|
// --- domain: render-scheduler — the loop and the render walk ---
|
|
7103
7676
|
/**
|
|
7104
7677
|
* The frame-rate cap actually in effect: the explicit {@link maxFPS}, further
|
|
@@ -7176,6 +7749,12 @@ var Scene = class _Scene {
|
|
|
7176
7749
|
/**
|
|
7177
7750
|
* Render the entire scene graph onto the specified renderer.
|
|
7178
7751
|
*
|
|
7752
|
+
* This is the `CanvasProjection` row of {@link ProjectionBackend}:
|
|
7753
|
+
* the per-frame materialization of scene semantics into pixels through the
|
|
7754
|
+
* backend-agnostic `IRenderer` contract. (The `A11yProjection` /
|
|
7755
|
+
* `ContentProjection` rows run on the same cadence via {@link Scene.syncA11y}
|
|
7756
|
+
* below; the `DOMProjection` row does not exist yet — RFC2/CTX-0598.)
|
|
7757
|
+
*
|
|
7179
7758
|
* Main-frame causal order is a correctness contract:
|
|
7180
7759
|
*
|
|
7181
7760
|
* 1. Browser/input callbacks finish before the scheduled frame begins.
|
|
@@ -7468,6 +8047,16 @@ var Scene = class _Scene {
|
|
|
7468
8047
|
visible = maxX >= 0 && minX <= vw && maxY >= 0 && minY <= vh;
|
|
7469
8048
|
}
|
|
7470
8049
|
if (!visible && node.children.length === 0) return;
|
|
8050
|
+
if (isMainRenderer && this.projectionBackends.length > 0 && this.resolveProjectionFor(node) === "dom" && visible) {
|
|
8051
|
+
for (const backend of this.projectionBackends) {
|
|
8052
|
+
backend.update(node, { a, b, c, d, e: te, f: tf });
|
|
8053
|
+
}
|
|
8054
|
+
if (node.domResident) {
|
|
8055
|
+
this.domSeenThisFrame.add(node.id);
|
|
8056
|
+
this.domSeenNodes.set(node.id, node);
|
|
8057
|
+
if (node.children.length === 0) return;
|
|
8058
|
+
}
|
|
8059
|
+
}
|
|
7471
8060
|
if (node.children.length === 0 && node.scaleX === node.scaleY) {
|
|
7472
8061
|
const bc = node.getBatchCircle();
|
|
7473
8062
|
if (bc) {
|
|
@@ -7597,6 +8186,7 @@ var Scene = class _Scene {
|
|
|
7597
8186
|
this.frameHadAnimation = walkHadAnimation;
|
|
7598
8187
|
this.frameHadInteractive = walkHadInteractive;
|
|
7599
8188
|
this.reconcilePortals();
|
|
8189
|
+
this.pruneProjectionBackends();
|
|
7600
8190
|
}
|
|
7601
8191
|
const flushTiming = this.phases.userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.flush) : null;
|
|
7602
8192
|
const flushT0 = this.phases.enabled ? performance.now() : 0;
|
|
@@ -8033,6 +8623,238 @@ var TextEntity = class extends Entity {
|
|
|
8033
8623
|
}
|
|
8034
8624
|
};
|
|
8035
8625
|
|
|
8626
|
+
// src/components/VirtualizedSetAggregate.ts
|
|
8627
|
+
var VirtualizedSetAggregate = class extends Entity {
|
|
8628
|
+
_items;
|
|
8629
|
+
_label;
|
|
8630
|
+
_role;
|
|
8631
|
+
_itemRole;
|
|
8632
|
+
_rowHeight;
|
|
8633
|
+
_visibleCapacity;
|
|
8634
|
+
_onActivate;
|
|
8635
|
+
/** First item index currently bound to the pool (the scroll window). */
|
|
8636
|
+
_visibleStart = 0;
|
|
8637
|
+
/** Item id owning the roving tab stop / keyboard focus. */
|
|
8638
|
+
_activeId = null;
|
|
8639
|
+
_selectedId = null;
|
|
8640
|
+
/** One focusable hotspot per visible slot; re-bound, never rebuilt per item. */
|
|
8641
|
+
_hotspots = [];
|
|
8642
|
+
constructor(opts = {}) {
|
|
8643
|
+
super();
|
|
8644
|
+
this._items = opts.items ?? [];
|
|
8645
|
+
this._label = opts.label ?? "Items";
|
|
8646
|
+
this._role = opts.role ?? "list";
|
|
8647
|
+
this._itemRole = opts.itemRole ?? "listitem";
|
|
8648
|
+
this._rowHeight = opts.rowHeight ?? 28;
|
|
8649
|
+
this._visibleCapacity = opts.visibleCapacity;
|
|
8650
|
+
this._onActivate = opts.onActivate;
|
|
8651
|
+
this.width = opts.width ?? 0;
|
|
8652
|
+
this.height = opts.height ?? 0;
|
|
8653
|
+
this.interactive = true;
|
|
8654
|
+
this._syncHotspots();
|
|
8655
|
+
}
|
|
8656
|
+
/** Full item count — the number the container label states. */
|
|
8657
|
+
get itemCount() {
|
|
8658
|
+
return this._items.length;
|
|
8659
|
+
}
|
|
8660
|
+
/** Live pool size (bounded by the visible capacity, never by the item count). */
|
|
8661
|
+
get poolSize() {
|
|
8662
|
+
return this._hotspots.length;
|
|
8663
|
+
}
|
|
8664
|
+
/** First item index currently bound to the pool. */
|
|
8665
|
+
get visibleStart() {
|
|
8666
|
+
return this._visibleStart;
|
|
8667
|
+
}
|
|
8668
|
+
getA11yAttributes() {
|
|
8669
|
+
return {
|
|
8670
|
+
role: this._role,
|
|
8671
|
+
label: `${this._label}, ${this._items.length} items`,
|
|
8672
|
+
pointerEvents: "none"
|
|
8673
|
+
};
|
|
8674
|
+
}
|
|
8675
|
+
render(_renderer) {
|
|
8676
|
+
}
|
|
8677
|
+
/** Axis-aligned hit-test against the container box (the `UIComponent` default). */
|
|
8678
|
+
isPointInside(globalX, globalY) {
|
|
8679
|
+
const local = this.worldToLocal(globalX, globalY);
|
|
8680
|
+
if (!local) return false;
|
|
8681
|
+
return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
|
|
8682
|
+
}
|
|
8683
|
+
/** Replace the item set; the pool re-binds and the label count follows. */
|
|
8684
|
+
setItems(items) {
|
|
8685
|
+
this._items = items;
|
|
8686
|
+
if (this._activeId !== null && !items.some((item) => item.id === this._activeId)) {
|
|
8687
|
+
this._activeId = null;
|
|
8688
|
+
}
|
|
8689
|
+
if (this._selectedId !== null && !items.some((item) => item.id === this._selectedId)) {
|
|
8690
|
+
this._selectedId = null;
|
|
8691
|
+
}
|
|
8692
|
+
this._visibleStart = Math.max(0, Math.min(this._visibleStart, Math.max(0, items.length - 1)));
|
|
8693
|
+
this._syncHotspots();
|
|
8694
|
+
this.scene?.markDirty();
|
|
8695
|
+
}
|
|
8696
|
+
/** Move the pool window to start at `start` (the scroll position). */
|
|
8697
|
+
setVisibleStart(start) {
|
|
8698
|
+
const clamped = Math.max(0, Math.min(start, Math.max(0, this._items.length - 1)));
|
|
8699
|
+
if (clamped === this._visibleStart) return;
|
|
8700
|
+
this._visibleStart = clamped;
|
|
8701
|
+
this._syncHotspots();
|
|
8702
|
+
this.scene?.markDirty();
|
|
8703
|
+
}
|
|
8704
|
+
/**
|
|
8705
|
+
* Keep one hotspot per visible slot, positioned over it. The pool is sized
|
|
8706
|
+
* to the viewport (capacity), each slot re-bound to whatever item currently
|
|
8707
|
+
* occupies it — the `Tree._syncHotspots` shape, minus tree structure.
|
|
8708
|
+
*/
|
|
8709
|
+
_syncHotspots() {
|
|
8710
|
+
const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
|
|
8711
|
+
const need = Math.max(0, Math.min(capacity, this._items.length - this._visibleStart));
|
|
8712
|
+
while (this._hotspots.length < need) {
|
|
8713
|
+
const hotspot = new AggregateItemHotspot(this);
|
|
8714
|
+
this._hotspots.push(hotspot);
|
|
8715
|
+
this.add(hotspot);
|
|
8716
|
+
}
|
|
8717
|
+
while (this._hotspots.length > need) {
|
|
8718
|
+
const hotspot = this._hotspots.pop();
|
|
8719
|
+
this.scene?.detachA11y(hotspot);
|
|
8720
|
+
this.remove(hotspot);
|
|
8721
|
+
}
|
|
8722
|
+
for (let slot = 0; slot < need; slot++) {
|
|
8723
|
+
const index = this._visibleStart + slot;
|
|
8724
|
+
const hotspot = this._hotspots[slot];
|
|
8725
|
+
hotspot.bind(index, this._items[index]);
|
|
8726
|
+
hotspot.x = 0;
|
|
8727
|
+
hotspot.y = slot * this._rowHeight;
|
|
8728
|
+
hotspot.width = this.width;
|
|
8729
|
+
hotspot.height = this._rowHeight;
|
|
8730
|
+
}
|
|
8731
|
+
}
|
|
8732
|
+
/** Whether `id` owns the roving tab stop: active, else selected, else first. */
|
|
8733
|
+
isTabStop(id) {
|
|
8734
|
+
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;
|
|
8735
|
+
return id === anchor;
|
|
8736
|
+
}
|
|
8737
|
+
isSelected(id) {
|
|
8738
|
+
return this._selectedId === id;
|
|
8739
|
+
}
|
|
8740
|
+
/** Item role the hotspots announce (kept on the parent: one source of truth). */
|
|
8741
|
+
get itemRole() {
|
|
8742
|
+
return this._itemRole;
|
|
8743
|
+
}
|
|
8744
|
+
/** Activate an item (pointer tap-through or Enter/Space): selects and notifies. */
|
|
8745
|
+
activateItem(id, focusIt = false) {
|
|
8746
|
+
const index = this._items.findIndex((item) => item.id === id);
|
|
8747
|
+
if (index === -1) return;
|
|
8748
|
+
this._activeId = id;
|
|
8749
|
+
this._selectedId = id;
|
|
8750
|
+
this._onActivate?.(this._items[index], index);
|
|
8751
|
+
if (focusIt) this.focusItem(id);
|
|
8752
|
+
this.scene?.markDirty();
|
|
8753
|
+
}
|
|
8754
|
+
/** Move keyboard focus to the hotspot currently bound to `id`, if any. */
|
|
8755
|
+
focusItem(id) {
|
|
8756
|
+
this._hotspots.find((hotspot) => hotspot.itemId === id)?.focus();
|
|
8757
|
+
}
|
|
8758
|
+
/**
|
|
8759
|
+
* Aggregate keyboard model: Up/Down move the active item (scrolling the pool
|
|
8760
|
+
* window so it stays bound), Home/End jump, Enter/Space activate. The active
|
|
8761
|
+
* item is focused as it moves so focus is never stranded on a re-bound slot.
|
|
8762
|
+
*/
|
|
8763
|
+
handleItemKey(e, id) {
|
|
8764
|
+
const index = this._items.findIndex((item) => item.id === id);
|
|
8765
|
+
if (index === -1) return;
|
|
8766
|
+
let next = -1;
|
|
8767
|
+
switch (e.key) {
|
|
8768
|
+
case "ArrowDown":
|
|
8769
|
+
next = Math.min(index + 1, this._items.length - 1);
|
|
8770
|
+
break;
|
|
8771
|
+
case "ArrowUp":
|
|
8772
|
+
next = Math.max(index - 1, 0);
|
|
8773
|
+
break;
|
|
8774
|
+
case "Home":
|
|
8775
|
+
next = 0;
|
|
8776
|
+
break;
|
|
8777
|
+
case "End":
|
|
8778
|
+
next = this._items.length - 1;
|
|
8779
|
+
break;
|
|
8780
|
+
case "Enter":
|
|
8781
|
+
case " ":
|
|
8782
|
+
e.preventDefault();
|
|
8783
|
+
this.activateItem(id, true);
|
|
8784
|
+
return;
|
|
8785
|
+
default:
|
|
8786
|
+
return;
|
|
8787
|
+
}
|
|
8788
|
+
e.preventDefault();
|
|
8789
|
+
this._activeId = this._items[next].id;
|
|
8790
|
+
this._ensureVisible(next);
|
|
8791
|
+
this.focusItem(this._activeId);
|
|
8792
|
+
this.scene?.markDirty();
|
|
8793
|
+
}
|
|
8794
|
+
/** Scroll the pool window just enough to keep `index` bound. */
|
|
8795
|
+
_ensureVisible(index) {
|
|
8796
|
+
const capacity = this._visibleCapacity ?? Math.max(1, Math.ceil(this.height / this._rowHeight));
|
|
8797
|
+
if (index < this._visibleStart) this.setVisibleStart(index);
|
|
8798
|
+
else if (index >= this._visibleStart + capacity) {
|
|
8799
|
+
this.setVisibleStart(index - capacity + 1);
|
|
8800
|
+
} else {
|
|
8801
|
+
this._syncHotspots();
|
|
8802
|
+
}
|
|
8803
|
+
}
|
|
8804
|
+
update(_dt, _time) {
|
|
8805
|
+
super.update(_dt, _time);
|
|
8806
|
+
this._syncHotspots();
|
|
8807
|
+
}
|
|
8808
|
+
};
|
|
8809
|
+
var AggregateItemHotspot = class extends Entity {
|
|
8810
|
+
constructor(aggregate) {
|
|
8811
|
+
super();
|
|
8812
|
+
this.aggregate = aggregate;
|
|
8813
|
+
this.interactive = true;
|
|
8814
|
+
this.on("click", () => this.aggregate.activateItem(this.itemId, true));
|
|
8815
|
+
this.on("keydown", (e) => this.aggregate.handleItemKey(e, this.itemId));
|
|
8816
|
+
}
|
|
8817
|
+
aggregate;
|
|
8818
|
+
itemId = "";
|
|
8819
|
+
_index = 0;
|
|
8820
|
+
_label = "";
|
|
8821
|
+
bind(index, item) {
|
|
8822
|
+
this._index = index;
|
|
8823
|
+
this.itemId = item.id;
|
|
8824
|
+
this._label = item.label;
|
|
8825
|
+
}
|
|
8826
|
+
/** The aggregate positions and sizes one pooled hotspot per visible slot. */
|
|
8827
|
+
getLayoutControlledProperties() {
|
|
8828
|
+
return ["x", "y", "width", "height"];
|
|
8829
|
+
}
|
|
8830
|
+
getA11yAttributes() {
|
|
8831
|
+
return {
|
|
8832
|
+
role: this.aggregate.itemRole,
|
|
8833
|
+
label: this._label,
|
|
8834
|
+
// 1-based position within the FULL set, not the pool: without these a
|
|
8835
|
+
// virtualized window announces the pool position as the set position.
|
|
8836
|
+
posInSet: this._index + 1,
|
|
8837
|
+
setSize: this.aggregate.itemCount,
|
|
8838
|
+
selected: this.aggregate.isSelected(this.itemId),
|
|
8839
|
+
// Roving tabindex: only the active item is a tab stop; arrows move within.
|
|
8840
|
+
tabIndex: this.aggregate.isTabStop(this.itemId) ? 0 : -1,
|
|
8841
|
+
// The owner keeps the pointer (canvas handling or an underlying
|
|
8842
|
+
// selectable-text mirror); this hotspot exists for semantics + keyboard
|
|
8843
|
+
// focus, so it opts out of hit-testing. Keyboard focus and
|
|
8844
|
+
// AT-synthesized `click` still work under `pointer-events:none`.
|
|
8845
|
+
pointerEvents: "none"
|
|
8846
|
+
};
|
|
8847
|
+
}
|
|
8848
|
+
render() {
|
|
8849
|
+
}
|
|
8850
|
+
/** Axis-aligned hit-test against the hotspot slot (the `UIComponent` default). */
|
|
8851
|
+
isPointInside(globalX, globalY) {
|
|
8852
|
+
const local = this.worldToLocal(globalX, globalY);
|
|
8853
|
+
if (!local) return false;
|
|
8854
|
+
return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
|
|
8855
|
+
}
|
|
8856
|
+
};
|
|
8857
|
+
|
|
8036
8858
|
// src/components/GridTextEntity.ts
|
|
8037
8859
|
var GridTextEntity = class extends Entity {
|
|
8038
8860
|
fontSize;
|
|
@@ -8728,6 +9550,8 @@ export {
|
|
|
8728
9550
|
Circle,
|
|
8729
9551
|
ComputeParticleEntity,
|
|
8730
9552
|
DEFAULT_CONTENT_SEMANTIC_BUDGET,
|
|
9553
|
+
DEFAULT_PROJECTION_CAPABILITIES,
|
|
9554
|
+
DEFAULT_SEMANTIC_PROJECTION_POLICY,
|
|
8731
9555
|
DOMPortalEntity,
|
|
8732
9556
|
Entity,
|
|
8733
9557
|
GlyphRasterAtlas,
|
|
@@ -8744,6 +9568,8 @@ export {
|
|
|
8744
9568
|
PARTICLE_OFFSET_VELOCITY_X,
|
|
8745
9569
|
PARTICLE_OFFSET_VELOCITY_Y,
|
|
8746
9570
|
PARTICLE_STRIDE_FLOATS,
|
|
9571
|
+
PROJECTION_AUTO_DOM_BUDGET,
|
|
9572
|
+
PROJECTION_AUTO_HYSTERESIS_FRAMES,
|
|
8747
9573
|
REDUCED_MOTION_FPS,
|
|
8748
9574
|
Rect,
|
|
8749
9575
|
SCENE_OPTION_KEYS,
|
|
@@ -8753,14 +9579,20 @@ export {
|
|
|
8753
9579
|
SplineEntity,
|
|
8754
9580
|
TextEntity,
|
|
8755
9581
|
TextRasterCache,
|
|
9582
|
+
UNKNOWN_PROJECTION_CAPABILITY,
|
|
8756
9583
|
VECTO_USER_TIMING,
|
|
8757
9584
|
VectoJSEvent,
|
|
9585
|
+
VirtualizedSetAggregate,
|
|
8758
9586
|
WebGPUParticleSystemManager,
|
|
8759
9587
|
beginVectoUserTiming,
|
|
8760
9588
|
contentLineInHint,
|
|
8761
9589
|
createWebGLPointRenderer,
|
|
9590
|
+
describeHTMLInCanvasSupport,
|
|
8762
9591
|
endVectoUserTiming,
|
|
9592
|
+
getSemanticProjectionCapabilities,
|
|
9593
|
+
hysteresisVote,
|
|
8763
9594
|
installRendererDevTraps,
|
|
9595
|
+
isDeferrableSemanticNode,
|
|
8764
9596
|
isRendererDevMode,
|
|
8765
9597
|
isSafeUrl,
|
|
8766
9598
|
loadSpline,
|
|
@@ -8769,6 +9601,8 @@ export {
|
|
|
8769
9601
|
ownsKeyboard,
|
|
8770
9602
|
parseColorToRGBA,
|
|
8771
9603
|
polySegmentToBezier,
|
|
9604
|
+
resolveProjection,
|
|
8772
9605
|
sanitizeUrl,
|
|
8773
|
-
setRendererDevMode
|
|
9606
|
+
setRendererDevMode,
|
|
9607
|
+
supportsHTMLInCanvas
|
|
8774
9608
|
};
|