@vectojs/core 1.32.7 → 1.34.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-FQ2565IT.js → chunk-UEIZDNK7.js} +57 -30
- package/dist/{chunk-YTGGL4I4.mjs → chunk-VFQMETOR.mjs} +27 -0
- package/dist/index.js +3082 -2280
- package/dist/index.mjs +2834 -2032
- package/dist/text.js +2 -2
- package/dist/text.mjs +1 -1
- package/dist/tree/Entity.d.ts +41 -0
- package/dist/tree/Scene.d.ts +199 -472
- package/dist/tree/scene/A11yProjectionManager.d.ts +147 -0
- package/dist/tree/scene/CanvasGeometry.d.ts +117 -0
- package/dist/tree/scene/ContentGridProjector.d.ts +63 -0
- package/dist/tree/scene/ContentProjectionManager.d.ts +217 -0
- package/dist/tree/scene/DirtyTracker.d.ts +107 -0
- package/dist/tree/scene/DriverTicker.d.ts +101 -0
- package/dist/tree/scene/HitTester.d.ts +113 -0
- package/dist/tree/scene/PhaseTimer.d.ts +114 -0
- package/dist/tree/scene/WasmBackendFacade.d.ts +271 -0
- package/dist/tree/scene/a11y-dom.d.ts +78 -0
- package/dist/tree/scene/content-break-carrier.d.ts +77 -0
- package/dist/tree/scene/content-caret.d.ts +28 -0
- package/dist/tree/scene/content-line-window.d.ts +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The a11y projection's DOM **ordering** engine: what order the projected
|
|
3
|
+
* mirrors sit in, and what that reordering must not break.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 2 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2), reduced in scope by
|
|
7
|
+
* carryctx `DEC-0020`. `Scene` keeps `enforceA11yDomOrder` and
|
|
8
|
+
* `a11yNeedsReorder` under their original names; the ordering half of the former
|
|
9
|
+
* and the whole of the latter live here.
|
|
10
|
+
*
|
|
11
|
+
* ## What this owns
|
|
12
|
+
*
|
|
13
|
+
* The per-pass scratch collections (all reused rather than reallocated, which is
|
|
14
|
+
* why they are fields and not locals), the reorder flag, the visual reading-order
|
|
15
|
+
* sort, and the cursor-based `insertBefore` pass with its focus and `Selection`
|
|
16
|
+
* preservation.
|
|
17
|
+
*
|
|
18
|
+
* ## What it deliberately does not own
|
|
19
|
+
*
|
|
20
|
+
* `Scene.enforceA11yDomOrder` still runs the collect-and-prune walk. Collecting
|
|
21
|
+
* needs `shouldProjectA11y` — which reaches the pointer position (extraction 5)
|
|
22
|
+
* and the content-projection tier (extraction 3) — and pruning needs
|
|
23
|
+
* `focusedA11yElement`, `caretBlinkTimer` and `preserveFocusOnRemoval`. So
|
|
24
|
+
* `Scene` walks and prunes, feeding each element in through {@link collect}, and
|
|
25
|
+
* this class orders what it was given.
|
|
26
|
+
*
|
|
27
|
+
* Three members the original extraction plan assigned here stayed on `Scene`,
|
|
28
|
+
* measured rather than assumed (`DEC-0020`):
|
|
29
|
+
*
|
|
30
|
+
* - `syncA11y` is not an a11y method. It is the shared depth-first walk driver
|
|
31
|
+
* for a11y **and** content projection: it calls `syncContentProjection` at its
|
|
32
|
+
* own recursion point and initialises four per-sync fields whose readers live
|
|
33
|
+
* in three other domains. It moves once extraction 3 has taken its co-driver
|
|
34
|
+
* out, not before — taking it now would need a back-edge into `Scene`, which
|
|
35
|
+
* `DEC-0019` rule 1 forbids.
|
|
36
|
+
* - `removeA11yRecursively` deletes content projections and releases DOM portals,
|
|
37
|
+
* which are extraction 3 and the ninth portal domain respectively.
|
|
38
|
+
* - `syncOverlayGeometry` is overlay-**layer** geometry: every dependency except
|
|
39
|
+
* `a11yRoot` (`canvas`, `glCanvas`, `gpuCanvas`, `width`, `height`) belongs to
|
|
40
|
+
* extraction 5. It sits under an a11y banner, which is a fourth instance of
|
|
41
|
+
* `DEC-0016`'s finding that the banners expose wrong cuts.
|
|
42
|
+
*
|
|
43
|
+
* ## Reading direction is passed in
|
|
44
|
+
*
|
|
45
|
+
* The inline sort is direction-sensitive and `_readingDirection` belongs to the
|
|
46
|
+
* text/layout side of `Scene`, so {@link reorder} takes it as an argument rather
|
|
47
|
+
* than reaching for it — `DEC-0019` rule 5, the same shape as
|
|
48
|
+
* `WasmBackendFacade.report(webgpuActive)`.
|
|
49
|
+
*/
|
|
50
|
+
import type { Entity } from '../Entity';
|
|
51
|
+
export declare class A11yProjectionManager {
|
|
52
|
+
/**
|
|
53
|
+
* Set by anything that changes which elements exist or how they nest, and
|
|
54
|
+
* cleared by the reorder pass. Public so `Scene` can expose it under its
|
|
55
|
+
* original name: `Entity` assigns to `scene.a11yNeedsReorder` as a public
|
|
56
|
+
* cross-class contract, so the flag needs a setter and not only
|
|
57
|
+
* {@link markNeedsReorder}.
|
|
58
|
+
*/
|
|
59
|
+
needsReorder: boolean;
|
|
60
|
+
/** Overlay mirrors, kept in insertion order — they cover everything, so the
|
|
61
|
+
* author's declared order is the right one. */
|
|
62
|
+
private readonly fullViewportElements;
|
|
63
|
+
/** Everything else, sorted into visual reading order by {@link reorder}. */
|
|
64
|
+
private readonly normalElements;
|
|
65
|
+
/** Ids collected this pass, read by `Scene`'s prune pass through
|
|
66
|
+
* {@link isActive}. */
|
|
67
|
+
private readonly activeIdSet;
|
|
68
|
+
/** Per-parent insertion cursor, reused by {@link reorder}. */
|
|
69
|
+
private readonly orderCursors;
|
|
70
|
+
/** Membership set for the elements being ordered, reused per reorder pass. */
|
|
71
|
+
private readonly orderMembers;
|
|
72
|
+
/**
|
|
73
|
+
* Elements that are an *ancestor* of another ordered element, reused per pass.
|
|
74
|
+
*
|
|
75
|
+
* A composite widget's container (a `grid` around its rows, a `tree` around its
|
|
76
|
+
* items) spans every descendant row, so it must not extend a visual row band —
|
|
77
|
+
* see {@link sortNormalElementsVisually}.
|
|
78
|
+
*/
|
|
79
|
+
private readonly orderContainers;
|
|
80
|
+
/**
|
|
81
|
+
* Nearest region-establishing ancestor per ordered element — its *region*.
|
|
82
|
+
* Written by `Scene.enforceA11yDomOrder`'s collect walk through
|
|
83
|
+
* {@link collect}, which already has the entity in hand, so a region costs one
|
|
84
|
+
* comparison per node rather than an ancestor walk per element.
|
|
85
|
+
*
|
|
86
|
+
* An ancestor establishes a region by setting `a11yRegion` (grouping declared
|
|
87
|
+
* directly) or `clipChildren` (a clipper is usually the column boundary you
|
|
88
|
+
* want anyway). Absent means the element sits under neither and belongs to the
|
|
89
|
+
* implicit root region. See {@link sortNormalElementsVisually}.
|
|
90
|
+
*/
|
|
91
|
+
private readonly orderRegions;
|
|
92
|
+
/** Mark the projected DOM as needing a reorder on the next pass. */
|
|
93
|
+
markNeedsReorder(): void;
|
|
94
|
+
/** Reset the per-pass collections. Zero-GC: length/clear, never reallocate. */
|
|
95
|
+
beginCollect(): void;
|
|
96
|
+
/**
|
|
97
|
+
* Add one projected element to this pass.
|
|
98
|
+
*
|
|
99
|
+
* `region` is the nearest ancestor that sets `a11yRegion` or `clipChildren`,
|
|
100
|
+
* or `null` for the implicit root region.
|
|
101
|
+
*/
|
|
102
|
+
collect(el: HTMLElement, fullViewport: boolean, region: Entity | null): void;
|
|
103
|
+
/** Record that `id` still has a live a11y mirror this pass. */
|
|
104
|
+
markActive(id: string): void;
|
|
105
|
+
/** Whether `id` was collected this pass. Drives `Scene`'s prune pass. */
|
|
106
|
+
isActive(id: string): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Put the collected elements into visual reading order in the DOM.
|
|
109
|
+
*
|
|
110
|
+
* No-op unless {@link needsReorder} is set; clears it on the way out.
|
|
111
|
+
*/
|
|
112
|
+
reorder(rtl: boolean): void;
|
|
113
|
+
/**
|
|
114
|
+
* Reorder `normalElements` (in place) into visual reading order using the
|
|
115
|
+
* positions `syncA11y` already wrote to each element's inline style
|
|
116
|
+
* (`top`/`left`/`height`). Elements are grouped into rows top-to-bottom (an
|
|
117
|
+
* element belongs to the current row while its top is above the row's
|
|
118
|
+
* running bottom edge), then sorted within a row by `left` — ascending for
|
|
119
|
+
* `'ltr'`, descending for `'rtl'`. The sort is stable, so entities at the
|
|
120
|
+
* same position keep their scene-graph (collection) order as a tiebreak.
|
|
121
|
+
*
|
|
122
|
+
* Those inline values are world coordinates for a top-level mirror but
|
|
123
|
+
* PARENT-RELATIVE for a nested one, so this list mixes coordinate spaces.
|
|
124
|
+
* That is sound because the result is only ever applied per DOM parent
|
|
125
|
+
* ({@link reorder} advances a cursor per parent), and all of one parent's
|
|
126
|
+
* children share one space: a `grid`'s rows are all grid-relative, a `row`'s
|
|
127
|
+
* cells all row-relative. Comparisons ACROSS spaces do happen while banding,
|
|
128
|
+
* but they only affect the relative order of elements in different parents,
|
|
129
|
+
* which no `insertBefore` ever acts on. Normalizing everything back to world
|
|
130
|
+
* coordinates here would cost a transform per element per frame to change
|
|
131
|
+
* nothing observable.
|
|
132
|
+
*
|
|
133
|
+
* Banding runs **per region** — per nearest ancestor setting `a11yRegion` or
|
|
134
|
+
* `clipChildren`, recorded by `Scene.enforceA11yDomOrder`'s collect walk —
|
|
135
|
+
* rather than once over the whole scene. Purely visual banding is right for a
|
|
136
|
+
* screen reader but wrong for selection: a DOM `Selection` covers everything
|
|
137
|
+
* between anchor and focus in DOM order, so under one global banding a
|
|
138
|
+
* vertical drag through a transcript also swallowed a sidebar whose headings
|
|
139
|
+
* happened to fall in the same rows. Regions are laid out side by side, so
|
|
140
|
+
* ordering region-major keeps each one a contiguous DOM run and a drag stays
|
|
141
|
+
* inside it, while reading order *within* a region is unchanged. Regions are
|
|
142
|
+
* emitted in the order their establishing ancestor is first reached by the
|
|
143
|
+
* depth-first walk, so a screen reader still meets them in the author's
|
|
144
|
+
* declared order.
|
|
145
|
+
*/
|
|
146
|
+
private sortNormalElementsVisually;
|
|
147
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas box geometry: the CSS↔logical mapping, the overlay layer alignment, and
|
|
3
|
+
* the device-pixel-ratio math.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 5 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2), shipped at **reduced
|
|
7
|
+
* scope** for the same measured reason extractions 2 and 3 were reduced
|
|
8
|
+
* (`DEC-0020`, `DEC-0022`): the decided `ContextAndResize` scope named nine
|
|
9
|
+
* members, and six of them cannot move without a `Scene` back-edge. See the
|
|
10
|
+
* decision record for the per-member measurement.
|
|
11
|
+
*
|
|
12
|
+
* ## What this owns
|
|
13
|
+
*
|
|
14
|
+
* Everything that is a pure function of the canvas's CSS box and the scene's
|
|
15
|
+
* logical size:
|
|
16
|
+
*
|
|
17
|
+
* - {@link clientToScene} — browser viewport coordinates to logical ones.
|
|
18
|
+
* - {@link syncOverlay} — keeps the a11y, portal, WebGL and WebGPU layers
|
|
19
|
+
* aligned with the canvas box, including the `position` that layers share with
|
|
20
|
+
* the canvas so a scroll needs no JS compensation, and the memo that makes an
|
|
21
|
+
* unchanged frame write nothing.
|
|
22
|
+
* - {@link effectiveDPR} and {@link sizeGpuCanvas} — the DPR clamp and the
|
|
23
|
+
* backing-store sizing that follows from it.
|
|
24
|
+
*
|
|
25
|
+
* ## What is held, and what is passed in
|
|
26
|
+
*
|
|
27
|
+
* `canvas`, `a11yRoot` and `portalRoot` are held: `Scene` assigns all three
|
|
28
|
+
* exactly once in its constructor and never reassigns them (`canvas` at
|
|
29
|
+
* `Scene.ts:2423`, `a11yRoot` at `:2526`/`:2683`, `portalRoot` at
|
|
30
|
+
* `:2670`/`:2684`).
|
|
31
|
+
*
|
|
32
|
+
* Everything else is a per-call argument (`DEC-0019` rule 5), because it is
|
|
33
|
+
* genuinely mutable and holding it would go stale silently:
|
|
34
|
+
*
|
|
35
|
+
* - `width`/`height` are **public** fields that `resize` mutates and that code
|
|
36
|
+
* outside this package assigns directly (9 sites across the test suite,
|
|
37
|
+
* benchmarks and comparisons write `scene.width = …`). They cannot become this
|
|
38
|
+
* collaborator's state without changing the public API.
|
|
39
|
+
* - `maxDPR` is public and externally assignable (`Scene.test.ts:1013`).
|
|
40
|
+
* - `glCanvas` and `gpuCanvas` are created lazily — `gpuCanvas` only when the
|
|
41
|
+
* WebGPU particle path first runs (`Scene.ts:6468`) — so the overlay sync is
|
|
42
|
+
* told which layers exist at the moment it runs.
|
|
43
|
+
*
|
|
44
|
+
* ## What deliberately did not move
|
|
45
|
+
*
|
|
46
|
+
* `resize` stays on `Scene` and keeps its whole body. It mutates `width`,
|
|
47
|
+
* `height`, `contentFontEpoch` and `contentViewportEpoch`, drives `renderer` and
|
|
48
|
+
* `pointRenderer`, and calls `markDirty` — four domains, none of them this one.
|
|
49
|
+
* It calls in here for the GPU canvas sizing and that is the honest extent of it
|
|
50
|
+
* (`DEC-0019` rule 2 applied without inventing a back-edge).
|
|
51
|
+
*
|
|
52
|
+
* `setupEvents` and `watchDevicePixelRatio` both call `this.resize(…)`, so moving
|
|
53
|
+
* either needs a `Scene` reference; a bound `resize` injected at construction is
|
|
54
|
+
* still that reference captured in a closure, which `DEC-0020` refused for
|
|
55
|
+
* `syncA11y` and `DEC-0021` refused for `_recordPhase`.
|
|
56
|
+
*
|
|
57
|
+
* `watchCanvasVisibility` reads `isRunning`, writes `lastTime`, calls
|
|
58
|
+
* `scheduleFrame`, and owns `_canvasOnScreen`, whose readers are `loop` and
|
|
59
|
+
* `stop`. That is render-scheduler state, so it belongs to extraction 6.
|
|
60
|
+
*
|
|
61
|
+
* `initWebGPUContext` is one member of a device-lifecycle cluster (`device`,
|
|
62
|
+
* `deviceLost`, `manager`, `recoveryTimerId`, `gpuContext`,
|
|
63
|
+
* `setupDeviceLostHandler`, `recreateWebGPUDeviceWithRetry`,
|
|
64
|
+
* `clearGPUCanvasIfStale`) that `render` and `destroy` also drive.
|
|
65
|
+
*
|
|
66
|
+
* `getContentMetricScaleX` caches into `contentMetricScaleX` keyed by
|
|
67
|
+
* `contentFontEpoch` and is called only by `syncContentGridProjection`, so it is
|
|
68
|
+
* content-projection state and moves with the deferred projection walk.
|
|
69
|
+
*/
|
|
70
|
+
/** The overlay box last written by {@link CanvasGeometry.syncOverlay}. */
|
|
71
|
+
export interface OverlayGeometry {
|
|
72
|
+
left: number;
|
|
73
|
+
top: number;
|
|
74
|
+
cssWidth: number;
|
|
75
|
+
cssHeight: number;
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
/**
|
|
79
|
+
* `position` written to the overlay layers, mirroring the canvas's own.
|
|
80
|
+
*
|
|
81
|
+
* Part of the memo because it is part of the written state: a canvas that
|
|
82
|
+
* becomes `fixed` after the first sync (a scroll-driven CSS class, a
|
|
83
|
+
* full-screen toggle) changes nothing else in here — same box, same logical
|
|
84
|
+
* size — so without this the memo would short-circuit and leave the overlay
|
|
85
|
+
* positioned the old way.
|
|
86
|
+
*/
|
|
87
|
+
position: 'absolute' | 'fixed';
|
|
88
|
+
}
|
|
89
|
+
export declare class CanvasGeometry {
|
|
90
|
+
private readonly canvas;
|
|
91
|
+
private readonly a11yRoot;
|
|
92
|
+
private readonly portalRoot;
|
|
93
|
+
/** Last geometry {@link syncOverlay} wrote, so an unchanged frame can skip the
|
|
94
|
+
* style writes entirely. Cleared by {@link invalidateOverlay} to force the next
|
|
95
|
+
* sync (a new overlay layer was created and has never been positioned). */
|
|
96
|
+
private overlayGeometry;
|
|
97
|
+
constructor(canvas: HTMLCanvasElement, a11yRoot: HTMLDivElement | null, portalRoot: HTMLDivElement | null);
|
|
98
|
+
/** Convert browser viewport coordinates into the scene's logical coordinates. */
|
|
99
|
+
clientToScene(clientX: number, clientY: number, width: number, height: number): {
|
|
100
|
+
x: number;
|
|
101
|
+
y: number;
|
|
102
|
+
};
|
|
103
|
+
/** Effective device pixel ratio, matching CanvasRenderer: real DPR clamped to
|
|
104
|
+
* `maxDPR` when set. */
|
|
105
|
+
effectiveDPR(maxDPR: number | undefined): number;
|
|
106
|
+
/** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at
|
|
107
|
+
* the logical size. Sizing the backing store in logical px (the old
|
|
108
|
+
* behavior) left it rasterized at 1× and CSS-stretched — blurry on HiDPI. */
|
|
109
|
+
sizeGpuCanvas(gpuCanvas: HTMLCanvasElement, width: number, height: number, maxDPR: number | undefined): void;
|
|
110
|
+
/** The memo, for the delegating accessor `Scene` keeps for its test readers. */
|
|
111
|
+
get overlay(): OverlayGeometry | null;
|
|
112
|
+
/** Force the next {@link syncOverlay} to write: a new overlay layer exists and
|
|
113
|
+
* has never been positioned. */
|
|
114
|
+
invalidateOverlay(): void;
|
|
115
|
+
/** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
|
|
116
|
+
syncOverlay(width: number, height: number, glCanvas: HTMLCanvasElement | null, gpuCanvas: HTMLCanvasElement | null): void;
|
|
117
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid carrier materialization: turning a prepared content grid into positioned
|
|
3
|
+
* DOM carriers, with per-line reuse so streaming stays affordable.
|
|
4
|
+
*
|
|
5
|
+
* The deferred **projection walk** extraction (`DEC-0028` measurement,
|
|
6
|
+
* `DEC-0019` pattern). The walk was deferred by `DEC-0020` and `DEC-0022` on the
|
|
7
|
+
* grounds that `syncA11y` and `syncContentProjection` are one shared depth-first
|
|
8
|
+
* walk and would have to move as a pair. Re-measured on `58c77ac` that premise
|
|
9
|
+
* held for the two walk drivers and **not** for this member: the grid path is a
|
|
10
|
+
* leaf of the walk, not part of it.
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is separable when its callers are not
|
|
13
|
+
*
|
|
14
|
+
* `syncContentGridProjection` has exactly one call site, takes everything it
|
|
15
|
+
* needs as parameters already, and calls nothing else on `Scene` except the
|
|
16
|
+
* content-projection manager and the phase timer. Its own state is two memo
|
|
17
|
+
* fields nobody outside it reads. It writes no state any other domain reads, so
|
|
18
|
+
* there is no back-edge to invent and `DEC-0019` rule 1 is satisfied outright.
|
|
19
|
+
*
|
|
20
|
+
* ## What is held, and what is passed in
|
|
21
|
+
*
|
|
22
|
+
* Held: the {@link ContentProjectionManager} and the {@link PhaseTimer}. Both are
|
|
23
|
+
* `readonly` on `Scene`, constructed once, and already shared collaborators — the
|
|
24
|
+
* timer is the shared leaf `DEC-0021` extracted for exactly this reason.
|
|
25
|
+
*
|
|
26
|
+
* Per-call (`DEC-0019` rule 5): `pageScaleX` and `fontEpoch`. `Scene` keeps
|
|
27
|
+
* `getContentMetricScaleX`, which reads `canvas` and the **public mutable**
|
|
28
|
+
* `width`, and whose memo is keyed on `contentFontEpoch` that `resize` and a font
|
|
29
|
+
* load both bump. Holding either here would go stale silently. `entityId` is
|
|
30
|
+
* passed rather than the `Entity`, because the id is all this needs.
|
|
31
|
+
*
|
|
32
|
+
* ## What deliberately did not move
|
|
33
|
+
*
|
|
34
|
+
* `syncContentProjection` (the walk's content driver) and `syncA11y` (its a11y
|
|
35
|
+
* driver) stay together on the facade, still blocked on each other exactly as
|
|
36
|
+
* `DEC-0020` measured.
|
|
37
|
+
*
|
|
38
|
+
* `projectionBoxVisible` also stays, for a reason measurement found rather than
|
|
39
|
+
* predicted: `test/ContentProjectionSettledWalk.test.ts` replaces it on the
|
|
40
|
+
* `Scene` instance and asserts call counts (the settled-walk fast path is
|
|
41
|
+
* verified by counting box tests, 2 when settled against 802 unpruned). Moving
|
|
42
|
+
* it onto a collaborator would make those calls invisible to the patch and turn
|
|
43
|
+
* a behavioural regression test into one that cannot fail. The suite is unedited
|
|
44
|
+
* by contract, so the member stays where the test can see it.
|
|
45
|
+
*/
|
|
46
|
+
import type { ContentProjection } from '../Entity';
|
|
47
|
+
import type { PreparedContentGrid } from '@vectojs/text';
|
|
48
|
+
import type { ContentProjectionManager } from './ContentProjectionManager';
|
|
49
|
+
import type { PhaseTimer } from './PhaseTimer';
|
|
50
|
+
export declare class ContentGridProjector {
|
|
51
|
+
private readonly contentProjection;
|
|
52
|
+
private readonly phases;
|
|
53
|
+
constructor(contentProjection: ContentProjectionManager, phases: PhaseTimer);
|
|
54
|
+
/**
|
|
55
|
+
* Materialize a prepared grid in logical source order while positioning each
|
|
56
|
+
* carrier from the shared canvas geometry. Browser font measurement happens
|
|
57
|
+
* later in one cold read/write batch, never inside projection synchronization.
|
|
58
|
+
*/
|
|
59
|
+
syncGrid(entityId: string, el: HTMLElement, projection: ContentProjection, grid: PreparedContentGrid, lineBand: {
|
|
60
|
+
minY: number;
|
|
61
|
+
maxY: number;
|
|
62
|
+
} | null, pageScaleX: number, fontEpoch: number): void;
|
|
63
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content projection's **selection preservation** and **grid calibration** —
|
|
3
|
+
* the two halves of extraction 3 that are separable today.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 3 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2), reduced in scope by
|
|
7
|
+
* carryctx `DEC-0022` for the same measured reason `DEC-0020` reduced extraction
|
|
8
|
+
* 2: the projection *walk* is not separable yet.
|
|
9
|
+
*
|
|
10
|
+
* ## What this owns
|
|
11
|
+
*
|
|
12
|
+
* **Selection across rebuilds.** A streaming message replaces its projection
|
|
13
|
+
* children on every appended chunk, and a naive rebuild wipes a selection the
|
|
14
|
+
* user made in the unchanged prefix. This owns the tracked drag anchor, the
|
|
15
|
+
* blank-region drag flag, the per-walk "is anything selected at all" memo, and
|
|
16
|
+
* the snapshot/restore of selection endpoints as linear character offsets.
|
|
17
|
+
*
|
|
18
|
+
* **Grid calibration.** The cold read/write batch that measures a projected
|
|
19
|
+
* grid's real laid-out text and writes a per-cell `scaleX`, its generation
|
|
20
|
+
* stamping, its probe DOM, and the pending-frame/probe bookkeeping that lets a
|
|
21
|
+
* teardown cancel work in flight.
|
|
22
|
+
*
|
|
23
|
+
* ## What it deliberately does not own
|
|
24
|
+
*
|
|
25
|
+
* `syncContentProjection` (624 lines) and its helpers stay on `Scene`. It is the
|
|
26
|
+
* far side of the shared walk `DEC-0020` measured: `syncA11y` calls it at its own
|
|
27
|
+
* recursion point, and it reads the four per-sync fields `syncA11y` initialises
|
|
28
|
+
* (`_syncSerial`, `contentSemanticBudgetLeft`, `contentSemanticDeferred`,
|
|
29
|
+
* `contentSelectionPresentThisSync`). The two walk drivers move together, once
|
|
30
|
+
* they can be cut as a pair rather than threaded through each other — taking
|
|
31
|
+
* either alone needs a back-edge, which `DEC-0019` rule 1 forbids.
|
|
32
|
+
*
|
|
33
|
+
* `getContentMetricScaleX` also stays: it reads `canvas` and `width`, which
|
|
34
|
+
* `resize` mutates, so it is extraction 5's state. Its result is already a
|
|
35
|
+
* parameter of {@link scheduleGridCalibration}, so nothing here reaches for it.
|
|
36
|
+
*
|
|
37
|
+
* ## What is passed in, and why
|
|
38
|
+
*
|
|
39
|
+
* `a11yRoot` is injected because it is assigned once in `Scene`'s constructor and
|
|
40
|
+
* never reassigned, so it is safe to hold. {@link PhaseTimer} is injected because
|
|
41
|
+
* it is the shared leaf `DEC-0021` extracted for exactly this: the calibration
|
|
42
|
+
* pass records `calibScan` and `calibProbeBuild`, and reaching a `Scene` private
|
|
43
|
+
* to do it — or holding a bound `Scene` method — is the rule 1 violation this
|
|
44
|
+
* whole sequence is avoiding.
|
|
45
|
+
*
|
|
46
|
+
* The font epoch is a per-call argument rather than held state (`DEC-0019` rule
|
|
47
|
+
* 5): a font load and a resize both bump it, and both belong to other domains.
|
|
48
|
+
*/
|
|
49
|
+
import { PhaseTimer } from './PhaseTimer';
|
|
50
|
+
import { type TextCaretPosition } from './content-caret';
|
|
51
|
+
export declare class ContentProjectionManager {
|
|
52
|
+
/**
|
|
53
|
+
* `a11yRoot` doubles as the projection root: carriers are appended to it, and
|
|
54
|
+
* the calibration probe is parented under it so CSS zoom and font substitution
|
|
55
|
+
* match the live carriers. `null` in non-DOM (SSR/Node) environments.
|
|
56
|
+
*/
|
|
57
|
+
private readonly a11yRoot;
|
|
58
|
+
private readonly phases;
|
|
59
|
+
/**
|
|
60
|
+
* True while a drag that started in a blank region of the projection is live.
|
|
61
|
+
* Mid-drag the browser is authoritative, so a rebuild must not try to preserve
|
|
62
|
+
* anything.
|
|
63
|
+
*/
|
|
64
|
+
private blankRegionDrag;
|
|
65
|
+
/** Tracked drag anchor. Survives a drag, unlike the live DOM selection. */
|
|
66
|
+
private anchor;
|
|
67
|
+
/**
|
|
68
|
+
* Memo for {@link selectionPresent}, valid for one sync walk.
|
|
69
|
+
*
|
|
70
|
+
* Reading any `Selection` property forces a synchronous layout, so the answer
|
|
71
|
+
* is resolved once per walk rather than once per rebuilt element. `null` means
|
|
72
|
+
* "not yet asked this walk".
|
|
73
|
+
*/
|
|
74
|
+
private presentThisSync;
|
|
75
|
+
/** Pending calibration rAF handle per projected grid entity. */
|
|
76
|
+
private readonly calibrationFrameHandles;
|
|
77
|
+
/** Detached, untransformed font probes used by the cold calibration pass. */
|
|
78
|
+
private readonly calibrationProbes;
|
|
79
|
+
/**
|
|
80
|
+
* Bumped when the conditions a measurement depends on change, which
|
|
81
|
+
* invalidates every existing per-cell `scaleX` at once without touching them.
|
|
82
|
+
*/
|
|
83
|
+
private calibrationGeneration;
|
|
84
|
+
/** The conditions the current generation was measured under. */
|
|
85
|
+
private calibrationStamp;
|
|
86
|
+
constructor(a11yRoot: HTMLDivElement | null, phases: PhaseTimer);
|
|
87
|
+
/** Pending calibration frames, keyed by entity id. Read by the e2e lifecycle probe. */
|
|
88
|
+
get calibrationFrames(): ReadonlyMap<string, number>;
|
|
89
|
+
/**
|
|
90
|
+
* Start tracking a drag that began in a blank region of the projection.
|
|
91
|
+
*
|
|
92
|
+
* Called from the projection root's `mousedown` handler, which has already
|
|
93
|
+
* resolved the caret and collapsed or extended the live selection.
|
|
94
|
+
*/
|
|
95
|
+
beginBlankRegionDrag(anchor: TextCaretPosition): void;
|
|
96
|
+
/** The tracked drag anchor, for extending a selection as the pointer moves. */
|
|
97
|
+
get selectionAnchor(): TextCaretPosition | null;
|
|
98
|
+
/**
|
|
99
|
+
* Whether a manually-driven blank-region drag is live.
|
|
100
|
+
*
|
|
101
|
+
* The projection root's `mousemove` handler gates on this: with no native
|
|
102
|
+
* anchor, the browser will not extend the selection itself.
|
|
103
|
+
*/
|
|
104
|
+
get blankRegionDragActive(): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Drop the memo describing whether the document holds a selection.
|
|
107
|
+
*
|
|
108
|
+
* Called at the top of each a11y sync walk: the memo answers a question about
|
|
109
|
+
* the live document, and a value from an earlier frame would be wrong.
|
|
110
|
+
*/
|
|
111
|
+
invalidateSelectionMemo(): void;
|
|
112
|
+
endDrag(): void;
|
|
113
|
+
/**
|
|
114
|
+
* Index of the carrier line currently holding a selection inside `el`, or
|
|
115
|
+
* `null`.
|
|
116
|
+
*
|
|
117
|
+
* Lets a partial re-materialization decide whether the user's selection is even
|
|
118
|
+
* affected. Checks the tracked anchor first (it survives a drag) and falls back
|
|
119
|
+
* to the live DOM selection.
|
|
120
|
+
*/
|
|
121
|
+
gridSelectionLine(el: HTMLElement): number | null;
|
|
122
|
+
/**
|
|
123
|
+
* Does the document hold a selection right now, memoized for this sync walk?
|
|
124
|
+
*
|
|
125
|
+
* Pays one forced layout per walk instead of one per rebuilt element — see
|
|
126
|
+
* {@link presentThisSync} for the measurements. When the answer is `false` no
|
|
127
|
+
* element can own a selection, so every per-element ownership test can be
|
|
128
|
+
* skipped without touching the object.
|
|
129
|
+
*/
|
|
130
|
+
selectionPresent(): boolean;
|
|
131
|
+
releaseSelectionForRebuild(el: HTMLElement): void;
|
|
132
|
+
/**
|
|
133
|
+
* Rebuild a content-projection element's DOM (`rebuild`) while preserving a
|
|
134
|
+
* text selection the user made inside it. A streaming message replaces its
|
|
135
|
+
* projection children on every appended chunk; without this, a selection in
|
|
136
|
+
* the UNCHANGED prefix is wiped on each frame ("can't select text in a
|
|
137
|
+
* message still receiving tokens"). We snapshot the selection's anchor/focus
|
|
138
|
+
* as linear character offsets within `el` before the rebuild and re-resolve
|
|
139
|
+
* them against the new DOM after, clamped to the new text length.
|
|
140
|
+
*
|
|
141
|
+
* Only fires when `el` owns the current selection and there is no active drag
|
|
142
|
+
* (mid-drag the browser is authoritative). The virtualization case — where
|
|
143
|
+
* `el` itself is removed from the DOM — is out of scope here (the node is
|
|
144
|
+
* genuinely freed; the browser clears the selection and there is nothing to
|
|
145
|
+
* restore against).
|
|
146
|
+
*/
|
|
147
|
+
preserveSelectionAcrossRebuild(el: HTMLElement, rebuild: () => void): void;
|
|
148
|
+
/**
|
|
149
|
+
* A selection inside a projected grid, expressed as offsets into `grid.source`.
|
|
150
|
+
*
|
|
151
|
+
* Source offsets rather than the linear DOM offsets
|
|
152
|
+
* {@link preserveSelectionAcrossRebuild} uses, because the grid path windows its
|
|
153
|
+
* carriers: the DOM holds only the lines near the viewport, so linear offset 0
|
|
154
|
+
* means "the first line that happens to be materialized" and moves whenever the
|
|
155
|
+
* window does. A reflow changes both the line breaks and the window, so a linear
|
|
156
|
+
* offset would restore the selection onto different characters. Every carrier
|
|
157
|
+
* cell already records its own `sourceStart`/`sourceEnd`, which are stable
|
|
158
|
+
* against line breaking, windowing, and per-cell calibration.
|
|
159
|
+
*/
|
|
160
|
+
private gridSelectionEndpointOffset;
|
|
161
|
+
/**
|
|
162
|
+
* Where in `grid.source` the live selection sits, or `null` when this element
|
|
163
|
+
* does not own one that can be expressed that way.
|
|
164
|
+
*
|
|
165
|
+
* Cheap-rejects exactly as {@link releaseSelectionForRebuild} does: the tracked
|
|
166
|
+
* anchor is a local field, and the memo costs one forced layout per sync walk
|
|
167
|
+
* rather than one per element.
|
|
168
|
+
*/
|
|
169
|
+
snapshotGridSelection(el: HTMLElement): {
|
|
170
|
+
anchor: number;
|
|
171
|
+
focus: number;
|
|
172
|
+
} | null;
|
|
173
|
+
/** The carrier caret for a source offset, or `null` when it is not projected. */
|
|
174
|
+
private gridCaretAtSourceOffset;
|
|
175
|
+
/**
|
|
176
|
+
* Put a {@link snapshotGridSelection} result back after a re-materialization,
|
|
177
|
+
* releasing instead whenever the selected text is no longer projected.
|
|
178
|
+
*
|
|
179
|
+
* Restoring is what keeps a selection alive across a reflow or a browser zoom,
|
|
180
|
+
* where every carrier line is rebuilt (the line breaks moved) but the selected
|
|
181
|
+
* characters are still on screen. When the window scrolled past them instead,
|
|
182
|
+
* the offsets resolve to nothing and the selection is dropped — a `Range` left
|
|
183
|
+
* pointing into detached carriers reports stale geometry and copies the wrong
|
|
184
|
+
* text.
|
|
185
|
+
*/
|
|
186
|
+
restoreGridSelection(el: HTMLElement, snapshot: {
|
|
187
|
+
anchor: number;
|
|
188
|
+
focus: number;
|
|
189
|
+
} | null): void;
|
|
190
|
+
/**
|
|
191
|
+
* Reset per-grid calibration and bookkeeping before a (re)materialization.
|
|
192
|
+
*
|
|
193
|
+
* @param entityId - Owning entity, keyed into the calibration maps.
|
|
194
|
+
* @param el - The projection element.
|
|
195
|
+
* @param releaseSelection - Whether to drop a selection this element owns.
|
|
196
|
+
* Pass `false` when carrier lines are being reused: the selection's DOM nodes
|
|
197
|
+
* survive the pass, so tearing it down would wipe a user's selection on every
|
|
198
|
+
* streamed chunk — the exact bug {@link preserveSelectionAcrossRebuild}
|
|
199
|
+
* exists to prevent on the non-grid path.
|
|
200
|
+
*/
|
|
201
|
+
clearGridState(entityId: string, el: HTMLElement, releaseSelection?: boolean): void;
|
|
202
|
+
/**
|
|
203
|
+
* Cancel every calibration in flight and drop every probe.
|
|
204
|
+
*
|
|
205
|
+
* For `Scene.destroy()`: an outstanding rAF would otherwise run against a
|
|
206
|
+
* destroyed scene, and a probe left in the document keeps a detached subtree
|
|
207
|
+
* alive.
|
|
208
|
+
*/
|
|
209
|
+
dispose(): void;
|
|
210
|
+
/**
|
|
211
|
+
* Measure a projected grid's real laid-out text and write a per-cell `scaleX`.
|
|
212
|
+
*
|
|
213
|
+
* @param fontEpoch - `Scene`'s font epoch. Passed in rather than held: a font
|
|
214
|
+
* load and a resize both bump it, and both belong to other domains.
|
|
215
|
+
*/
|
|
216
|
+
scheduleGridCalibration(entityId: string, el: HTMLElement, calibrationKey: string, pageScaleX: number, fontEpoch: number): void;
|
|
217
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dirty flag and its attribution: whether the next frame must redraw, and
|
|
3
|
+
* who asked for it.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 6 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2), shipped at **heavily
|
|
7
|
+
* reduced scope** — see `DEC-0025`. The decided `RenderScheduler` scope named
|
|
8
|
+
* `loop` and `render` as its centre; neither is movable, so what ships is this
|
|
9
|
+
* cluster and {@link DriverTicker}.
|
|
10
|
+
*
|
|
11
|
+
* ## What this owns
|
|
12
|
+
*
|
|
13
|
+
* The flag itself, the opt-in attribution map, and its FIFO bound. The two are
|
|
14
|
+
* one domain rather than two: `markDirty` is the only writer of the flag and the
|
|
15
|
+
* only trigger of an attribution, so splitting them would put a branch on the hot
|
|
16
|
+
* path in one class and its data in another.
|
|
17
|
+
*
|
|
18
|
+
* ## The hot path
|
|
19
|
+
*
|
|
20
|
+
* {@link mark} is called from dozens of sites, several per frame. It stays a
|
|
21
|
+
* single field write plus one already-false branch when tracking is off, which is
|
|
22
|
+
* why {@link record} is a separate method: V8 inlines the one-field version
|
|
23
|
+
* reliably, and that split was deliberate before this extraction.
|
|
24
|
+
*
|
|
25
|
+
* ## What is passed in
|
|
26
|
+
*
|
|
27
|
+
* `currentFrame` is a per-call argument (`DEC-0019` rule 5). It is written by
|
|
28
|
+
* `Scene.render`, which did not move, so capturing it would go stale every frame.
|
|
29
|
+
*
|
|
30
|
+
* ## What deliberately did not move
|
|
31
|
+
*
|
|
32
|
+
* `Scene.markDirty` keeps its exact name and signature and delegates here. It is
|
|
33
|
+
* a cross-class contract — `Entity.ts` calls `scene.markDirty()`, and there are
|
|
34
|
+
* 129 call sites across the workspace packages — so it stays reachable on `Scene`
|
|
35
|
+
* (`DEC-0019` rule 2).
|
|
36
|
+
*
|
|
37
|
+
* `renderMode`, `maxFPS` and `autoThrottle` stay on `Scene` as public fields:
|
|
38
|
+
* they are consumed by `loop`, which did not move, and `renderMode` has three
|
|
39
|
+
* external readers in `@vectojs/devtools`. The frame telemetry counters stay with
|
|
40
|
+
* `loop` for the same reason — it is their only writer.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Who marked the scene dirty, and why.
|
|
44
|
+
*
|
|
45
|
+
* Every field is optional except `reason` so a call site can be as specific as it
|
|
46
|
+
* cheaply can — an entity id costs nothing to pass, a property name is often
|
|
47
|
+
* already in scope.
|
|
48
|
+
*/
|
|
49
|
+
export interface DirtySource {
|
|
50
|
+
/** Entity id responsible, when one is. Omitted for scene-level invalidation. */
|
|
51
|
+
entity?: string;
|
|
52
|
+
/** Short, stable category — e.g. `'text-changed'`, `'animation'`, `'resize'`. */
|
|
53
|
+
reason: string;
|
|
54
|
+
/** Property that changed, when the reason alone is ambiguous. */
|
|
55
|
+
property?: string;
|
|
56
|
+
}
|
|
57
|
+
/** An aggregated dirty attribution. */
|
|
58
|
+
export interface DirtyReasonEntry {
|
|
59
|
+
entity?: string;
|
|
60
|
+
reason: string;
|
|
61
|
+
property?: string;
|
|
62
|
+
/** How many times this exact attribution was recorded. */
|
|
63
|
+
count: number;
|
|
64
|
+
firstFrame: number;
|
|
65
|
+
lastFrame: number;
|
|
66
|
+
}
|
|
67
|
+
export declare class DirtyTracker {
|
|
68
|
+
/** Cap on distinct recorded dirty reasons (see {@link record}). */
|
|
69
|
+
private static readonly MAX_DIRTY_REASONS;
|
|
70
|
+
private tracking;
|
|
71
|
+
private readonly reasons;
|
|
72
|
+
private isDirty;
|
|
73
|
+
/** Whether the next frame must redraw. */
|
|
74
|
+
get dirty(): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Mark the scene as needing a redraw on the next frame.
|
|
77
|
+
*
|
|
78
|
+
* Attribution is opt-in and costs nothing when off: this is called from dozens
|
|
79
|
+
* of sites, several of them per-frame, so the common path must stay a single
|
|
80
|
+
* field write.
|
|
81
|
+
*/
|
|
82
|
+
mark(source: DirtySource | undefined, currentFrame: number): void;
|
|
83
|
+
/**
|
|
84
|
+
* Consume the flag.
|
|
85
|
+
*
|
|
86
|
+
* `Scene.loop` clears it BEFORE the update/render pass: any `markDirty()` call
|
|
87
|
+
* made inside an entity's `update()` must survive into the next frame
|
|
88
|
+
* (self-animating entities re-arm themselves this way). Clearing after render
|
|
89
|
+
* would silently wipe those marks and freeze the entity.
|
|
90
|
+
*/
|
|
91
|
+
clear(): void;
|
|
92
|
+
/**
|
|
93
|
+
* Record who marked the scene dirty and why.
|
|
94
|
+
*
|
|
95
|
+
* Kept separate from {@link mark} so the hot path is not a function call with a
|
|
96
|
+
* branch — V8 inlines the one-field version reliably.
|
|
97
|
+
*/
|
|
98
|
+
private record;
|
|
99
|
+
/** Start or stop recording dirty attributions. */
|
|
100
|
+
setTracking(enabled: boolean): void;
|
|
101
|
+
/** Whether dirty attribution is currently being recorded. */
|
|
102
|
+
get trackingEnabled(): boolean;
|
|
103
|
+
/** Recorded dirty attributions, most frequent first. */
|
|
104
|
+
get sortedReasons(): DirtyReasonEntry[];
|
|
105
|
+
/** Drop recorded attributions, keeping tracking enabled. */
|
|
106
|
+
clearReasons(): void;
|
|
107
|
+
}
|