/** * The content projection's **selection preservation** and **grid calibration** — * the two halves of extraction 3 that are separable today. * * Extraction 3 of the `Scene.ts` decomposition * (`forge/decisions/file-decomposition-2026-08.md` §2), reduced in scope by * carryctx `DEC-0022` for the same measured reason `DEC-0020` reduced extraction * 2: the projection *walk* is not separable yet. * * ## What this owns * * **Selection across rebuilds.** A streaming message replaces its projection * children on every appended chunk, and a naive rebuild wipes a selection the * user made in the unchanged prefix. This owns the tracked drag anchor, the * blank-region drag flag, the per-walk "is anything selected at all" memo, and * the snapshot/restore of selection endpoints as linear character offsets. * * **Grid calibration.** The cold read/write batch that measures a projected * grid's real laid-out text and writes a per-cell `scaleX`, its generation * stamping, its probe DOM, and the pending-frame/probe bookkeeping that lets a * teardown cancel work in flight. * * ## What it deliberately does not own * * `syncContentProjection` (624 lines) and its helpers stay on `Scene`. It is the * far side of the shared walk `DEC-0020` measured: `syncA11y` calls it at its own * recursion point, and it reads the four per-sync fields `syncA11y` initialises * (`_syncSerial`, `contentSemanticBudgetLeft`, `contentSemanticDeferred`, * `contentSelectionPresentThisSync`). The two walk drivers move together, once * they can be cut as a pair rather than threaded through each other — taking * either alone needs a back-edge, which `DEC-0019` rule 1 forbids. * * `getContentMetricScaleX` also stays: it reads `canvas` and `width`, which * `resize` mutates, so it is extraction 5's state. Its result is already a * parameter of {@link scheduleGridCalibration}, so nothing here reaches for it. * * ## What is passed in, and why * * `a11yRoot` is injected because it is assigned once in `Scene`'s constructor and * never reassigned, so it is safe to hold. {@link PhaseTimer} is injected because * it is the shared leaf `DEC-0021` extracted for exactly this: the calibration * pass records `calibScan` and `calibProbeBuild`, and reaching a `Scene` private * to do it — or holding a bound `Scene` method — is the rule 1 violation this * whole sequence is avoiding. * * The font epoch is a per-call argument rather than held state (`DEC-0019` rule * 5): a font load and a resize both bump it, and both belong to other domains. */ import { PhaseTimer } from './PhaseTimer'; import { type TextCaretPosition } from './content-caret'; export declare class ContentProjectionManager { /** * `a11yRoot` doubles as the projection root: carriers are appended to it, and * the calibration probe is parented under it so CSS zoom and font substitution * match the live carriers. `null` in non-DOM (SSR/Node) environments. */ private readonly a11yRoot; private readonly phases; /** * True while a drag that started in a blank region of the projection is live. * Mid-drag the browser is authoritative, so a rebuild must not try to preserve * anything. */ private blankRegionDrag; /** Tracked drag anchor. Survives a drag, unlike the live DOM selection. */ private anchor; /** * Memo for {@link selectionPresent}, valid for one sync walk. * * Reading any `Selection` property forces a synchronous layout, so the answer * is resolved once per walk rather than once per rebuilt element. `null` means * "not yet asked this walk". */ private presentThisSync; /** Pending calibration rAF handle per projected grid entity. */ private readonly calibrationFrameHandles; /** Detached, untransformed font probes used by the cold calibration pass. */ private readonly calibrationProbes; /** * Bumped when the conditions a measurement depends on change, which * invalidates every existing per-cell `scaleX` at once without touching them. */ private calibrationGeneration; /** The conditions the current generation was measured under. */ private calibrationStamp; constructor(a11yRoot: HTMLDivElement | null, phases: PhaseTimer); /** Pending calibration frames, keyed by entity id. Read by the e2e lifecycle probe. */ get calibrationFrames(): ReadonlyMap; /** * Start tracking a drag that began in a blank region of the projection. * * Called from the projection root's `mousedown` handler, which has already * resolved the caret and collapsed or extended the live selection. */ beginBlankRegionDrag(anchor: TextCaretPosition): void; /** The tracked drag anchor, for extending a selection as the pointer moves. */ get selectionAnchor(): TextCaretPosition | null; /** * Whether a manually-driven blank-region drag is live. * * The projection root's `mousemove` handler gates on this: with no native * anchor, the browser will not extend the selection itself. */ get blankRegionDragActive(): boolean; /** * Drop the memo describing whether the document holds a selection. * * Called at the top of each a11y sync walk: the memo answers a question about * the live document, and a value from an earlier frame would be wrong. */ invalidateSelectionMemo(): void; endDrag(): void; /** * Index of the carrier line currently holding a selection inside `el`, or * `null`. * * Lets a partial re-materialization decide whether the user's selection is even * affected. Checks the tracked anchor first (it survives a drag) and falls back * to the live DOM selection. */ gridSelectionLine(el: HTMLElement): number | null; /** * Does the document hold a selection right now, memoized for this sync walk? * * Pays one forced layout per walk instead of one per rebuilt element — see * {@link presentThisSync} for the measurements. When the answer is `false` no * element can own a selection, so every per-element ownership test can be * skipped without touching the object. */ selectionPresent(): boolean; releaseSelectionForRebuild(el: HTMLElement): void; /** * Rebuild a content-projection element's DOM (`rebuild`) while preserving a * text selection the user made inside it. A streaming message replaces its * projection children on every appended chunk; without this, a selection in * the UNCHANGED prefix is wiped on each frame ("can't select text in a * message still receiving tokens"). We snapshot the selection's anchor/focus * as linear character offsets within `el` before the rebuild and re-resolve * them against the new DOM after, clamped to the new text length. * * Only fires when `el` owns the current selection and there is no active drag * (mid-drag the browser is authoritative). The virtualization case — where * `el` itself is removed from the DOM — is out of scope here (the node is * genuinely freed; the browser clears the selection and there is nothing to * restore against). */ preserveSelectionAcrossRebuild(el: HTMLElement, rebuild: () => void): void; /** * A selection inside a projected grid, expressed as offsets into `grid.source`. * * Source offsets rather than the linear DOM offsets * {@link preserveSelectionAcrossRebuild} uses, because the grid path windows its * carriers: the DOM holds only the lines near the viewport, so linear offset 0 * means "the first line that happens to be materialized" and moves whenever the * window does. A reflow changes both the line breaks and the window, so a linear * offset would restore the selection onto different characters. Every carrier * cell already records its own `sourceStart`/`sourceEnd`, which are stable * against line breaking, windowing, and per-cell calibration. */ private gridSelectionEndpointOffset; /** * Where in `grid.source` the live selection sits, or `null` when this element * does not own one that can be expressed that way. * * Cheap-rejects exactly as {@link releaseSelectionForRebuild} does: the tracked * anchor is a local field, and the memo costs one forced layout per sync walk * rather than one per element. */ snapshotGridSelection(el: HTMLElement): { anchor: number; focus: number; } | null; /** The carrier caret for a source offset, or `null` when it is not projected. */ private gridCaretAtSourceOffset; /** * Put a {@link snapshotGridSelection} result back after a re-materialization, * releasing instead whenever the selected text is no longer projected. * * Restoring is what keeps a selection alive across a reflow or a browser zoom, * where every carrier line is rebuilt (the line breaks moved) but the selected * characters are still on screen. When the window scrolled past them instead, * the offsets resolve to nothing and the selection is dropped — a `Range` left * pointing into detached carriers reports stale geometry and copies the wrong * text. */ restoreGridSelection(el: HTMLElement, snapshot: { anchor: number; focus: number; } | null): void; /** * Reset per-grid calibration and bookkeeping before a (re)materialization. * * @param entityId - Owning entity, keyed into the calibration maps. * @param el - The projection element. * @param releaseSelection - Whether to drop a selection this element owns. * Pass `false` when carrier lines are being reused: the selection's DOM nodes * survive the pass, so tearing it down would wipe a user's selection on every * streamed chunk — the exact bug {@link preserveSelectionAcrossRebuild} * exists to prevent on the non-grid path. */ clearGridState(entityId: string, el: HTMLElement, releaseSelection?: boolean): void; /** * Cancel every calibration in flight and drop every probe. * * For `Scene.destroy()`: an outstanding rAF would otherwise run against a * destroyed scene, and a probe left in the document keeps a detached subtree * alive. */ dispose(): void; /** * Measure a projected grid's real laid-out text and write a per-cell `scaleX`. * * @param fontEpoch - `Scene`'s font epoch. Passed in rather than held: a font * load and a resize both bump it, and both belong to other domains. */ scheduleGridCalibration(entityId: string, el: HTMLElement, calibrationKey: string, pageScaleX: number, fontEpoch: number): void; }