@vectojs/core 1.30.0 → 1.31.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/index.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  SVGEntity,
19
19
  VectoJSEvent,
20
20
  contentLineInHint
21
- } from "./chunk-2WXZCPYQ.mjs";
21
+ } from "./chunk-RWN3ITBD.mjs";
22
22
 
23
23
  // src/tree/Scene.ts
24
24
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1379,6 +1379,7 @@ var SCENE_OPTION_KEYS = [
1379
1379
  "autoThrottle",
1380
1380
  "contentProjection",
1381
1381
  "contentProjectionMargin",
1382
+ "contentSemanticMargin",
1382
1383
  "debugA11y",
1383
1384
  "disableWindowResize",
1384
1385
  "maxDPR",
@@ -1956,6 +1957,15 @@ var Scene = class _Scene {
1956
1957
  a11yElements = /* @__PURE__ */ new Map();
1957
1958
  /** DOM nodes mirroring static text content, keyed by entity id. */
1958
1959
  contentElements = /* @__PURE__ */ new Map();
1960
+ /**
1961
+ * What the last completed content-projection sync was built from, per entity.
1962
+ *
1963
+ * Compared at the top of {@link syncContentProjection} to skip a block whose
1964
+ * content AND geometry are both unchanged, before the O(glyphs) projection
1965
+ * build. Only populated for entities that opt in via
1966
+ * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
1967
+ */
1968
+ contentSyncState = /* @__PURE__ */ new Map();
1959
1969
  /** Pending cold font-calibration frame per projected grid entity. */
1960
1970
  contentGridCalibrationFrames = /* @__PURE__ */ new Map();
1961
1971
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -1989,9 +1999,14 @@ var Scene = class _Scene {
1989
1999
  contentMetricScaleEpoch = -1;
1990
2000
  contentMetricScaleX = 1;
1991
2001
  contentProjectionEnabled = true;
1992
- // Virtualization margin (px) for content projection; `undefined` → one
1993
- // viewport height, resolved at sync time. `Infinity` = materialize everything.
2002
+ // Virtualization margin (px) for the content-projection CARRIER band;
2003
+ // `undefined` → one viewport height, resolved at sync time. `Infinity` is
2004
+ // unsupported here: it unwindows every carrier, which is O(total glyphs).
1994
2005
  contentProjectionMargin = void 0;
2006
+ // Virtualization margin (px) for the SEMANTIC tier — whether a block has
2007
+ // any projected DOM. `undefined` → falls back to contentProjectionMargin, so
2008
+ // the default keeps one gate. `Infinity` = every block keeps resident text.
2009
+ contentSemanticMargin = void 0;
1995
2010
  /**
1996
2011
  * True while a text-selection drag that started on a projection's blank
1997
2012
  * region (no text node under the press) is being driven manually — the
@@ -3027,6 +3042,7 @@ var Scene = class _Scene {
3027
3042
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
3028
3043
  this.contentProjectionEnabled = options.contentProjection ?? true;
3029
3044
  this.contentProjectionMargin = options.contentProjectionMargin;
3045
+ this.contentSemanticMargin = options.contentSemanticMargin;
3030
3046
  this.readingDirection = options.readingDirection ?? "ltr";
3031
3047
  this.renderMode = options.renderMode ?? "always";
3032
3048
  this._devActive = _Scene._devModeDetected();
@@ -3449,6 +3465,7 @@ var Scene = class _Scene {
3449
3465
  this.clearContentGridState(node.id, contentEl);
3450
3466
  contentEl.remove();
3451
3467
  this.contentElements.delete(node.id);
3468
+ this.contentSyncState.delete(node.id);
3452
3469
  this.a11yNeedsReorder = true;
3453
3470
  }
3454
3471
  const el = this.a11yElements.get(node.id);
@@ -3593,6 +3610,7 @@ var Scene = class _Scene {
3593
3610
  this.a11yElements.clear();
3594
3611
  for (const el of this.contentElements.values()) el.remove();
3595
3612
  this.contentElements.clear();
3613
+ this.contentSyncState.clear();
3596
3614
  if (typeof cancelAnimationFrame === "function") {
3597
3615
  for (const frame of this.contentGridCalibrationFrames.values()) {
3598
3616
  cancelAnimationFrame(frame);
@@ -4555,14 +4573,27 @@ var Scene = class _Scene {
4555
4573
  this.contentElements.delete(node.id);
4556
4574
  this.a11yNeedsReorder = true;
4557
4575
  }
4576
+ this.contentSyncState.delete(node.id);
4558
4577
  };
4559
4578
  const worldTf = node.getWorldTransform();
4560
- const margin = this.contentProjectionMargin ?? this.height;
4561
- if (Number.isFinite(margin) && !this.projectionBoxVisible(node, worldTf, margin)) {
4579
+ const interactionMargin = this.contentProjectionMargin ?? this.height;
4580
+ const semanticMargin = this.contentSemanticMargin ?? interactionMargin;
4581
+ if (Number.isFinite(semanticMargin) && !this.projectionBoxVisible(node, worldTf, semanticMargin)) {
4562
4582
  releaseProjectionEl();
4563
4583
  return;
4564
4584
  }
4565
- const lineBand = Number.isFinite(margin) ? this.projectionVisibleLocalYBand(node, worldTf, margin) : null;
4585
+ const inInteractionBand = !Number.isFinite(interactionMargin) || this.projectionBoxVisible(node, worldTf, interactionMargin);
4586
+ const tier = inInteractionBand ? "fine" : "coarse";
4587
+ const lineBand = tier === "coarse" ? null : Number.isFinite(interactionMargin) ? this.projectionVisibleLocalYBand(node, worldTf, interactionMargin) : null;
4588
+ const visible = this.projectionBoxVisible(node, worldTf, 0);
4589
+ const epoch = node.getContentEpoch();
4590
+ const prior = this.contentSyncState.get(node.id);
4591
+ if (epoch !== null && el && prior !== void 0) {
4592
+ const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
4593
+ if (prior.epoch === epoch && prior.fontEpoch === this.contentFontEpoch && prior.a === a2 && prior.b === b2 && prior.c === c2 && prior.d === d2 && prior.e === e2 && prior.f === f2 && prior.tier === tier && prior.hasBand === (lineBand !== null) && (lineBand === null || prior.bandMin === lineBand.minY && prior.bandMax === lineBand.maxY) && prior.visible === visible && prior.width === node.width && prior.height === node.height && prior.interactive === node.interactive) {
4594
+ return;
4595
+ }
4596
+ }
4566
4597
  const projection = node.getContentProjection(
4567
4598
  lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4568
4599
  );
@@ -4595,14 +4626,15 @@ var Scene = class _Scene {
4595
4626
  this.a11yNeedsReorder = true;
4596
4627
  }
4597
4628
  const lines = projection.lines;
4598
- if (!projection.grid && el.dataset.vectoContentGrid !== void 0) {
4629
+ const useCarriers = tier === "fine";
4630
+ if ((!projection.grid || !useCarriers) && el.dataset.vectoContentGrid !== void 0) {
4599
4631
  this.clearContentGridState(node.id, el);
4600
4632
  }
4601
- if (projection.grid) {
4633
+ if (projection.grid && useCarriers) {
4602
4634
  const gridSyncStart = this._phaseTiming ? performance.now() : 0;
4603
4635
  this.syncContentGridProjection(node, el, projection, projection.grid, lineBand);
4604
4636
  if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
4605
- } else if (lines && lines.length > 0) {
4637
+ } else if (useCarriers && lines && lines.length > 0) {
4606
4638
  const lineWindow = projectionLineWindow(lines, lineBand, projection.lineHeight ?? 16);
4607
4639
  const signature = JSON.stringify({
4608
4640
  lines,
@@ -4665,11 +4697,13 @@ var Scene = class _Scene {
4665
4697
  }
4666
4698
  }
4667
4699
  } else {
4668
- if (el.textContent !== projection.text) {
4700
+ const demoted = tier === "coarse" && el.children.length > 0;
4701
+ if (el.textContent !== projection.text || demoted) {
4669
4702
  this.releaseContentSelectionForRebuild(el);
4670
4703
  el.textContent = projection.text;
4671
4704
  }
4672
4705
  delete el.dataset.vectoProjectionLines;
4706
+ if (tier === "coarse") delete el.dataset.vectoProjectionWindow;
4673
4707
  }
4674
4708
  const font = projection.font ?? "";
4675
4709
  if (el.style.font !== font) el.style.font = font;
@@ -4702,9 +4736,29 @@ var Scene = class _Scene {
4702
4736
  if (node.width > 0) el.style.width = `${node.width}px`;
4703
4737
  if (node.height > 0) el.style.height = `${node.height}px`;
4704
4738
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4705
- const visible = this.projectionBoxVisible(node, worldTf, 0);
4706
4739
  const display = visible ? "" : "none";
4707
4740
  if (el.style.display !== display) el.style.display = display;
4741
+ if (epoch !== null) {
4742
+ const { a: a2, b: b2, c: c2, d: d2, e: e2, f: f2 } = worldTf;
4743
+ const next = prior ?? {};
4744
+ next.epoch = epoch;
4745
+ next.fontEpoch = this.contentFontEpoch;
4746
+ next.a = a2;
4747
+ next.b = b2;
4748
+ next.c = c2;
4749
+ next.d = d2;
4750
+ next.e = e2;
4751
+ next.f = f2;
4752
+ next.tier = tier;
4753
+ next.hasBand = lineBand !== null;
4754
+ next.bandMin = lineBand?.minY ?? 0;
4755
+ next.bandMax = lineBand?.maxY ?? 0;
4756
+ next.visible = visible;
4757
+ next.width = node.width;
4758
+ next.height = node.height;
4759
+ next.interactive = node.interactive;
4760
+ if (prior === void 0) this.contentSyncState.set(node.id, next);
4761
+ }
4708
4762
  }
4709
4763
  /**
4710
4764
  * Materialize a prepared grid in logical source order while positioning each
@@ -6107,6 +6161,8 @@ var TextEntity = class extends Entity {
6107
6161
  hoveredFillStyle = "#ffffff";
6108
6162
  lineWidth = 1;
6109
6163
  isHovered = false;
6164
+ /** Bumped by {@link applyLayout}; read by `Scene` to skip an unchanged sync. */
6165
+ contentEpoch = 0;
6110
6166
  constructor(text, atlas, maxWidth, fontSize = 24) {
6111
6167
  super();
6112
6168
  this.text = text;
@@ -6127,6 +6183,9 @@ var TextEntity = class extends Entity {
6127
6183
  if (!this.text) return null;
6128
6184
  return { text: this.text, font: `${this.fontSize}px sans-serif` };
6129
6185
  }
6186
+ getContentEpoch() {
6187
+ return this.contentEpoch;
6188
+ }
6130
6189
  /**
6131
6190
  * Replace the text content. Runs the **cold** measurement pass (re-segment +
6132
6191
  * re-measure) since the glyphs changed, then re-lays out.
@@ -6173,6 +6232,7 @@ var TextEntity = class extends Entity {
6173
6232
  }
6174
6233
  /** Hot pass: place the cached {@link PreparedText} and refresh the a11y box. */
6175
6234
  applyLayout() {
6235
+ this.contentEpoch++;
6176
6236
  const result = this.layout.layoutPrepared(this.prepared);
6177
6237
  this.nodes = result.nodes;
6178
6238
  this.width = result.totalWidth;
@@ -34,6 +34,8 @@ export declare class MSDFTextEntity extends Entity {
34
34
  private layoutText;
35
35
  private text;
36
36
  private lastRenderedSeqId;
37
+ /** Bumped by {@link queueLayout}; read by `Scene` to skip an unchanged sync. */
38
+ private contentEpoch;
37
39
  private atlasDecodeTarget;
38
40
  private atlasDecodeHandler;
39
41
  private rgbColorCache;
@@ -91,6 +93,7 @@ export declare class MSDFTextEntity extends Entity {
91
93
  * readers, crawlers, and translation see the same string the canvas draws.
92
94
  */
93
95
  getContentProjection(): ContentProjection | null;
96
+ getContentEpoch(): number;
94
97
  isPointInside(globalX: number, globalY: number): boolean;
95
98
  render(renderer: any): void;
96
99
  destroy(): void;
package/dist/text.js CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
 
4
4
 
5
- var _chunk7PI7LVWWjs = require('./chunk-7PI7LVWW.js');
5
+ var _chunkTRT2TRCWjs = require('./chunk-TRT2TRCW.js');
6
6
 
7
7
  // src/text/index.ts
8
8
  var _text = require('@vectojs/text'); _createStarExport(_text);
9
9
 
10
10
 
11
11
 
12
- exports.MSDFTextEntity = _chunk7PI7LVWWjs.MSDFTextEntity; exports.SVGEntity = _chunk7PI7LVWWjs.SVGEntity;
12
+ exports.MSDFTextEntity = _chunkTRT2TRCWjs.MSDFTextEntity; exports.SVGEntity = _chunkTRT2TRCWjs.SVGEntity;
package/dist/text.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  MSDFTextEntity,
3
3
  SVGEntity
4
- } from "./chunk-2WXZCPYQ.mjs";
4
+ } from "./chunk-RWN3ITBD.mjs";
5
5
 
6
6
  // src/text/index.ts
7
7
  export * from "@vectojs/text";
@@ -1000,6 +1000,32 @@ export declare abstract class Entity {
1000
1000
  * @returns The projection descriptor, or `null` to project nothing.
1001
1001
  */
1002
1002
  getContentProjection(hint?: ContentProjectionHint): ContentProjection | null;
1003
+ /**
1004
+ * A cheap, monotonically-increasing stamp of this entity's projected content.
1005
+ *
1006
+ * Purely an optimization, and opt-in: returning `null` (the default) means
1007
+ * "I cannot cheaply tell whether my content changed", and the {@link Scene}
1008
+ * then rebuilds the projection every synced frame exactly as before. An
1009
+ * implementation must bump the value whenever anything
1010
+ * {@link getContentProjection} would report changes — text, fonts, line
1011
+ * geometry, `selectable`, grid revision.
1012
+ *
1013
+ * When two consecutive syncs report the same epoch AND the entity's geometry
1014
+ * is unchanged, `Scene` skips the block *before* calling
1015
+ * {@link getContentProjection}. That matters because the projection call is
1016
+ * O(glyphs-in-block) and the DOM diff around it costs about the same again:
1017
+ * measured on a 1500-resident-block document, a sync in which the projected
1018
+ * text was byte-identical before and after still cost 17.875 ms, and skipping
1019
+ * unchanged blocks took that to 0.475 ms (carryctx CTX-0199, vectojs#343).
1020
+ *
1021
+ * Correctness is entirely on the implementer: a stale epoch means stale DOM,
1022
+ * so bump it in the same place the content is invalidated rather than trying
1023
+ * to enumerate mutation sites afterwards. Any monotonic counter works; the
1024
+ * value is only ever compared for equality with the previous sync's.
1025
+ *
1026
+ * @returns The current content epoch, or `null` to disable skipping.
1027
+ */
1028
+ getContentEpoch(): number | null;
1003
1029
  /**
1004
1030
  * Whether this entity still has a queued/running tween animation, or an
1005
1031
  * active {@link setTransition}/{@link animateTo}/{@link springTo} property
@@ -201,6 +201,34 @@ export interface SceneOptions {
201
201
  * height (`undefined` → resolved to `Scene.height` at sync time).
202
202
  */
203
203
  contentProjectionMargin?: number;
204
+ /**
205
+ * Virtualization margin (px) for the *semantic* tier of content projection —
206
+ * whether a block has **any** projected DOM at all, as opposed to
207
+ * {@link SceneOptions.contentProjectionMargin}, which decides whether that
208
+ * block's per-line **carriers** are windowed.
209
+ *
210
+ * Splitting the two makes a coarse resident tier expressible: with
211
+ * `contentSemanticMargin: Infinity` and a finite `contentProjectionMargin`,
212
+ * every block in the document keeps an element holding its full text — so
213
+ * find-in-page and screen-reader read-ahead see the whole document — while
214
+ * only blocks near the viewport pay for per-line carriers. One scalar could
215
+ * not express that, because a finite value freed off-band blocks entirely and
216
+ * `Infinity` also unwindowed every carrier, which is O(total document glyphs).
217
+ *
218
+ * `Infinity` is safe **here** and remains unsupported for
219
+ * `contentProjectionMargin`: the cost that made it unsupported comes from an
220
+ * unwindowed carrier band, not from resident text.
221
+ *
222
+ * Note the one-time cost. A resident tier materializes one element per block
223
+ * on the first sync — measured ~13µs per node created, so ~20ms at 1000 blocks
224
+ * and ~146ms at 10000 — as one synchronous block. Steady state is cheap
225
+ * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a
226
+ * document-open stall, not a per-frame cost.
227
+ *
228
+ * Default: whatever `contentProjectionMargin` resolves to, so omitting this
229
+ * leaves behaviour unchanged.
230
+ */
231
+ contentSemanticMargin?: number;
204
232
  /**
205
233
  * Reading direction used to order the accessibility/automation shadow tree so
206
234
  * keyboard **tab order** and screen-reader traversal follow the *visual*
@@ -240,7 +268,7 @@ export interface SceneOptions {
240
268
  * against. A new option must be added here too — the test suite asserts the two
241
269
  * stay in sync.
242
270
  */
243
- export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
271
+ export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming'];
244
272
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
245
273
  export declare const REDUCED_MOTION_FPS = 30;
246
274
  /**
@@ -488,6 +516,15 @@ export declare class Scene {
488
516
  private a11yElements;
489
517
  /** DOM nodes mirroring static text content, keyed by entity id. */
490
518
  private contentElements;
519
+ /**
520
+ * What the last completed content-projection sync was built from, per entity.
521
+ *
522
+ * Compared at the top of {@link syncContentProjection} to skip a block whose
523
+ * content AND geometry are both unchanged, before the O(glyphs) projection
524
+ * build. Only populated for entities that opt in via
525
+ * {@link Entity.getContentEpoch}. (carryctx CTX-0199)
526
+ */
527
+ private contentSyncState;
491
528
  /** Pending cold font-calibration frame per projected grid entity. */
492
529
  private contentGridCalibrationFrames;
493
530
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -522,6 +559,7 @@ export declare class Scene {
522
559
  private contentMetricScaleX;
523
560
  private contentProjectionEnabled;
524
561
  private contentProjectionMargin;
562
+ private contentSemanticMargin;
525
563
  /**
526
564
  * True while a text-selection drag that started on a projection's blank
527
565
  * region (no text node under the press) is being driven manually — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },