@vectojs/core 1.31.0 → 1.32.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/index.js +265 -146
- package/dist/index.mjs +144 -25
- package/dist/tree/Scene.d.ts +140 -5
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1379,6 +1379,7 @@ var SCENE_OPTION_KEYS = [
|
|
|
1379
1379
|
"autoThrottle",
|
|
1380
1380
|
"contentProjection",
|
|
1381
1381
|
"contentProjectionMargin",
|
|
1382
|
+
"contentSemanticBudget",
|
|
1382
1383
|
"contentSemanticMargin",
|
|
1383
1384
|
"debugA11y",
|
|
1384
1385
|
"disableWindowResize",
|
|
@@ -1432,6 +1433,7 @@ function parseInlinePx(value) {
|
|
|
1432
1433
|
const n = parseFloat(value);
|
|
1433
1434
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
1434
1435
|
}
|
|
1436
|
+
var DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
|
|
1435
1437
|
function projectionGridLineWindow(grid, projectionLines, band) {
|
|
1436
1438
|
const count = grid.lines.length;
|
|
1437
1439
|
const all = { start: 0, end: count, gated: false };
|
|
@@ -2007,6 +2009,41 @@ var Scene = class _Scene {
|
|
|
2007
2009
|
// any projected DOM. `undefined` → falls back to contentProjectionMargin, so
|
|
2008
2010
|
// the default keeps one gate. `Infinity` = every block keeps resident text.
|
|
2009
2011
|
contentSemanticMargin = void 0;
|
|
2012
|
+
// How many coarse-tier blocks may be MATERIALIZED per sync, spreading the
|
|
2013
|
+
// resident tier's document-open cost across frames. `Infinity` = one
|
|
2014
|
+
// synchronous pass.
|
|
2015
|
+
contentSemanticBudget = DEFAULT_CONTENT_SEMANTIC_BUDGET;
|
|
2016
|
+
// Remaining materializations in the CURRENT sync. Reset at the start of each
|
|
2017
|
+
// a11y walk; decremented per coarse block that creates its element.
|
|
2018
|
+
contentSemanticBudgetLeft = 0;
|
|
2019
|
+
// Set when a sync deferred at least one block, so the scene knows to keep
|
|
2020
|
+
// drawing frames until the resident tier is complete. Without it a static
|
|
2021
|
+
// scene in `onDemand` mode would stop rendering with the document half
|
|
2022
|
+
// materialized and never finish.
|
|
2023
|
+
contentSemanticDeferred = false;
|
|
2024
|
+
/**
|
|
2025
|
+
* Per-sync memo of "does the document hold a selection at all".
|
|
2026
|
+
*
|
|
2027
|
+
* Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
|
|
2028
|
+
* `isCollapsed`) forces a synchronous layout, because Blink validates the
|
|
2029
|
+
* selection against current box geometry before answering. Measured in real
|
|
2030
|
+
* Chrome against a 1000-carrier subtree with layout dirtied between reads:
|
|
2031
|
+
* `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
|
|
2032
|
+
* all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
|
|
2033
|
+
* mutating without reading. So there is no cheap property to probe with; the
|
|
2034
|
+
* only way to avoid the layout is to not touch the object at all.
|
|
2035
|
+
*
|
|
2036
|
+
* Materializing a block rebuilds its carriers, which asks whether the rebuild
|
|
2037
|
+
* would destroy a selection. Once per block, that read cost a forced layout
|
|
2038
|
+
* over the whole (and growing) projection subtree, which is what made
|
|
2039
|
+
* per-block cost rise with resident count: profiled at 1973 forced layouts
|
|
2040
|
+
* totalling 633ms of an 847ms 1000-block drain (75%).
|
|
2041
|
+
*
|
|
2042
|
+
* A selection is a single document-wide object and a sync walk cannot yield to
|
|
2043
|
+
* the user, so its presence cannot change mid-walk. Resolving it once per walk
|
|
2044
|
+
* turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
|
|
2045
|
+
*/
|
|
2046
|
+
contentSelectionPresentThisSync = null;
|
|
2010
2047
|
/**
|
|
2011
2048
|
* True while a text-selection drag that started on a projection's blank
|
|
2012
2049
|
* region (no text node under the press) is being driven manually — the
|
|
@@ -2074,6 +2111,16 @@ var Scene = class _Scene {
|
|
|
2074
2111
|
* see {@link sortNormalElementsVisually}.
|
|
2075
2112
|
*/
|
|
2076
2113
|
a11yOrderContainers = /* @__PURE__ */ new Set();
|
|
2114
|
+
/**
|
|
2115
|
+
* Nearest `clipChildren` ancestor per ordered element — its *region* — reused
|
|
2116
|
+
* per pass. Written by `enforceA11yDomOrder`'s collect walk, which already has
|
|
2117
|
+
* the entity in hand, so a region costs one comparison per node rather than an
|
|
2118
|
+
* ancestor walk per element.
|
|
2119
|
+
*
|
|
2120
|
+
* Absent means the element sits under no clipping ancestor and belongs to the
|
|
2121
|
+
* implicit root region. See {@link sortNormalElementsVisually}.
|
|
2122
|
+
*/
|
|
2123
|
+
a11yOrderRegions = /* @__PURE__ */ new Map();
|
|
2077
2124
|
activePortalsThisFrame = /* @__PURE__ */ new Set();
|
|
2078
2125
|
activePortalsPrevFrame = /* @__PURE__ */ new Set();
|
|
2079
2126
|
portalEntities = /* @__PURE__ */ new Map();
|
|
@@ -3043,6 +3090,7 @@ var Scene = class _Scene {
|
|
|
3043
3090
|
this.contentProjectionEnabled = options.contentProjection ?? true;
|
|
3044
3091
|
this.contentProjectionMargin = options.contentProjectionMargin;
|
|
3045
3092
|
this.contentSemanticMargin = options.contentSemanticMargin;
|
|
3093
|
+
this.contentSemanticBudget = options.contentSemanticBudget ?? DEFAULT_CONTENT_SEMANTIC_BUDGET;
|
|
3046
3094
|
this.readingDirection = options.readingDirection ?? "ltr";
|
|
3047
3095
|
this.renderMode = options.renderMode ?? "always";
|
|
3048
3096
|
this._devActive = _Scene._devModeDetected();
|
|
@@ -3324,12 +3372,31 @@ var Scene = class _Scene {
|
|
|
3324
3372
|
}
|
|
3325
3373
|
return null;
|
|
3326
3374
|
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Does the document hold a selection right now, memoized for this sync walk?
|
|
3377
|
+
*
|
|
3378
|
+
* Pays one forced layout per walk instead of one per rebuilt element — see
|
|
3379
|
+
* {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
|
|
3380
|
+
* answer is `false` no element can own a selection, so every per-element
|
|
3381
|
+
* ownership test can be skipped without touching the object.
|
|
3382
|
+
*/
|
|
3383
|
+
contentSelectionPresent() {
|
|
3384
|
+
if (this.contentSelectionPresentThisSync !== null) {
|
|
3385
|
+
return this.contentSelectionPresentThisSync;
|
|
3386
|
+
}
|
|
3387
|
+
const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
3388
|
+
const present = !!selection && (!!selection.anchorNode || !!selection.focusNode);
|
|
3389
|
+
this.contentSelectionPresentThisSync = present;
|
|
3390
|
+
return present;
|
|
3391
|
+
}
|
|
3327
3392
|
releaseContentSelectionForRebuild(el) {
|
|
3393
|
+
if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) return;
|
|
3328
3394
|
const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
3329
3395
|
const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
|
|
3330
3396
|
if (!ownsSelection) return;
|
|
3331
3397
|
this.endContentSelectionDrag();
|
|
3332
3398
|
selection?.removeAllRanges();
|
|
3399
|
+
this.contentSelectionPresentThisSync = null;
|
|
3333
3400
|
}
|
|
3334
3401
|
/**
|
|
3335
3402
|
* Rebuild a content-projection element's DOM (`rebuild`) while preserving a
|
|
@@ -3347,6 +3414,10 @@ var Scene = class _Scene {
|
|
|
3347
3414
|
* restore against).
|
|
3348
3415
|
*/
|
|
3349
3416
|
preserveContentSelectionAcrossRebuild(el, rebuild) {
|
|
3417
|
+
if (!this.contentSelectionAnchor && !this.contentSelectionPresent()) {
|
|
3418
|
+
rebuild();
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3350
3421
|
const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
3351
3422
|
const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
|
|
3352
3423
|
if (!owns || !selection.anchorNode || !selection.focusNode) {
|
|
@@ -4042,6 +4113,11 @@ var Scene = class _Scene {
|
|
|
4042
4113
|
}
|
|
4043
4114
|
syncA11y(node, container = null) {
|
|
4044
4115
|
if (!this.a11yRoot) return;
|
|
4116
|
+
if (node === this.root) {
|
|
4117
|
+
this.contentSemanticBudgetLeft = this.contentSemanticBudget;
|
|
4118
|
+
this.contentSemanticDeferred = false;
|
|
4119
|
+
this.contentSelectionPresentThisSync = null;
|
|
4120
|
+
}
|
|
4045
4121
|
if (node.isDOMPortal) {
|
|
4046
4122
|
return;
|
|
4047
4123
|
}
|
|
@@ -4460,7 +4536,9 @@ var Scene = class _Scene {
|
|
|
4460
4536
|
* transparent DOM node positioned over the drawn glyphs. Runs on the a11y
|
|
4461
4537
|
* sync cadence; all writes are dirty-checked. Off-viewport projections are
|
|
4462
4538
|
* hidden (`display: none`) so text-heavy scenes only materialize what is
|
|
4463
|
-
* visible to the browser's text machinery anyway
|
|
4539
|
+
* visible to the browser's text machinery anyway — except in the coarse
|
|
4540
|
+
* (resident) tier, which stays displayed because hiding it would make its text
|
|
4541
|
+
* unfindable and remove it from the accessibility tree, defeating the tier.
|
|
4464
4542
|
*/
|
|
4465
4543
|
/**
|
|
4466
4544
|
* Whether `node`'s world-space box, expanded by `margin` px on every side,
|
|
@@ -4469,8 +4547,15 @@ var Scene = class _Scene {
|
|
|
4469
4547
|
* at `margin = contentProjectionMargin`) and for the exact `display:none`
|
|
4470
4548
|
* visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
|
|
4471
4549
|
* culling and always count as visible, matching the legacy behavior.
|
|
4550
|
+
*
|
|
4551
|
+
* `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
|
|
4552
|
+
* question "does this box overlap the viewport at all". The coarse content tier
|
|
4553
|
+
* needs the two apart: text that is merely off-viewport is clipped by
|
|
4554
|
+
* `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
|
|
4555
|
+
* rejected by an ancestor clip box that itself overlaps the viewport would sit
|
|
4556
|
+
* transparently on top of whatever is really drawn there.
|
|
4472
4557
|
*/
|
|
4473
|
-
projectionBoxVisible(node, tf, margin) {
|
|
4558
|
+
projectionBoxVisible(node, tf, margin, viewportOnly = false) {
|
|
4474
4559
|
if (!(node.width > 0 && node.height > 0)) return true;
|
|
4475
4560
|
const { a, b, c, d, e, f } = tf;
|
|
4476
4561
|
const worldCorners = [];
|
|
@@ -4492,6 +4577,7 @@ var Scene = class _Scene {
|
|
|
4492
4577
|
if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
|
|
4493
4578
|
return false;
|
|
4494
4579
|
}
|
|
4580
|
+
if (viewportOnly) return true;
|
|
4495
4581
|
for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
|
|
4496
4582
|
if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
|
|
4497
4583
|
let localMinX = Infinity;
|
|
@@ -4594,6 +4680,10 @@ var Scene = class _Scene {
|
|
|
4594
4680
|
return;
|
|
4595
4681
|
}
|
|
4596
4682
|
}
|
|
4683
|
+
if (tier === "coarse" && !el && this.contentSemanticBudgetLeft <= 0) {
|
|
4684
|
+
this.contentSemanticDeferred = true;
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4597
4687
|
const projection = node.getContentProjection(
|
|
4598
4688
|
lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
|
|
4599
4689
|
);
|
|
@@ -4602,6 +4692,7 @@ var Scene = class _Scene {
|
|
|
4602
4692
|
return;
|
|
4603
4693
|
}
|
|
4604
4694
|
if (!el) {
|
|
4695
|
+
if (tier === "coarse") this.contentSemanticBudgetLeft--;
|
|
4605
4696
|
el = document.createElement("div");
|
|
4606
4697
|
el.setAttribute("data-vecto-content", node.id);
|
|
4607
4698
|
const s = el.style;
|
|
@@ -4736,7 +4827,8 @@ var Scene = class _Scene {
|
|
|
4736
4827
|
if (node.width > 0) el.style.width = `${node.width}px`;
|
|
4737
4828
|
if (node.height > 0) el.style.height = `${node.height}px`;
|
|
4738
4829
|
el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
|
|
4739
|
-
const
|
|
4830
|
+
const residentTier = semanticMargin > interactionMargin;
|
|
4831
|
+
const display = visible || residentTier && !this.projectionBoxVisible(node, worldTf, 0, true) ? "" : "none";
|
|
4740
4832
|
if (el.style.display !== display) el.style.display = display;
|
|
4741
4833
|
if (epoch !== null) {
|
|
4742
4834
|
const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
|
|
@@ -5087,12 +5179,14 @@ var Scene = class _Scene {
|
|
|
5087
5179
|
this.fullViewportElements.length = 0;
|
|
5088
5180
|
this.normalElements.length = 0;
|
|
5089
5181
|
this.activeIds.clear();
|
|
5090
|
-
|
|
5182
|
+
this.a11yOrderRegions.clear();
|
|
5183
|
+
const collect = (node, region) => {
|
|
5091
5184
|
if (node.isDOMPortal) return;
|
|
5092
5185
|
const contentEl = this.contentElements.get(node.id);
|
|
5093
5186
|
if (contentEl) {
|
|
5094
5187
|
if (node.a11yFullViewport) this.fullViewportElements.push(contentEl);
|
|
5095
5188
|
else this.normalElements.push(contentEl);
|
|
5189
|
+
if (region) this.a11yOrderRegions.set(contentEl, region);
|
|
5096
5190
|
}
|
|
5097
5191
|
if (this.shouldProjectA11y(node)) {
|
|
5098
5192
|
const el = this.a11yElements.get(node.id);
|
|
@@ -5100,14 +5194,16 @@ var Scene = class _Scene {
|
|
|
5100
5194
|
this.activeIds.add(node.id);
|
|
5101
5195
|
if (node.a11yFullViewport) this.fullViewportElements.push(el);
|
|
5102
5196
|
else this.normalElements.push(el);
|
|
5197
|
+
if (region) this.a11yOrderRegions.set(el, region);
|
|
5103
5198
|
}
|
|
5104
5199
|
}
|
|
5105
|
-
|
|
5200
|
+
const childRegion = node.clipChildren && node.width > 0 && node.height > 0 ? node : region;
|
|
5201
|
+
for (const child of node.children) collect(child, childRegion);
|
|
5106
5202
|
if (node === this.root) {
|
|
5107
|
-
for (const overlay of this.overlayRoot.children) collect(overlay);
|
|
5203
|
+
for (const overlay of this.overlayRoot.children) collect(overlay, null);
|
|
5108
5204
|
}
|
|
5109
5205
|
};
|
|
5110
|
-
collect(this.root);
|
|
5206
|
+
collect(this.root, null);
|
|
5111
5207
|
let elementsPruned = false;
|
|
5112
5208
|
for (const [id, el] of this.a11yElements.entries()) {
|
|
5113
5209
|
if (!this.activeIds.has(id)) {
|
|
@@ -5169,6 +5265,18 @@ var Scene = class _Scene {
|
|
|
5169
5265
|
* parents, which no `insertBefore` ever acts on. Normalizing everything back
|
|
5170
5266
|
* to world coordinates here would cost a transform per element per frame to
|
|
5171
5267
|
* change nothing observable.
|
|
5268
|
+
*
|
|
5269
|
+
* Banding runs **per region** — per nearest `clipChildren` ancestor, recorded
|
|
5270
|
+
* by {@link enforceA11yDomOrder}'s collect walk — rather than once over the
|
|
5271
|
+
* whole scene. Purely visual banding is right for a screen reader but wrong
|
|
5272
|
+
* for selection: a DOM `Selection` covers everything between anchor and focus
|
|
5273
|
+
* in DOM order, so under one global banding a vertical drag through a
|
|
5274
|
+
* transcript also swallowed a sidebar whose headings happened to fall in the
|
|
5275
|
+
* same rows. Regions are laid out side by side, so ordering region-major keeps
|
|
5276
|
+
* each one a contiguous DOM run and a drag stays inside it, while reading
|
|
5277
|
+
* order *within* a region is unchanged. Regions are emitted in the order their
|
|
5278
|
+
* clipper is first reached by the depth-first walk, so a screen reader still
|
|
5279
|
+
* meets them in the author's declared order.
|
|
5172
5280
|
*/
|
|
5173
5281
|
sortNormalElementsVisually() {
|
|
5174
5282
|
const els = this.normalElements;
|
|
@@ -5196,30 +5304,40 @@ var Scene = class _Scene {
|
|
|
5196
5304
|
}
|
|
5197
5305
|
return { top, left };
|
|
5198
5306
|
};
|
|
5199
|
-
const
|
|
5307
|
+
const decorated = els.map((el, i) => {
|
|
5200
5308
|
const { top, left } = absolute(el);
|
|
5201
5309
|
return { el, i, top, left, container: containers.has(el) };
|
|
5202
5310
|
});
|
|
5203
|
-
|
|
5311
|
+
const regions = this.a11yOrderRegions;
|
|
5312
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
5313
|
+
for (const d of decorated) {
|
|
5314
|
+
const key = regions.get(d.el) ?? null;
|
|
5315
|
+
const bucket = buckets.get(key);
|
|
5316
|
+
if (bucket) bucket.push(d);
|
|
5317
|
+
else buckets.set(key, [d]);
|
|
5318
|
+
}
|
|
5204
5319
|
const bandBottom = (r) => r.top + (r.container ? 4 : heightOf(r.el));
|
|
5205
5320
|
const sorted = [];
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5321
|
+
for (const order of buckets.values()) {
|
|
5322
|
+
order.sort((p, q) => p.top - q.top || p.i - q.i);
|
|
5323
|
+
let rowStart = 0;
|
|
5324
|
+
let rowBottom = order.length ? bandBottom(order[0]) : 0;
|
|
5325
|
+
const flushRow = (end) => {
|
|
5326
|
+
const row = order.slice(rowStart, end);
|
|
5327
|
+
row.sort((p, q) => (rtl ? q.left - p.left : p.left - q.left) || p.i - q.i);
|
|
5328
|
+
for (const r of row) sorted.push(r.el);
|
|
5329
|
+
};
|
|
5330
|
+
for (let k = 1; k < order.length; k++) {
|
|
5331
|
+
if (order[k].top < rowBottom) {
|
|
5332
|
+
rowBottom = Math.max(rowBottom, bandBottom(order[k]));
|
|
5333
|
+
} else {
|
|
5334
|
+
flushRow(k);
|
|
5335
|
+
rowStart = k;
|
|
5336
|
+
rowBottom = bandBottom(order[k]);
|
|
5337
|
+
}
|
|
5220
5338
|
}
|
|
5339
|
+
flushRow(order.length);
|
|
5221
5340
|
}
|
|
5222
|
-
flushRow(order.length);
|
|
5223
5341
|
for (let i = 0; i < sorted.length; i++) els[i] = sorted[i];
|
|
5224
5342
|
}
|
|
5225
5343
|
/** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
|
|
@@ -5379,7 +5497,7 @@ var Scene = class _Scene {
|
|
|
5379
5497
|
if (!this.isRunning) return;
|
|
5380
5498
|
if (!this._canvasOnScreen) return;
|
|
5381
5499
|
let cap = this.effectiveMaxFPS();
|
|
5382
|
-
const isIdle = !this.dirty && !this.frameHadAnimation;
|
|
5500
|
+
const isIdle = !this.dirty && !this.frameHadAnimation && !this.contentSemanticDeferred;
|
|
5383
5501
|
if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
|
|
5384
5502
|
cap = Math.min(cap, 2);
|
|
5385
5503
|
}
|
|
@@ -6910,6 +7028,7 @@ export {
|
|
|
6910
7028
|
CanvasRenderer,
|
|
6911
7029
|
Circle,
|
|
6912
7030
|
ComputeParticleEntity,
|
|
7031
|
+
DEFAULT_CONTENT_SEMANTIC_BUDGET,
|
|
6913
7032
|
DOMPortalEntity,
|
|
6914
7033
|
Entity,
|
|
6915
7034
|
GlyphRasterAtlas,
|
package/dist/tree/Scene.d.ts
CHANGED
|
@@ -220,15 +220,45 @@ export interface SceneOptions {
|
|
|
220
220
|
* unwindowed carrier band, not from resident text.
|
|
221
221
|
*
|
|
222
222
|
* Note the one-time cost. A resident tier materializes one element per block
|
|
223
|
-
* on the first sync — measured
|
|
224
|
-
*
|
|
223
|
+
* on the first sync — measured unbudgeted at 21.3ms for 1000 blocks and 139.5ms
|
|
224
|
+
* for 10000 on Chrome — as one synchronous block. Steady state is cheap
|
|
225
225
|
* (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
|
|
226
|
-
* document-open stall, not a per-frame cost.
|
|
226
|
+
* document-open stall, not a per-frame cost. That stall is what
|
|
227
|
+
* {@link SceneOptions.contentSemanticBudget} spreads across frames.
|
|
227
228
|
*
|
|
228
229
|
* Default: whatever `contentProjectionMargin` resolves to, so omitting this
|
|
229
230
|
* leaves behaviour unchanged.
|
|
230
231
|
*/
|
|
231
232
|
contentSemanticMargin?: number;
|
|
233
|
+
/**
|
|
234
|
+
* How many resident (coarse-tier) blocks may be materialized in **one** sync,
|
|
235
|
+
* bounding the document-open stall a wide {@link
|
|
236
|
+
* SceneOptions.contentSemanticMargin} otherwise pays all at once.
|
|
237
|
+
*
|
|
238
|
+
* The cost of a resident tier is per node **created**, not per node held: 10000
|
|
239
|
+
* resident blocks cost ~3.0 ms/sync at steady state, while creating them costs
|
|
240
|
+
* ~0.03 ms each plus a per-pass floor that grows with how many are already
|
|
241
|
+
* resident. So the front-load is a *scheduling* problem, and this is the
|
|
242
|
+
* schedule — remaining blocks materialize on subsequent syncs, a few per frame,
|
|
243
|
+
* until the document is fully resident.
|
|
244
|
+
*
|
|
245
|
+
* What it does **not** change is the end state: the same blocks end up with the
|
|
246
|
+
* same DOM, only later. Nothing is dropped, so the reachability the semantic
|
|
247
|
+
* tier exists for is preserved; a block still waiting is simply not yet in the
|
|
248
|
+
* DOM, exactly as a block beyond the margin is not.
|
|
249
|
+
*
|
|
250
|
+
* Applies **only** to the coarse tier. A block inside the interaction margin is
|
|
251
|
+
* on screen and materializes immediately regardless of this budget — deferring
|
|
252
|
+
* visible text would make it briefly unselectable, which is a user-visible
|
|
253
|
+
* regression rather than a cost saving.
|
|
254
|
+
*
|
|
255
|
+
* `Infinity` disables the budget and restores one synchronous pass. Default:
|
|
256
|
+
* {@link DEFAULT_CONTENT_SEMANTIC_BUDGET}. Because the coarse tier exists only
|
|
257
|
+
* when `contentSemanticMargin` is wider than `contentProjectionMargin`, a scene
|
|
258
|
+
* that does not opt into a resident tier has no coarse blocks and is therefore
|
|
259
|
+
* unaffected by any value here.
|
|
260
|
+
*/
|
|
261
|
+
contentSemanticBudget?: number;
|
|
232
262
|
/**
|
|
233
263
|
* Reading direction used to order the accessibility/automation shadow tree so
|
|
234
264
|
* keyboard **tab order** and screen-reader traversal follow the *visual*
|
|
@@ -268,7 +298,7 @@ export interface SceneOptions {
|
|
|
268
298
|
* against. A new option must be added here too — the test suite asserts the two
|
|
269
299
|
* stay in sync.
|
|
270
300
|
*/
|
|
271
|
-
export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
|
|
301
|
+
export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticBudget', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
|
|
272
302
|
/** Frame-rate the loop is capped to when the OS requests reduced motion. */
|
|
273
303
|
export declare const REDUCED_MOTION_FPS = 30;
|
|
274
304
|
/**
|
|
@@ -359,6 +389,45 @@ export interface A11yTreeNode {
|
|
|
359
389
|
valuemax?: string;
|
|
360
390
|
children: A11yTreeNode[];
|
|
361
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Default {@link SceneOptions.contentSemanticBudget}: resident blocks
|
|
394
|
+
* materialized per sync.
|
|
395
|
+
*
|
|
396
|
+
* Sized against the two costs a pass actually pays, both measured in real headed
|
|
397
|
+
* Chrome on a 240Hz panel. Per created block is cheap and flat (~0.03ms). What
|
|
398
|
+
* dominates is style+layout of the projection subtree, which scales with how many
|
|
399
|
+
* blocks are already RESIDENT and is paid once per pass: traced at 10000 blocks,
|
|
400
|
+
* `UpdateLayoutTree` 391.7ms + `Layout` 305.8ms over 40 passes (~17ms each), with
|
|
401
|
+
* per-pass cost roughly doubling from the first pass to the last while the number
|
|
402
|
+
* created stayed constant.
|
|
403
|
+
*
|
|
404
|
+
* So total drain cost is approximately `passes × f(resident)`, and a SMALLER
|
|
405
|
+
* budget multiplies the term that does not shrink. Measured to completion, 3
|
|
406
|
+
* repeats, medians:
|
|
407
|
+
*
|
|
408
|
+
* ```text
|
|
409
|
+
* 1000 blocks budget 32 → 67.1ms total, 4.3ms worst pass
|
|
410
|
+
* budget 64 → 54.0ms total, 5.1ms worst pass
|
|
411
|
+
* budget 256 → 27.7ms total, 7.7ms worst pass
|
|
412
|
+
* Infinity → 24.1ms total, 23.6ms worst pass
|
|
413
|
+
* 10000 blocks budget 32 → 3773.2ms total, 42.6ms worst pass
|
|
414
|
+
* budget 64 → 1896.2ms total, 41.6ms worst pass
|
|
415
|
+
* budget 256 → 648.1ms total, 35.2ms worst pass
|
|
416
|
+
* Infinity → 319.4ms total, 307.3ms worst pass
|
|
417
|
+
* ```
|
|
418
|
+
*
|
|
419
|
+
* 256 is where the two goals stop trading against each other. Below it there is no
|
|
420
|
+
* frame-bound improvement at 10000 blocks — every budget lands at 35-43ms, because
|
|
421
|
+
* the worst pass is the LAST one laying out the complete subtree — while total time
|
|
422
|
+
* rises 6x. At 1000 blocks it still holds 7.7ms, inside a 60Hz frame, for less than
|
|
423
|
+
* half the total time of 64.
|
|
424
|
+
*
|
|
425
|
+
* This replaces an earlier default of 64, which was sized against a per-block cost
|
|
426
|
+
* of ~0.4ms. That figure was inflated by a forced layout per materialized block
|
|
427
|
+
* (see `contentSelectionPresentThisSync`); with that removed, 64 spends 6x the
|
|
428
|
+
* total time for no frame-bound gain.
|
|
429
|
+
*/
|
|
430
|
+
export declare const DEFAULT_CONTENT_SEMANTIC_BUDGET = 256;
|
|
362
431
|
/**
|
|
363
432
|
* Top-level orchestrator that owns the entity tree, drive the render loop,
|
|
364
433
|
* and maintains the accessibility/automation shadow layer.
|
|
@@ -560,6 +629,32 @@ export declare class Scene {
|
|
|
560
629
|
private contentProjectionEnabled;
|
|
561
630
|
private contentProjectionMargin;
|
|
562
631
|
private contentSemanticMargin;
|
|
632
|
+
private contentSemanticBudget;
|
|
633
|
+
private contentSemanticBudgetLeft;
|
|
634
|
+
private contentSemanticDeferred;
|
|
635
|
+
/**
|
|
636
|
+
* Per-sync memo of "does the document hold a selection at all".
|
|
637
|
+
*
|
|
638
|
+
* Reading ANY property of a `Selection` (`anchorNode`, `rangeCount`, `type`,
|
|
639
|
+
* `isCollapsed`) forces a synchronous layout, because Blink validates the
|
|
640
|
+
* selection against current box geometry before answering. Measured in real
|
|
641
|
+
* Chrome against a 1000-carrier subtree with layout dirtied between reads:
|
|
642
|
+
* `anchorNode` 0.5ms, `rangeCount` 0.4ms, `type` 0.5ms, `isCollapsed` 0.5ms —
|
|
643
|
+
* all indistinguishable from `offsetHeight` (0.5ms), against a 0ms floor for
|
|
644
|
+
* mutating without reading. So there is no cheap property to probe with; the
|
|
645
|
+
* only way to avoid the layout is to not touch the object at all.
|
|
646
|
+
*
|
|
647
|
+
* Materializing a block rebuilds its carriers, which asks whether the rebuild
|
|
648
|
+
* would destroy a selection. Once per block, that read cost a forced layout
|
|
649
|
+
* over the whole (and growing) projection subtree, which is what made
|
|
650
|
+
* per-block cost rise with resident count: profiled at 1973 forced layouts
|
|
651
|
+
* totalling 633ms of an 847ms 1000-block drain (75%).
|
|
652
|
+
*
|
|
653
|
+
* A selection is a single document-wide object and a sync walk cannot yield to
|
|
654
|
+
* the user, so its presence cannot change mid-walk. Resolving it once per walk
|
|
655
|
+
* turns O(blocks) forced layouts into O(1). `null` = not yet resolved.
|
|
656
|
+
*/
|
|
657
|
+
private contentSelectionPresentThisSync;
|
|
563
658
|
/**
|
|
564
659
|
* True while a text-selection drag that started on a projection's blank
|
|
565
660
|
* region (no text node under the press) is being driven manually — the
|
|
@@ -624,6 +719,16 @@ export declare class Scene {
|
|
|
624
719
|
* see {@link sortNormalElementsVisually}.
|
|
625
720
|
*/
|
|
626
721
|
private a11yOrderContainers;
|
|
722
|
+
/**
|
|
723
|
+
* Nearest `clipChildren` ancestor per ordered element — its *region* — reused
|
|
724
|
+
* per pass. Written by `enforceA11yDomOrder`'s collect walk, which already has
|
|
725
|
+
* the entity in hand, so a region costs one comparison per node rather than an
|
|
726
|
+
* ancestor walk per element.
|
|
727
|
+
*
|
|
728
|
+
* Absent means the element sits under no clipping ancestor and belongs to the
|
|
729
|
+
* implicit root region. See {@link sortNormalElementsVisually}.
|
|
730
|
+
*/
|
|
731
|
+
private a11yOrderRegions;
|
|
627
732
|
private activePortalsThisFrame;
|
|
628
733
|
private activePortalsPrevFrame;
|
|
629
734
|
private portalEntities;
|
|
@@ -1101,6 +1206,15 @@ export declare class Scene {
|
|
|
1101
1206
|
* to the live DOM selection.
|
|
1102
1207
|
*/
|
|
1103
1208
|
private contentGridSelectionLine;
|
|
1209
|
+
/**
|
|
1210
|
+
* Does the document hold a selection right now, memoized for this sync walk?
|
|
1211
|
+
*
|
|
1212
|
+
* Pays one forced layout per walk instead of one per rebuilt element — see
|
|
1213
|
+
* {@link Scene.contentSelectionPresentThisSync} for the measurements. When the
|
|
1214
|
+
* answer is `false` no element can own a selection, so every per-element
|
|
1215
|
+
* ownership test can be skipped without touching the object.
|
|
1216
|
+
*/
|
|
1217
|
+
private contentSelectionPresent;
|
|
1104
1218
|
private releaseContentSelectionForRebuild;
|
|
1105
1219
|
/**
|
|
1106
1220
|
* Rebuild a content-projection element's DOM (`rebuild`) while preserving a
|
|
@@ -1428,7 +1542,9 @@ export declare class Scene {
|
|
|
1428
1542
|
* transparent DOM node positioned over the drawn glyphs. Runs on the a11y
|
|
1429
1543
|
* sync cadence; all writes are dirty-checked. Off-viewport projections are
|
|
1430
1544
|
* hidden (`display: none`) so text-heavy scenes only materialize what is
|
|
1431
|
-
* visible to the browser's text machinery anyway
|
|
1545
|
+
* visible to the browser's text machinery anyway — except in the coarse
|
|
1546
|
+
* (resident) tier, which stays displayed because hiding it would make its text
|
|
1547
|
+
* unfindable and remove it from the accessibility tree, defeating the tier.
|
|
1432
1548
|
*/
|
|
1433
1549
|
/**
|
|
1434
1550
|
* Whether `node`'s world-space box, expanded by `margin` px on every side,
|
|
@@ -1437,6 +1553,13 @@ export declare class Scene {
|
|
|
1437
1553
|
* at `margin = contentProjectionMargin`) and for the exact `display:none`
|
|
1438
1554
|
* visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
|
|
1439
1555
|
* culling and always count as visible, matching the legacy behavior.
|
|
1556
|
+
*
|
|
1557
|
+
* `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower
|
|
1558
|
+
* question "does this box overlap the viewport at all". The coarse content tier
|
|
1559
|
+
* needs the two apart: text that is merely off-viewport is clipped by
|
|
1560
|
+
* `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text
|
|
1561
|
+
* rejected by an ancestor clip box that itself overlaps the viewport would sit
|
|
1562
|
+
* transparently on top of whatever is really drawn there.
|
|
1440
1563
|
*/
|
|
1441
1564
|
private projectionBoxVisible;
|
|
1442
1565
|
/**
|
|
@@ -1496,6 +1619,18 @@ export declare class Scene {
|
|
|
1496
1619
|
* parents, which no `insertBefore` ever acts on. Normalizing everything back
|
|
1497
1620
|
* to world coordinates here would cost a transform per element per frame to
|
|
1498
1621
|
* change nothing observable.
|
|
1622
|
+
*
|
|
1623
|
+
* Banding runs **per region** — per nearest `clipChildren` ancestor, recorded
|
|
1624
|
+
* by {@link enforceA11yDomOrder}'s collect walk — rather than once over the
|
|
1625
|
+
* whole scene. Purely visual banding is right for a screen reader but wrong
|
|
1626
|
+
* for selection: a DOM `Selection` covers everything between anchor and focus
|
|
1627
|
+
* in DOM order, so under one global banding a vertical drag through a
|
|
1628
|
+
* transcript also swallowed a sidebar whose headings happened to fall in the
|
|
1629
|
+
* same rows. Regions are laid out side by side, so ordering region-major keeps
|
|
1630
|
+
* each one a contiguous DOM run and a drag stays inside it, while reading
|
|
1631
|
+
* order *within* a region is unchanged. Regions are emitted in the order their
|
|
1632
|
+
* clipper is first reached by the depth-first walk, so a screen reader still
|
|
1633
|
+
* meets them in the author's declared order.
|
|
1499
1634
|
*/
|
|
1500
1635
|
private sortNormalElementsVisually;
|
|
1501
1636
|
/** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
|