@vectojs/core 1.20.0 → 1.22.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-YLH7F4ZV.js → chunk-AGP4VLF4.js} +32 -0
- package/dist/{chunk-TJCXB2F6.mjs → chunk-FRMLD4PP.mjs} +32 -0
- package/dist/{chunk-L4SWVP2H.js → chunk-LHONR3GO.js} +336 -38
- package/dist/{chunk-QS3CUV7H.mjs → chunk-UPULSLKA.mjs} +298 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +451 -218
- package/dist/index.mjs +253 -20
- package/dist/renderer/CanvasRenderer.d.ts +20 -1
- package/dist/renderer/GlyphRasterAtlas.d.ts +186 -0
- package/dist/renderer/IRenderer.d.ts +94 -0
- package/dist/renderer/SVGRenderer.d.ts +2 -0
- package/dist/renderer/WebGLPointRenderer.d.ts +33 -0
- package/dist/renderer/index.d.ts +1 -0
- package/dist/renderer.js +4 -2
- package/dist/renderer.mjs +3 -1
- package/dist/text.js +2 -2
- package/dist/text.mjs +1 -1
- package/dist/tree/Entity.d.ts +128 -0
- package/dist/tree/Scene.d.ts +132 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanvasRenderer,
|
|
3
|
+
GlyphRasterAtlas,
|
|
3
4
|
SVGRenderer,
|
|
4
5
|
TextRasterCache,
|
|
5
6
|
WebGPUParticleSystemManager,
|
|
@@ -7,13 +8,13 @@ import {
|
|
|
7
8
|
isSafeUrl,
|
|
8
9
|
parseColorToRGBA,
|
|
9
10
|
sanitizeUrl
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-UPULSLKA.mjs";
|
|
11
12
|
import {
|
|
12
13
|
Entity,
|
|
13
14
|
MSDFTextEntity,
|
|
14
15
|
SVGEntity,
|
|
15
16
|
VectoJSEvent
|
|
16
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-FRMLD4PP.mjs";
|
|
17
18
|
|
|
18
19
|
// src/tree/Scene.ts
|
|
19
20
|
import { SpringDriver, TweenDriver } from "@vectojs/animation";
|
|
@@ -1687,6 +1688,29 @@ var Scene = class _Scene {
|
|
|
1687
1688
|
contentGridCalibrationFrames = /* @__PURE__ */ new Map();
|
|
1688
1689
|
/** Detached, untransformed font probes used by the cold calibration pass. */
|
|
1689
1690
|
contentGridCalibrationProbes = /* @__PURE__ */ new Map();
|
|
1691
|
+
/**
|
|
1692
|
+
* Monotonic stamp identifying the conditions grid cells were calibrated under.
|
|
1693
|
+
*
|
|
1694
|
+
* Calibration measures the difference between the advance the canvas grid assigns
|
|
1695
|
+
* a cluster and the width the browser lays it out at, then writes a per-cell
|
|
1696
|
+
* `scaleX`. That result stays valid until the font or the page scale changes, and
|
|
1697
|
+
* it lives on the cell element — so a cell carrying this stamp needs no further
|
|
1698
|
+
* work.
|
|
1699
|
+
*
|
|
1700
|
+
* The scan that feeds calibration was O(cells) on every revision bump: for a
|
|
1701
|
+
* streaming code block it re-derived a measurement key for every cell in the
|
|
1702
|
+
* block each frame in order to produce only ~20 distinct keys, costing about
|
|
1703
|
+
* 2.5 ms/frame after the `style.font` fix and still over half of `a11ySync`. Since
|
|
1704
|
+
* carrier reuse (#244) leaves untouched lines — and therefore their calibrated
|
|
1705
|
+
* transforms — in place, cells stamped with the current generation can simply be
|
|
1706
|
+
* skipped, making the scan O(new cells) instead.
|
|
1707
|
+
*
|
|
1708
|
+
* A plain incrementing integer rather than the descriptive calibration key,
|
|
1709
|
+
* because it goes into an attribute selector and must not need escaping.
|
|
1710
|
+
*/
|
|
1711
|
+
contentGridCalibrationGeneration = 0;
|
|
1712
|
+
/** The `(fontEpoch, pageScale)` pair the current generation corresponds to. */
|
|
1713
|
+
contentGridCalibrationStamp = "";
|
|
1690
1714
|
/** Invalidates grid font calibration after browser font availability changes. */
|
|
1691
1715
|
contentFontEpoch = 0;
|
|
1692
1716
|
/** Cached Canvas-to-client scale for the current font/viewport epoch. */
|
|
@@ -2438,6 +2462,27 @@ var Scene = class _Scene {
|
|
|
2438
2462
|
get webgpuDisabled() {
|
|
2439
2463
|
return this._webgpuDisabled || this.particleBackend === "cpu";
|
|
2440
2464
|
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Draw accounting for the WebGL point layer, or null when that layer is not in
|
|
2467
|
+
* use.
|
|
2468
|
+
*
|
|
2469
|
+
* Null and all-zero mean different things: null is "this backend is not
|
|
2470
|
+
* running", zero is "it ran and drew nothing". A readout that conflates them
|
|
2471
|
+
* sends someone looking for a performance problem in a backend that was never
|
|
2472
|
+
* active.
|
|
2473
|
+
*/
|
|
2474
|
+
get webglDrawStats() {
|
|
2475
|
+
return this.pointRenderer?.stats?.() ?? null;
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Whether a WebGPU device is currently live for particle compute.
|
|
2479
|
+
*
|
|
2480
|
+
* The WebGPU path only activates when a `ComputeParticleEntity` is present, so
|
|
2481
|
+
* most scenes never touch it.
|
|
2482
|
+
*/
|
|
2483
|
+
get webgpuActive() {
|
|
2484
|
+
return this.device !== null && !this.deviceLost;
|
|
2485
|
+
}
|
|
2441
2486
|
set webgpuDisabled(value) {
|
|
2442
2487
|
this._webgpuDisabled = value;
|
|
2443
2488
|
}
|
|
@@ -2795,6 +2840,29 @@ var Scene = class _Scene {
|
|
|
2795
2840
|
this.contentSelectionAnchor = null;
|
|
2796
2841
|
if (this.a11yRoot) this.a11yRoot.style.pointerEvents = "none";
|
|
2797
2842
|
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Index of the carrier line currently holding a selection inside `el`, or
|
|
2845
|
+
* `null`.
|
|
2846
|
+
*
|
|
2847
|
+
* Lets a partial re-materialization decide whether the user's selection is even
|
|
2848
|
+
* affected. Checks the tracked anchor first (it survives a drag) and falls back
|
|
2849
|
+
* to the live DOM selection.
|
|
2850
|
+
*/
|
|
2851
|
+
contentGridSelectionLine(el) {
|
|
2852
|
+
const candidates = [this.contentSelectionAnchor?.node];
|
|
2853
|
+
if (typeof window !== "undefined" && typeof window.getSelection === "function") {
|
|
2854
|
+
const selection = window.getSelection();
|
|
2855
|
+
candidates.push(selection?.anchorNode, selection?.focusNode);
|
|
2856
|
+
}
|
|
2857
|
+
for (const candidate of candidates) {
|
|
2858
|
+
if (!candidate || !el.contains(candidate)) continue;
|
|
2859
|
+
let cursor = candidate;
|
|
2860
|
+
while (cursor && cursor.parentNode !== el) cursor = cursor.parentNode;
|
|
2861
|
+
const lineIndex = cursor?.dataset?.vectoGridLine;
|
|
2862
|
+
if (lineIndex !== void 0) return Number(lineIndex);
|
|
2863
|
+
}
|
|
2864
|
+
return null;
|
|
2865
|
+
}
|
|
2798
2866
|
releaseContentSelectionForRebuild(el) {
|
|
2799
2867
|
const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
2800
2868
|
const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
|
|
@@ -2880,7 +2948,18 @@ var Scene = class _Scene {
|
|
|
2880
2948
|
this.registerActiveDriverSubtree(entity);
|
|
2881
2949
|
return this;
|
|
2882
2950
|
}
|
|
2883
|
-
|
|
2951
|
+
/**
|
|
2952
|
+
* Reset per-grid calibration and bookkeeping before a (re)materialization.
|
|
2953
|
+
*
|
|
2954
|
+
* @param entityId - Owning entity, keyed into the calibration maps.
|
|
2955
|
+
* @param el - The projection element.
|
|
2956
|
+
* @param releaseSelection - Whether to drop a selection this element owns.
|
|
2957
|
+
* Pass `false` when carrier lines are being reused: the selection's DOM nodes
|
|
2958
|
+
* survive the pass, so tearing it down would wipe a user's selection on every
|
|
2959
|
+
* streamed chunk — the exact bug `preserveContentSelectionAcrossRebuild`
|
|
2960
|
+
* exists to prevent on the non-grid path.
|
|
2961
|
+
*/
|
|
2962
|
+
clearContentGridState(entityId, el, releaseSelection = true) {
|
|
2884
2963
|
const calibrationFrame = this.contentGridCalibrationFrames.get(entityId);
|
|
2885
2964
|
if (calibrationFrame !== void 0 && typeof cancelAnimationFrame === "function") {
|
|
2886
2965
|
cancelAnimationFrame(calibrationFrame);
|
|
@@ -2896,7 +2975,7 @@ var Scene = class _Scene {
|
|
|
2896
2975
|
delete el.dataset.vectoGridMaterializeMs;
|
|
2897
2976
|
delete el.dataset.vectoGridCalibrationSamples;
|
|
2898
2977
|
delete el.dataset.vectoGridCalibrationMs;
|
|
2899
|
-
this.releaseContentSelectionForRebuild(el);
|
|
2978
|
+
if (releaseSelection) this.releaseContentSelectionForRebuild(el);
|
|
2900
2979
|
}
|
|
2901
2980
|
/**
|
|
2902
2981
|
* Drop any projected elements under `node` without touching the entity tree.
|
|
@@ -3226,6 +3305,26 @@ var Scene = class _Scene {
|
|
|
3226
3305
|
get overlayRootEntity() {
|
|
3227
3306
|
return this.overlayRoot;
|
|
3228
3307
|
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Advance and render exactly one frame, synchronously.
|
|
3310
|
+
*
|
|
3311
|
+
* This renders UNCONDITIONALLY: it consults neither {@link renderMode} nor
|
|
3312
|
+
* {@link dirty}, and it does not apply the `always`-mode idle auto-throttle.
|
|
3313
|
+
* That is deliberate — a deterministic driver (video export, a test, a
|
|
3314
|
+
* fixed-step benchmark) asks for a frame because it wants that frame, not a
|
|
3315
|
+
* scheduler opinion about whether it is needed.
|
|
3316
|
+
*
|
|
3317
|
+
* The consequence is a measurement footgun worth stating explicitly: a
|
|
3318
|
+
* benchmark that drives frames through `step()` CANNOT observe frame skipping,
|
|
3319
|
+
* so `always` and `onDemand` produce byte-identical draw counts through this
|
|
3320
|
+
* path. An investigation into whether `onDemand` skips redundant repaints once
|
|
3321
|
+
* concluded "it does not" on exactly that basis; on the real rAF loop the same
|
|
3322
|
+
* workload rendered ~1.0 frames per content change. To measure anything about
|
|
3323
|
+
* scheduling, use {@link start} and let `requestAnimationFrame` drive.
|
|
3324
|
+
*
|
|
3325
|
+
* @param dt Seconds to advance. Not clamped by `MAX_FRAME_DT` — the caller
|
|
3326
|
+
* chooses the step, since determinism is the point.
|
|
3327
|
+
*/
|
|
3229
3328
|
step(dt) {
|
|
3230
3329
|
const time = this.lastTime + dt;
|
|
3231
3330
|
this.lastTime = time;
|
|
@@ -3244,6 +3343,24 @@ var Scene = class _Scene {
|
|
|
3244
3343
|
this.dirty = true;
|
|
3245
3344
|
if (this._dirtyTracking && source) this.recordDirtyReason(source);
|
|
3246
3345
|
}
|
|
3346
|
+
/**
|
|
3347
|
+
* Increments whenever the tree's shape changes: add, remove or reparent.
|
|
3348
|
+
*
|
|
3349
|
+
* Already maintained for the resident WASM transform store (see
|
|
3350
|
+
* {@link markStructureChanged}, called from `Entity.add`/`remove`), and exposed
|
|
3351
|
+
* here because a cache of the tree's shape — a DevTools tree model, a serialized
|
|
3352
|
+
* snapshot — is valid exactly as long as this value is unchanged. Comparing it is
|
|
3353
|
+
* O(1) against re-walking the tree, which is what it replaces: DevTools rebuilt
|
|
3354
|
+
* both trees on a fixed 500 ms interval, a constant cost proportional to entity
|
|
3355
|
+
* count, purely because it had no way to ask whether the shape had changed.
|
|
3356
|
+
*
|
|
3357
|
+
* Property changes do NOT bump it. Moving or restyling an entity leaves the
|
|
3358
|
+
* shape intact, so a consumer that also cares about values must read those
|
|
3359
|
+
* directly rather than rebuilding a tree.
|
|
3360
|
+
*/
|
|
3361
|
+
get structureVersion() {
|
|
3362
|
+
return this._structureVersion;
|
|
3363
|
+
}
|
|
3247
3364
|
/**
|
|
3248
3365
|
* Record who marked the scene dirty and why.
|
|
3249
3366
|
*
|
|
@@ -3370,6 +3487,7 @@ var Scene = class _Scene {
|
|
|
3370
3487
|
this.pruneA11ySubtree(node);
|
|
3371
3488
|
return;
|
|
3372
3489
|
}
|
|
3490
|
+
const nodeStart = this._phaseTiming ? performance.now() : 0;
|
|
3373
3491
|
if (this.shouldProjectA11y(node)) {
|
|
3374
3492
|
let el = this.a11yElements.get(node.id);
|
|
3375
3493
|
const attrs = node.getA11yAttributes();
|
|
@@ -3712,7 +3830,14 @@ var Scene = class _Scene {
|
|
|
3712
3830
|
if (el.style.display !== display) el.style.display = display;
|
|
3713
3831
|
}
|
|
3714
3832
|
}
|
|
3715
|
-
this.
|
|
3833
|
+
if (this._phaseTiming) {
|
|
3834
|
+
const projectionStart = performance.now();
|
|
3835
|
+
this.syncContentProjection(node);
|
|
3836
|
+
this._recordPhase("contentProjection", performance.now() - projectionStart);
|
|
3837
|
+
this._recordPhase("a11yNodes", projectionStart - nodeStart);
|
|
3838
|
+
} else {
|
|
3839
|
+
this.syncContentProjection(node);
|
|
3840
|
+
}
|
|
3716
3841
|
for (const child of node.children) this.syncA11y(child);
|
|
3717
3842
|
if (node === this.root) {
|
|
3718
3843
|
for (const overlay of this.overlayRoot.children) this.syncA11y(overlay);
|
|
@@ -3826,7 +3951,9 @@ var Scene = class _Scene {
|
|
|
3826
3951
|
this.clearContentGridState(node.id, el);
|
|
3827
3952
|
}
|
|
3828
3953
|
if (projection.grid) {
|
|
3954
|
+
const gridSyncStart = this._phaseTiming ? performance.now() : 0;
|
|
3829
3955
|
this.syncContentGridProjection(node, el, projection, projection.grid);
|
|
3956
|
+
if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
|
|
3830
3957
|
} else if (lines && lines.length > 0) {
|
|
3831
3958
|
const signature = JSON.stringify({
|
|
3832
3959
|
lines,
|
|
@@ -3932,16 +4059,33 @@ var Scene = class _Scene {
|
|
|
3932
4059
|
const signature = `${grid.revision}`;
|
|
3933
4060
|
if (el.dataset.vectoContentGrid !== signature) {
|
|
3934
4061
|
const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
|
|
3935
|
-
this.clearContentGridState(node.id, el);
|
|
3936
|
-
el.replaceChildren();
|
|
4062
|
+
this.clearContentGridState(node.id, el, false);
|
|
3937
4063
|
const projectionLines = projection.lines ?? [];
|
|
4064
|
+
const selectionLine = this.contentGridSelectionLine(el);
|
|
4065
|
+
let rebuiltSelectionLine = false;
|
|
4066
|
+
const existingLines = el.children;
|
|
3938
4067
|
for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
|
|
3939
4068
|
const gridLine = grid.lines[lineIndex];
|
|
3940
4069
|
const projectedLine = projectionLines[lineIndex];
|
|
3941
4070
|
const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
|
|
3942
4071
|
const baseline = projectedLine?.baseline ?? grid.baseline;
|
|
3943
4072
|
const lineFont = projectedLine?.font ?? grid.font;
|
|
4073
|
+
const lineSignature = contentGridLineSignature(
|
|
4074
|
+
grid,
|
|
4075
|
+
gridLine,
|
|
4076
|
+
projectedLine,
|
|
4077
|
+
lineHeight,
|
|
4078
|
+
baseline,
|
|
4079
|
+
lineFont,
|
|
4080
|
+
lineIndex === 0
|
|
4081
|
+
);
|
|
4082
|
+
const reusable = existingLines[lineIndex];
|
|
4083
|
+
if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
|
|
4084
|
+
continue;
|
|
4085
|
+
}
|
|
4086
|
+
if (selectionLine !== null && selectionLine === lineIndex) rebuiltSelectionLine = true;
|
|
3944
4087
|
const lineElement = document.createElement("span");
|
|
4088
|
+
lineElement.dataset.vectoGridLineSig = lineSignature;
|
|
3945
4089
|
lineElement.dir = "ltr";
|
|
3946
4090
|
lineElement.dataset.vectoGridLine = `${lineIndex}`;
|
|
3947
4091
|
lineElement.style.position = "absolute";
|
|
@@ -3983,6 +4127,8 @@ var Scene = class _Scene {
|
|
|
3983
4127
|
cellElement.style.font = lineFont;
|
|
3984
4128
|
cellElement.style.lineHeight = `${lineHeight}px`;
|
|
3985
4129
|
cellElement.style.transformOrigin = "0 50%";
|
|
4130
|
+
cellElement.dataset.vectoGridFont = lineFont;
|
|
4131
|
+
cellElement.dataset.vectoGridLineHeight = `${lineHeight}px`;
|
|
3986
4132
|
lineElement.appendChild(cellElement);
|
|
3987
4133
|
logicalX += cell.advance;
|
|
3988
4134
|
}
|
|
@@ -4006,13 +4152,24 @@ var Scene = class _Scene {
|
|
|
4006
4152
|
lineElement.appendChild(marker);
|
|
4007
4153
|
}
|
|
4008
4154
|
}
|
|
4009
|
-
el.
|
|
4155
|
+
const occupant = el.children[lineIndex];
|
|
4156
|
+
if (occupant) el.replaceChild(lineElement, occupant);
|
|
4157
|
+
else el.appendChild(lineElement);
|
|
4010
4158
|
}
|
|
4159
|
+
while (el.children.length > grid.lines.length) {
|
|
4160
|
+
if (selectionLine !== null && selectionLine >= grid.lines.length) {
|
|
4161
|
+
rebuiltSelectionLine = true;
|
|
4162
|
+
}
|
|
4163
|
+
el.lastElementChild?.remove();
|
|
4164
|
+
}
|
|
4165
|
+
if (rebuiltSelectionLine) this.releaseContentSelectionForRebuild(el);
|
|
4011
4166
|
el.dataset.vectoProjectionLines = signature;
|
|
4012
4167
|
el.dataset.vectoContentGrid = signature;
|
|
4013
4168
|
el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
|
|
4014
4169
|
if (typeof performance !== "undefined") {
|
|
4015
|
-
|
|
4170
|
+
const materializeMs = performance.now() - materializeStart;
|
|
4171
|
+
el.dataset.vectoGridMaterializeMs = `${materializeMs}`;
|
|
4172
|
+
if (this._phaseTiming) this._recordPhase("gridMaterialize", materializeMs);
|
|
4016
4173
|
}
|
|
4017
4174
|
delete el.dataset.vectoGridCalibration;
|
|
4018
4175
|
delete el.dataset.vectoGridReady;
|
|
@@ -4020,7 +4177,11 @@ var Scene = class _Scene {
|
|
|
4020
4177
|
const pageScaleX = this.getContentMetricScaleX();
|
|
4021
4178
|
const calibrationKey = `${signature}:${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
|
|
4022
4179
|
if (el.dataset.vectoGridCalibration !== calibrationKey) {
|
|
4180
|
+
const calibStart = this._phaseTiming ? performance.now() : 0;
|
|
4023
4181
|
this.scheduleContentGridCalibration(node.id, el, calibrationKey, pageScaleX);
|
|
4182
|
+
if (this._phaseTiming) {
|
|
4183
|
+
this._recordPhase("gridCalibrateSchedule", performance.now() - calibStart);
|
|
4184
|
+
}
|
|
4024
4185
|
}
|
|
4025
4186
|
}
|
|
4026
4187
|
getContentMetricScaleX() {
|
|
@@ -4038,6 +4199,27 @@ var Scene = class _Scene {
|
|
|
4038
4199
|
scheduleContentGridCalibration(entityId, el, calibrationKey, pageScaleX) {
|
|
4039
4200
|
if (typeof requestAnimationFrame !== "function") return;
|
|
4040
4201
|
if (el.dataset.vectoGridCalibrationPending === calibrationKey) return;
|
|
4202
|
+
const stamp = `${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
|
|
4203
|
+
if (this.contentGridCalibrationStamp !== stamp) {
|
|
4204
|
+
this.contentGridCalibrationStamp = stamp;
|
|
4205
|
+
this.contentGridCalibrationGeneration++;
|
|
4206
|
+
}
|
|
4207
|
+
const generation = `${this.contentGridCalibrationGeneration}`;
|
|
4208
|
+
const pendingCells = el.querySelectorAll(
|
|
4209
|
+
`[data-vecto-grid-cell]:not([data-vecto-grid-calib="${generation}"])`
|
|
4210
|
+
);
|
|
4211
|
+
if (pendingCells.length === 0) {
|
|
4212
|
+
el.dataset.vectoGridCalibrationSamples = "0";
|
|
4213
|
+
delete el.dataset.vectoGridCalibrationPending;
|
|
4214
|
+
const readyFrame = requestAnimationFrame(() => {
|
|
4215
|
+
this.contentGridCalibrationFrames.delete(entityId);
|
|
4216
|
+
if (!el.isConnected) return;
|
|
4217
|
+
el.dataset.vectoGridCalibration = calibrationKey;
|
|
4218
|
+
el.dataset.vectoGridReady = "true";
|
|
4219
|
+
});
|
|
4220
|
+
this.contentGridCalibrationFrames.set(entityId, readyFrame);
|
|
4221
|
+
return;
|
|
4222
|
+
}
|
|
4041
4223
|
const previous = this.contentGridCalibrationFrames.get(entityId);
|
|
4042
4224
|
if (previous !== void 0 && typeof cancelAnimationFrame === "function") {
|
|
4043
4225
|
cancelAnimationFrame(previous);
|
|
@@ -4068,18 +4250,22 @@ var Scene = class _Scene {
|
|
|
4068
4250
|
probe.append(probeOrigin, probeX);
|
|
4069
4251
|
const measurements = [];
|
|
4070
4252
|
const measurementsByKey = /* @__PURE__ */ new Map();
|
|
4071
|
-
|
|
4253
|
+
const scanStart = this._phaseTiming ? performance.now() : 0;
|
|
4254
|
+
for (const target of pendingCells) {
|
|
4072
4255
|
const sourceLength = Number(target.dataset.vectoGridSourceLength ?? 0);
|
|
4073
4256
|
const targetWidth = Number(target.dataset.vectoGridAdvance ?? 0);
|
|
4074
|
-
if (sourceLength <= 0 || targetWidth <= 0)
|
|
4257
|
+
if (sourceLength <= 0 || targetWidth <= 0) {
|
|
4258
|
+
target.dataset.vectoGridCalib = generation;
|
|
4259
|
+
continue;
|
|
4260
|
+
}
|
|
4075
4261
|
const sourceText = target.textContent?.slice(0, sourceLength) ?? "";
|
|
4076
|
-
if (!sourceText)
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
]);
|
|
4262
|
+
if (!sourceText) {
|
|
4263
|
+
target.dataset.vectoGridCalib = generation;
|
|
4264
|
+
continue;
|
|
4265
|
+
}
|
|
4266
|
+
const cellFont = target.dataset.vectoGridFont ?? "";
|
|
4267
|
+
const cellLineHeight = target.dataset.vectoGridLineHeight ?? "";
|
|
4268
|
+
const measurementKey = JSON.stringify([cellFont, cellLineHeight, targetWidth, sourceText]);
|
|
4083
4269
|
const shared = measurementsByKey.get(measurementKey);
|
|
4084
4270
|
if (shared) {
|
|
4085
4271
|
shared.targets.push(target);
|
|
@@ -4091,8 +4277,8 @@ var Scene = class _Scene {
|
|
|
4091
4277
|
carrier.style.left = "0";
|
|
4092
4278
|
carrier.style.top = "0";
|
|
4093
4279
|
carrier.style.whiteSpace = "pre";
|
|
4094
|
-
carrier.style.font =
|
|
4095
|
-
carrier.style.lineHeight =
|
|
4280
|
+
carrier.style.font = cellFont;
|
|
4281
|
+
carrier.style.lineHeight = cellLineHeight;
|
|
4096
4282
|
carrier.style.fontVariantLigatures = "none";
|
|
4097
4283
|
carrier.style.fontKerning = "none";
|
|
4098
4284
|
const source = document.createTextNode(sourceText);
|
|
@@ -4107,7 +4293,17 @@ var Scene = class _Scene {
|
|
|
4107
4293
|
measurements.push(measurement);
|
|
4108
4294
|
measurementsByKey.set(measurementKey, measurement);
|
|
4109
4295
|
}
|
|
4296
|
+
if (this._phaseTiming) this._recordPhase("calibScan", performance.now() - scanStart);
|
|
4297
|
+
if (measurements.length === 0) {
|
|
4298
|
+
el.dataset.vectoGridCalibration = calibrationKey;
|
|
4299
|
+
el.dataset.vectoGridReady = "true";
|
|
4300
|
+
el.dataset.vectoGridCalibrationSamples = "0";
|
|
4301
|
+
delete el.dataset.vectoGridCalibrationPending;
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
4304
|
+
const appendStart = this._phaseTiming ? performance.now() : 0;
|
|
4110
4305
|
(this.a11yRoot ?? document.body ?? document.documentElement).appendChild(probe);
|
|
4306
|
+
if (this._phaseTiming) this._recordPhase("calibProbeBuild", performance.now() - appendStart);
|
|
4111
4307
|
el.dataset.vectoGridCalibrationSamples = `${measurements.length}`;
|
|
4112
4308
|
this.contentGridCalibrationProbes.set(entityId, probe);
|
|
4113
4309
|
el.dataset.vectoGridCalibrationPending = calibrationKey;
|
|
@@ -4151,6 +4347,7 @@ var Scene = class _Scene {
|
|
|
4151
4347
|
}
|
|
4152
4348
|
for (const { element, scale } of updates) {
|
|
4153
4349
|
element.style.transform = Math.abs(scale - 1) <= 1e-3 ? "" : `scaleX(${scale})`;
|
|
4350
|
+
element.dataset.vectoGridCalib = generation;
|
|
4154
4351
|
}
|
|
4155
4352
|
el.dataset.vectoGridCalibration = calibrationKey;
|
|
4156
4353
|
el.dataset.vectoGridReady = "true";
|
|
@@ -5091,6 +5288,41 @@ function intersectBounds(a, b) {
|
|
|
5091
5288
|
function pointInBounds(b, x, y) {
|
|
5092
5289
|
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
|
|
5093
5290
|
}
|
|
5291
|
+
function contentGridLineSignature(grid, line, projected, lineHeight, baseline, font, isFirstLine) {
|
|
5292
|
+
const parts = [
|
|
5293
|
+
// Line box: position, size, and the font that resolves its baseline.
|
|
5294
|
+
`${projected?.x ?? 0}`,
|
|
5295
|
+
`${projected?.y ?? ""}`,
|
|
5296
|
+
`${lineHeight}`,
|
|
5297
|
+
`${baseline}`,
|
|
5298
|
+
font,
|
|
5299
|
+
`${line.width}`,
|
|
5300
|
+
// The trailing hard break belongs to this line and lands in the DOM text.
|
|
5301
|
+
grid.source.slice(line.sourceEnd, line.nextSourceStart),
|
|
5302
|
+
// The basis markers are appended only to line 0, so a line moving to or from
|
|
5303
|
+
// index 0 changes its DOM even when nothing else does.
|
|
5304
|
+
isFirstLine ? "1" : "0"
|
|
5305
|
+
];
|
|
5306
|
+
if (line.cells.length === 0) {
|
|
5307
|
+
parts.push("empty");
|
|
5308
|
+
} else {
|
|
5309
|
+
for (const cell of line.cells) {
|
|
5310
|
+
parts.push(
|
|
5311
|
+
`${cell.sourceStart}`,
|
|
5312
|
+
`${cell.sourceEnd}`,
|
|
5313
|
+
`${cell.x}`,
|
|
5314
|
+
`${cell.advance}`,
|
|
5315
|
+
`${cell.level}`,
|
|
5316
|
+
cell.sourceCaretOffsets.join("."),
|
|
5317
|
+
// Source text, not `cell.glyph`: the carrier holds the original characters
|
|
5318
|
+
// (the shaped glyph is the canvas's business), so a change in shaping alone
|
|
5319
|
+
// must not invalidate a carrier, and a change in source must.
|
|
5320
|
+
grid.source.slice(cell.sourceStart, cell.sourceEnd)
|
|
5321
|
+
);
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
return parts.join("");
|
|
5325
|
+
}
|
|
5094
5326
|
|
|
5095
5327
|
// src/components/TextEntity.ts
|
|
5096
5328
|
import {
|
|
@@ -5859,6 +6091,7 @@ export {
|
|
|
5859
6091
|
ComputeParticleEntity,
|
|
5860
6092
|
DOMPortalEntity,
|
|
5861
6093
|
Entity,
|
|
6094
|
+
GlyphRasterAtlas,
|
|
5862
6095
|
GridTextEntity,
|
|
5863
6096
|
Group,
|
|
5864
6097
|
MSDFTextEntity,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IRenderer } from './IRenderer';
|
|
1
|
+
import { type DrawCounters, IRenderer } from './IRenderer';
|
|
2
2
|
export declare class CanvasRenderer implements IRenderer {
|
|
3
3
|
private ctx;
|
|
4
4
|
private width;
|
|
@@ -33,6 +33,17 @@ export declare class CanvasRenderer implements IRenderer {
|
|
|
33
33
|
private batchCount;
|
|
34
34
|
private _cachedFont;
|
|
35
35
|
private _cachedFill;
|
|
36
|
+
/** Backend discriminator; see {@link IRenderer.kind}. */
|
|
37
|
+
readonly kind = "canvas2d";
|
|
38
|
+
/**
|
|
39
|
+
* Draw counters, allocated only once counting is enabled.
|
|
40
|
+
*
|
|
41
|
+
* Null when off, so the guard on every op is a single null test and an inactive
|
|
42
|
+
* renderer carries no counter object at all.
|
|
43
|
+
*/
|
|
44
|
+
private counters;
|
|
45
|
+
/** Accumulated primitive area, kept separately so the ratio is derived on read. */
|
|
46
|
+
private drawnArea;
|
|
36
47
|
/**
|
|
37
48
|
* @param canvas - The target canvas. Its backing store is resized to the
|
|
38
49
|
* logical size × devicePixelRatio.
|
|
@@ -79,6 +90,12 @@ export declare class CanvasRenderer implements IRenderer {
|
|
|
79
90
|
* restored). The owner skips its render pass while this is true. */
|
|
80
91
|
isContextLost(): boolean;
|
|
81
92
|
/** @inheritdoc */
|
|
93
|
+
/** @inheritdoc */
|
|
94
|
+
setDrawCounters(enabled: boolean): void;
|
|
95
|
+
/** @inheritdoc */
|
|
96
|
+
getDrawCounters(): DrawCounters | null;
|
|
97
|
+
/** @inheritdoc */
|
|
98
|
+
clearDrawCounters(): void;
|
|
82
99
|
clear(): void;
|
|
83
100
|
/** @inheritdoc */
|
|
84
101
|
save(): void;
|
|
@@ -111,6 +128,8 @@ export declare class CanvasRenderer implements IRenderer {
|
|
|
111
128
|
/** @inheritdoc */
|
|
112
129
|
drawImage(source: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
|
|
113
130
|
/** @inheritdoc */
|
|
131
|
+
drawImageRect(source: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
|
|
132
|
+
/** @inheritdoc */
|
|
114
133
|
fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
|
|
115
134
|
/** @inheritdoc */
|
|
116
135
|
flush(): void;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A texture atlas of rasterized glyphs, for grids that draw the *same small set
|
|
3
|
+
* of glyphs* thousands of times per frame.
|
|
4
|
+
*
|
|
5
|
+
* Named `GlyphRasterAtlas` rather than `GlyphAtlas` because `@vectojs/layout`
|
|
6
|
+
* already exports a `GlyphAtlas` interface — a map of grapheme to *vector* path
|
|
7
|
+
* metrics — and the core barrel re-exports that package, so the shorter name is a
|
|
8
|
+
* hard collision. The distinction is also worth keeping: that one holds path data
|
|
9
|
+
* for measuring, this one holds pixels for blitting.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this exists alongside {@link TextRasterCache}
|
|
12
|
+
*
|
|
13
|
+
* Both replace per-cell `fillText` with a bitmap blit. The difference is where
|
|
14
|
+
* the pixels live, and measurement says that difference decides whether the idea
|
|
15
|
+
* works at all.
|
|
16
|
+
*
|
|
17
|
+
* `TextRasterCache` allocates **one canvas per cached run**. A warm cache for a
|
|
18
|
+
* syntax-highlighted code grid holds a few hundred of them (glyphs x theme
|
|
19
|
+
* colours), so a frame blits from a few hundred distinct source textures and the
|
|
20
|
+
* GPU re-binds on nearly every call. Measured on real hardware, that per-source
|
|
21
|
+
* cost is invisible at 2k cells and dominant at 40k: Chrome went 1.82x at 2k to
|
|
22
|
+
* **0.87x at 40k** — slower than the `fillText` it replaced. Its per-call cost
|
|
23
|
+
* grows with cell count (1.22 -> 2.89 us) rather than staying flat.
|
|
24
|
+
*
|
|
25
|
+
* This atlas keeps every glyph in **one** canvas and selects with a source rect,
|
|
26
|
+
* so the source texture never changes. Same call count, same pixels, same
|
|
27
|
+
* geometry — and per-call cost is flat as the grid grows (Chrome 1.10 -> 1.11
|
|
28
|
+
* us), giving **1.90-2.27x over `fillText` on both engines at every size**.
|
|
29
|
+
* Full data: `vectojs-docs/forge/baselines/raster-cache-findings.md`.
|
|
30
|
+
*
|
|
31
|
+
* The win comes from *reuse*, so this is for bounded glyph sets: a monospace code
|
|
32
|
+
* grid, a terminal, a data grid, a numeric HUD. Prose is the wrong customer —
|
|
33
|
+
* every run is distinct, so an atlas is pure overhead (use `RichText`'s coalesced
|
|
34
|
+
* runs there instead).
|
|
35
|
+
*
|
|
36
|
+
* ## Requires a source-rect blit
|
|
37
|
+
*
|
|
38
|
+
* Selecting one slot needs {@link IRenderer.drawImageRect}, which is optional:
|
|
39
|
+
* `CanvasRenderer` implements it, `SVGRenderer` deliberately does not (an SVG
|
|
40
|
+
* blit embeds its source as a data URL, so a per-cell sub-rect would inline the
|
|
41
|
+
* whole atlas thousands of times — and vector text is the correct output for a
|
|
42
|
+
* vector export anyway). Callers must keep their `fillText` path for renderers
|
|
43
|
+
* that lack it:
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const slot = atlas.get(font, color, glyph);
|
|
47
|
+
* if (slot && r.drawImageRect) {
|
|
48
|
+
* r.drawImageRect(atlas.source, slot.sx, slot.sy, slot.sw, slot.sh,
|
|
49
|
+
* x - slot.offsetX, baselineY - slot.offsetY, slot.w, slot.h);
|
|
50
|
+
* } else {
|
|
51
|
+
* r.fillText(glyph, x, baselineY, font, color);
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
/** Where one glyph lives in the atlas, and how to blit it at a baseline. */
|
|
56
|
+
export interface GlyphSlot {
|
|
57
|
+
/** Source X in atlas *device* pixels. */
|
|
58
|
+
sx: number;
|
|
59
|
+
/** Source Y in atlas *device* pixels. */
|
|
60
|
+
sy: number;
|
|
61
|
+
/** Source width in atlas *device* pixels. */
|
|
62
|
+
sw: number;
|
|
63
|
+
/** Source height in atlas *device* pixels. */
|
|
64
|
+
sh: number;
|
|
65
|
+
/** Destination width in CSS pixels. */
|
|
66
|
+
w: number;
|
|
67
|
+
/** Destination height in CSS pixels. */
|
|
68
|
+
h: number;
|
|
69
|
+
/** Left inset (CSS px) of the glyph origin inside the slot. */
|
|
70
|
+
offsetX: number;
|
|
71
|
+
/** Distance (CSS px) from the slot top down to the text baseline. */
|
|
72
|
+
offsetY: number;
|
|
73
|
+
/** The cluster these pixels represent. */
|
|
74
|
+
glyph: string;
|
|
75
|
+
/** The CSS font shorthand these pixels were rasterized with. */
|
|
76
|
+
font: string;
|
|
77
|
+
/** Advance width (CSS px) of the cluster, i.e. `measureText().width`. */
|
|
78
|
+
advance: number;
|
|
79
|
+
/**
|
|
80
|
+
* Ink extent left of the glyph origin (CSS px), from `actualBoundingBoxLeft`.
|
|
81
|
+
*
|
|
82
|
+
* Carried on the slot so a blit can be mapped back to the same geometry a
|
|
83
|
+
* `fillText` would have produced. Without it, instrumentation that traces draw
|
|
84
|
+
* calls to verify grid positioning (`e2e/text-projection.e2e.ts`) can see only
|
|
85
|
+
* a destination rect and cannot recover where the glyph origin sat inside it.
|
|
86
|
+
*/
|
|
87
|
+
left: number;
|
|
88
|
+
/** Ink extent right of the glyph origin (CSS px), from `actualBoundingBoxRight`. */
|
|
89
|
+
right: number;
|
|
90
|
+
}
|
|
91
|
+
/** Instrumentation counters, e.g. to surface a HUD hit rate. */
|
|
92
|
+
export interface GlyphRasterAtlasStats {
|
|
93
|
+
/** Requests served from an existing slot. */
|
|
94
|
+
hits: number;
|
|
95
|
+
/** Requests that had to rasterize. */
|
|
96
|
+
misses: number;
|
|
97
|
+
/** Glyphs currently resident. */
|
|
98
|
+
size: number;
|
|
99
|
+
/**
|
|
100
|
+
* Times the atlas filled up and was reset.
|
|
101
|
+
*
|
|
102
|
+
* Steady-state thrash means the glyph set is unbounded for the configured
|
|
103
|
+
* size, and the atlas is doing net harm — every reset re-rasterizes everything.
|
|
104
|
+
* A caller that watches this can fall back to `fillText` permanently.
|
|
105
|
+
*/
|
|
106
|
+
resets: number;
|
|
107
|
+
}
|
|
108
|
+
/** Options for {@link GlyphRasterAtlas}. */
|
|
109
|
+
export interface GlyphRasterAtlasOptions {
|
|
110
|
+
/**
|
|
111
|
+
* Device-pixel-ratio to rasterize at. Slots record device pixels while `w`/`h`
|
|
112
|
+
* stay in CSS pixels, so the blit is DPR-correct without caller arithmetic.
|
|
113
|
+
* Default `1`.
|
|
114
|
+
*/
|
|
115
|
+
dpr?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Max atlas edge in device pixels, capped at 8192 — comfortably inside the
|
|
118
|
+
* lowest common `maxTextureSize` while leaving room for thousands of glyphs.
|
|
119
|
+
* Exceeding a browser's real limit yields a silently blank canvas, so this is
|
|
120
|
+
* clamped rather than trusted. Default `2048`.
|
|
121
|
+
*/
|
|
122
|
+
maxSize?: number;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A glyph atlas. Create one per renderer/scene — instances share no state, so
|
|
126
|
+
* multiple scenes or an SSR pass never collide.
|
|
127
|
+
*/
|
|
128
|
+
export declare class GlyphRasterAtlas {
|
|
129
|
+
private readonly slots;
|
|
130
|
+
private readonly dpr;
|
|
131
|
+
private readonly maxSize;
|
|
132
|
+
private canvas;
|
|
133
|
+
private ctx;
|
|
134
|
+
/**
|
|
135
|
+
* Shelf packing: glyphs land left-to-right on a row, then a new row starts.
|
|
136
|
+
* A monospace grid produces near-uniform widths, so shelves waste very little
|
|
137
|
+
* and cost one comparison per insert — a real 2D packer would buy nothing here.
|
|
138
|
+
*/
|
|
139
|
+
private penX;
|
|
140
|
+
private penY;
|
|
141
|
+
private rowHeight;
|
|
142
|
+
private _hits;
|
|
143
|
+
private _misses;
|
|
144
|
+
private _resets;
|
|
145
|
+
constructor(options?: GlyphRasterAtlasOptions);
|
|
146
|
+
/** Live instrumentation snapshot. */
|
|
147
|
+
get stats(): GlyphRasterAtlasStats;
|
|
148
|
+
/**
|
|
149
|
+
* The atlas canvas, to pass as the blit source.
|
|
150
|
+
*
|
|
151
|
+
* `null` until the first successful {@link get}, and in any non-DOM context.
|
|
152
|
+
*/
|
|
153
|
+
get source(): HTMLCanvasElement | null;
|
|
154
|
+
private ensureCanvas;
|
|
155
|
+
/**
|
|
156
|
+
* Look up a glyph, rasterizing it into the atlas on first request.
|
|
157
|
+
*
|
|
158
|
+
* @param font - Full CSS `font` shorthand, used for measuring and painting.
|
|
159
|
+
* @param color - CSS color baked into the pixels.
|
|
160
|
+
* @param glyph - A single grapheme cluster. Long strings are rejected
|
|
161
|
+
* (`null`): they defeat the atlas's fixed-slot packing and belong in
|
|
162
|
+
* `fillText` or {@link TextRasterCache}.
|
|
163
|
+
* @returns The slot, or `null` when the caller must fall back to `fillText`
|
|
164
|
+
* (headless, unrasterizable, or too large to pack).
|
|
165
|
+
*/
|
|
166
|
+
get(font: string, color: string, glyph: string): GlyphSlot | null;
|
|
167
|
+
/**
|
|
168
|
+
* Find the slot occupying a source position, or `null`.
|
|
169
|
+
*
|
|
170
|
+
* The inverse of {@link get}: it maps a blit back to the glyph it drew. Exists
|
|
171
|
+
* for instrumentation — a test or devtool that traces `drawImage` calls sees
|
|
172
|
+
* only a source rect, and needs this to recover which cluster was painted and
|
|
173
|
+
* with what metrics. Linear over resident slots, so it is a diagnostic, not a
|
|
174
|
+
* per-frame call.
|
|
175
|
+
*/
|
|
176
|
+
slotAt(sx: number, sy: number): GlyphSlot | null;
|
|
177
|
+
/**
|
|
178
|
+
* Drop every glyph and reuse the canvas.
|
|
179
|
+
*
|
|
180
|
+
* Call after a font or theme change: slots are keyed by `(font, color, glyph)`
|
|
181
|
+
* so stale entries are never *returned* wrongly, but they do occupy space.
|
|
182
|
+
*/
|
|
183
|
+
reset(): void;
|
|
184
|
+
/** Release the backing canvas and all slots. */
|
|
185
|
+
destroy(): void;
|
|
186
|
+
}
|