@vectojs/core 1.29.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
@@ -16,8 +16,9 @@ import {
16
16
  Entity,
17
17
  MSDFTextEntity,
18
18
  SVGEntity,
19
- VectoJSEvent
20
- } from "./chunk-MVFPN4Y5.mjs";
19
+ VectoJSEvent,
20
+ contentLineInHint
21
+ } from "./chunk-RWN3ITBD.mjs";
21
22
 
22
23
  // src/tree/Scene.ts
23
24
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1378,6 +1379,7 @@ var SCENE_OPTION_KEYS = [
1378
1379
  "autoThrottle",
1379
1380
  "contentProjection",
1380
1381
  "contentProjectionMargin",
1382
+ "contentSemanticMargin",
1381
1383
  "debugA11y",
1382
1384
  "disableWindowResize",
1383
1385
  "maxDPR",
@@ -1430,6 +1432,45 @@ function parseInlinePx(value) {
1430
1432
  const n = parseFloat(value);
1431
1433
  return Number.isFinite(n) && n > 0 ? n : null;
1432
1434
  }
1435
+ function projectionGridLineWindow(grid, projectionLines, band) {
1436
+ const count = grid.lines.length;
1437
+ const all = { start: 0, end: count, gated: false };
1438
+ if (!band || count === 0) return all;
1439
+ const lines = [];
1440
+ for (let i = 0; i < count; i++) {
1441
+ const projected = projectionLines?.[i];
1442
+ lines.push({
1443
+ y: projected?.y ?? i * grid.lineHeight,
1444
+ lineHeight: projected?.lineHeight ?? grid.lineHeight
1445
+ });
1446
+ }
1447
+ return projectionLineWindow(lines, band, grid.lineHeight);
1448
+ }
1449
+ function projectionLineWindow(lines, band, fallbackLineHeight) {
1450
+ const all = {
1451
+ start: 0,
1452
+ end: lines.length,
1453
+ gated: false
1454
+ };
1455
+ if (!band || lines.length === 0) return all;
1456
+ let start = -1;
1457
+ let end = -1;
1458
+ for (let i = 0; i < lines.length; i++) {
1459
+ const line = lines[i];
1460
+ const h = line.lineHeight ?? fallbackLineHeight;
1461
+ if (line.y + h >= band.minY && line.y <= band.maxY) {
1462
+ if (start === -1) start = i;
1463
+ end = i + 1;
1464
+ } else if (start !== -1 && line.y > band.maxY) {
1465
+ break;
1466
+ }
1467
+ }
1468
+ if (start === -1) {
1469
+ return { start: 0, end: Math.min(1, lines.length), gated: true };
1470
+ }
1471
+ if (start === 0 && end === lines.length) return all;
1472
+ return { start, end, gated: true };
1473
+ }
1433
1474
  function collectTextNodes(root) {
1434
1475
  const out = [];
1435
1476
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -1916,6 +1957,15 @@ var Scene = class _Scene {
1916
1957
  a11yElements = /* @__PURE__ */ new Map();
1917
1958
  /** DOM nodes mirroring static text content, keyed by entity id. */
1918
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();
1919
1969
  /** Pending cold font-calibration frame per projected grid entity. */
1920
1970
  contentGridCalibrationFrames = /* @__PURE__ */ new Map();
1921
1971
  /** Detached, untransformed font probes used by the cold calibration pass. */
@@ -1949,9 +1999,14 @@ var Scene = class _Scene {
1949
1999
  contentMetricScaleEpoch = -1;
1950
2000
  contentMetricScaleX = 1;
1951
2001
  contentProjectionEnabled = true;
1952
- // Virtualization margin (px) for content projection; `undefined` → one
1953
- // 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).
1954
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;
1955
2010
  /**
1956
2011
  * True while a text-selection drag that started on a projection's blank
1957
2012
  * region (no text node under the press) is being driven manually — the
@@ -1988,6 +2043,14 @@ var Scene = class _Scene {
1988
2043
  * mid-hover synthesize the `pointerleave` the browser never sends for a
1989
2044
  * detached element, so the entity doesn't keep its hover state. */
1990
2045
  hoveredA11yElements = /* @__PURE__ */ new WeakSet();
2046
+ /**
2047
+ * Entity ids the application has pinned via {@link requestA11yProjection}.
2048
+ *
2049
+ * Ids rather than entities so a removed entity cannot be retained by this set;
2050
+ * a stale id simply never matches. Cleared per-entity by
2051
+ * {@link releaseA11yProjection}.
2052
+ */
2053
+ a11yProjectionRequests = /* @__PURE__ */ new Set();
1991
2054
  /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
1992
2055
  * pruned (virtualization/streaming/removal) while it holds focus, we move
1993
2056
  * focus here instead of letting the browser drop it to <body> — keeping the
@@ -2941,7 +3004,9 @@ var Scene = class _Scene {
2941
3004
  const el = this.contentElements?.get(node.id);
2942
3005
  if (el) {
2943
3006
  const projectedText = el.textContent || "";
2944
- if (projectedText !== "" && projectedText !== proj.text) {
3007
+ const windowed = el.dataset.vectoProjectionWindow !== void 0;
3008
+ const mismatched = windowed ? !proj.text.includes(projectedText) : projectedText !== proj.text;
3009
+ if (projectedText !== "" && mismatched) {
2945
3010
  this._devWarn(
2946
3011
  `Content projection mismatch for entity "${node.id}": projection says "${proj.text.slice(0, 60)}" but DOM shows "${projectedText.slice(0, 60)}". Ensure getContentProjection() output matches what drawSelf renders.`
2947
3012
  );
@@ -2977,6 +3042,7 @@ var Scene = class _Scene {
2977
3042
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
2978
3043
  this.contentProjectionEnabled = options.contentProjection ?? true;
2979
3044
  this.contentProjectionMargin = options.contentProjectionMargin;
3045
+ this.contentSemanticMargin = options.contentSemanticMargin;
2980
3046
  this.readingDirection = options.readingDirection ?? "ltr";
2981
3047
  this.renderMode = options.renderMode ?? "always";
2982
3048
  this._devActive = _Scene._devModeDetected();
@@ -3399,6 +3465,7 @@ var Scene = class _Scene {
3399
3465
  this.clearContentGridState(node.id, contentEl);
3400
3466
  contentEl.remove();
3401
3467
  this.contentElements.delete(node.id);
3468
+ this.contentSyncState.delete(node.id);
3402
3469
  this.a11yNeedsReorder = true;
3403
3470
  }
3404
3471
  const el = this.a11yElements.get(node.id);
@@ -3543,6 +3610,7 @@ var Scene = class _Scene {
3543
3610
  this.a11yElements.clear();
3544
3611
  for (const el of this.contentElements.values()) el.remove();
3545
3612
  this.contentElements.clear();
3613
+ this.contentSyncState.clear();
3546
3614
  if (typeof cancelAnimationFrame === "function") {
3547
3615
  for (const frame of this.contentGridCalibrationFrames.values()) {
3548
3616
  cancelAnimationFrame(frame);
@@ -3885,7 +3953,92 @@ var Scene = class _Scene {
3885
3953
  * predicate, which is only tractable while it has one home.
3886
3954
  */
3887
3955
  shouldProjectA11y(node) {
3888
- return node.interactive && (node.width > 0 || node.a11yFullViewport);
3956
+ if (!node.interactive) return false;
3957
+ if (!(node.width > 0 || node.a11yFullViewport)) return false;
3958
+ switch (node.a11yProjection) {
3959
+ case "never":
3960
+ return false;
3961
+ case "onDemand":
3962
+ return this.a11yEngaged(node);
3963
+ default:
3964
+ return true;
3965
+ }
3966
+ }
3967
+ /**
3968
+ * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to
3969
+ * deserve a shadow node.
3970
+ *
3971
+ * Deliberately **not** hover alone. A keyboard or assistive-technology user
3972
+ * generates no pointer events, so a hover-only trigger would withhold the
3973
+ * semantic node from precisely the users it exists for. Three signals, any of
3974
+ * which counts:
3975
+ *
3976
+ * - **Focus.** Covers keyboard traversal and AT-driven focus. Checked against
3977
+ * the live element so a node keeps its own focus rather than being pruned out
3978
+ * from under the user mid-interaction.
3979
+ * - **Pointer target.** The entity under the pointer, so a mouse user gets the
3980
+ * same node a hover-gated design would have given them.
3981
+ * - **Explicit request.** {@link Scene.requestA11yProjection}, for anything the
3982
+ * app knows is significant — the selected item, a search hit, a
3983
+ * just-announced element. This is the escape hatch that keeps the mode usable
3984
+ * when neither focus nor pointer applies.
3985
+ *
3986
+ * The entity stays hit-testable on canvas regardless, so a click always reaches
3987
+ * it and promotes it on the next sync.
3988
+ */
3989
+ a11yEngaged(node) {
3990
+ if (this.a11yProjectionRequests.has(node.id)) return true;
3991
+ if (this.mouseX > -9e3 && !this.projectsSelectableText(node)) {
3992
+ if (node.isPointInside(this.mouseX, this.mouseY)) return true;
3993
+ }
3994
+ const existing = this.a11yElements?.get(node.id);
3995
+ if (existing && typeof document !== "undefined" && document.activeElement === existing) {
3996
+ return true;
3997
+ }
3998
+ return false;
3999
+ }
4000
+ /**
4001
+ * Whether `node` mirrors selectable text of its own.
4002
+ *
4003
+ * Such an entity must not be promoted by the pointer: its interactive a11y node
4004
+ * would sit above the text mirror and eat the mousedown that starts a native
4005
+ * selection.
4006
+ */
4007
+ projectsSelectableText(node) {
4008
+ const projection = node.getContentProjection?.();
4009
+ return !!projection?.text && projection.selectable !== false;
4010
+ }
4011
+ /**
4012
+ * Keep `entity`'s a11y shadow node projected while it has
4013
+ * `a11yProjection: 'onDemand'`.
4014
+ *
4015
+ * For anything the application knows matters but the engine cannot infer — the
4016
+ * selected danmaku, a search hit, a node just announced in a live region.
4017
+ * Without this, `'onDemand'` would be reachable only by focus or pointer, and
4018
+ * an app-driven selection change would leave the selected entity semantically
4019
+ * invisible.
4020
+ *
4021
+ * Idempotent. Has no effect on an `'eager'` entity, which is always projected.
4022
+ */
4023
+ requestA11yProjection(entity) {
4024
+ const id = typeof entity === "string" ? entity : entity.id;
4025
+ if (this.a11yProjectionRequests.has(id)) return;
4026
+ this.a11yProjectionRequests.add(id);
4027
+ this.a11yNeedsReorder = true;
4028
+ this.markDirty({ entity: id, reason: "a11y-reorder" });
4029
+ }
4030
+ /**
4031
+ * Drop a projection request made by {@link requestA11yProjection}.
4032
+ *
4033
+ * The node is not removed immediately: it survives while it is focused or under
4034
+ * the pointer, and is pruned on the next sync that finds it unengaged. Releasing
4035
+ * a request the scene does not hold is a no-op.
4036
+ */
4037
+ releaseA11yProjection(entity) {
4038
+ const id = typeof entity === "string" ? entity : entity.id;
4039
+ if (!this.a11yProjectionRequests.delete(id)) return;
4040
+ this.a11yNeedsReorder = true;
4041
+ this.markDirty({ entity: id, reason: "a11y-reorder" });
3889
4042
  }
3890
4043
  syncA11y(node, container = null) {
3891
4044
  if (!this.a11yRoot) return;
@@ -4359,6 +4512,57 @@ var Scene = class _Scene {
4359
4512
  }
4360
4513
  return true;
4361
4514
  }
4515
+ /**
4516
+ * The band of an entity's own y coordinates that is worth projecting, or
4517
+ * `null` to project everything.
4518
+ *
4519
+ * {@link projectionBoxVisible} answers "is this entity near the viewport",
4520
+ * which frees whole blocks that scroll away. It cannot help a single entity
4521
+ * *taller* than the viewport: that entity's box always intersects, so every
4522
+ * one of its visual lines was materialized — a `<span>` per line and, on the
4523
+ * grid path, a `<span>` per glyph cluster. That is where "14.8k elements for a
4524
+ * 346KB Markdown doc" comes from, and it is O(document) rather than
4525
+ * O(viewport) in both element count and per-frame walk cost.
4526
+ *
4527
+ * Measured on one entity scrolled to its middle, real headed browsers
4528
+ * (`benchmarks/projection-per-line/`): at 4000 lines, materializing every line
4529
+ * costs 6.28 ms/frame on Chrome and 6.51 ms on Firefox with 36,000 child
4530
+ * elements, against 0.28/0.16 ms and 963 elements when only the visible band
4531
+ * is emitted. The gated cost is *flat* across a 20x document-size range, so
4532
+ * this converts an asymptote rather than shaving a constant.
4533
+ *
4534
+ * Returns local-y bounds in the entity's own coordinate space, already
4535
+ * expanded by `margin` and intersected with every `clipChildren` ancestor, so
4536
+ * a line inside a scrolled container is measured against the container rather
4537
+ * than the window. `null` means "no useful bound" — a degenerate transform, a
4538
+ * rotation/skew that makes a y-band meaningless, or a boundless entity — and
4539
+ * the caller must then project every line, because emitting nothing would
4540
+ * silently drop text from selection, find-in-page and screen readers.
4541
+ */
4542
+ projectionVisibleLocalYBand(node, tf, margin) {
4543
+ const { b, d, f } = tf;
4544
+ if (b !== 0 || d === 0 || !Number.isFinite(d) || !Number.isFinite(f)) return null;
4545
+ const top = (-margin - f) / d;
4546
+ const bottom = (this.height + margin - f) / d;
4547
+ let minY = Math.min(top, bottom);
4548
+ let maxY = Math.max(top, bottom);
4549
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4550
+ if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
4551
+ const originWorldY = f;
4552
+ const unitWorldY = d + f;
4553
+ const originLocal = ancestor.worldToLocal(tf.e, originWorldY);
4554
+ const unitLocal = ancestor.worldToLocal(tf.e + tf.c, unitWorldY);
4555
+ if (!originLocal || !unitLocal) return null;
4556
+ const slope = unitLocal.y - originLocal.y;
4557
+ if (slope === 0 || !Number.isFinite(slope)) return null;
4558
+ const a1 = (-margin - originLocal.y) / slope;
4559
+ const a2 = (ancestor.height + margin - originLocal.y) / slope;
4560
+ minY = Math.max(minY, Math.min(a1, a2));
4561
+ maxY = Math.min(maxY, Math.max(a1, a2));
4562
+ }
4563
+ if (!Number.isFinite(minY) || !Number.isFinite(maxY) || maxY < minY) return null;
4564
+ return { minY, maxY };
4565
+ }
4362
4566
  syncContentProjection(node) {
4363
4567
  if (!this.contentProjectionEnabled || !this.a11yRoot) return;
4364
4568
  let el = this.contentElements.get(node.id);
@@ -4369,14 +4573,30 @@ var Scene = class _Scene {
4369
4573
  this.contentElements.delete(node.id);
4370
4574
  this.a11yNeedsReorder = true;
4371
4575
  }
4576
+ this.contentSyncState.delete(node.id);
4372
4577
  };
4373
4578
  const worldTf = node.getWorldTransform();
4374
- const margin = this.contentProjectionMargin ?? this.height;
4375
- 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)) {
4376
4582
  releaseProjectionEl();
4377
4583
  return;
4378
4584
  }
4379
- const projection = node.getContentProjection();
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
+ }
4597
+ const projection = node.getContentProjection(
4598
+ lineBand ? { minY: lineBand.minY, maxY: lineBand.maxY } : void 0
4599
+ );
4380
4600
  if (!projection || !projection.text) {
4381
4601
  releaseProjectionEl();
4382
4602
  return;
@@ -4406,23 +4626,30 @@ var Scene = class _Scene {
4406
4626
  this.a11yNeedsReorder = true;
4407
4627
  }
4408
4628
  const lines = projection.lines;
4409
- if (!projection.grid && el.dataset.vectoContentGrid !== void 0) {
4629
+ const useCarriers = tier === "fine";
4630
+ if ((!projection.grid || !useCarriers) && el.dataset.vectoContentGrid !== void 0) {
4410
4631
  this.clearContentGridState(node.id, el);
4411
4632
  }
4412
- if (projection.grid) {
4633
+ if (projection.grid && useCarriers) {
4413
4634
  const gridSyncStart = this._phaseTiming ? performance.now() : 0;
4414
- this.syncContentGridProjection(node, el, projection, projection.grid);
4635
+ this.syncContentGridProjection(node, el, projection, projection.grid, lineBand);
4415
4636
  if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
4416
- } else if (lines && lines.length > 0) {
4637
+ } else if (useCarriers && lines && lines.length > 0) {
4638
+ const lineWindow = projectionLineWindow(lines, lineBand, projection.lineHeight ?? 16);
4417
4639
  const signature = JSON.stringify({
4418
4640
  lines,
4419
4641
  fallbackFont: projection.font ?? "",
4420
- fallbackLineHeight: projection.lineHeight ?? 16
4642
+ fallbackLineHeight: projection.lineHeight ?? 16,
4643
+ // Part of the signature, or scrolling would not rebuild the carriers and
4644
+ // the window would stay frozen where it was first built. Quantized to
4645
+ // whole line indices by construction, so a sub-pixel scroll inside one
4646
+ // line does not churn the DOM.
4647
+ window: lineWindow.gated ? `${lineWindow.start}-${lineWindow.end}` : "all"
4421
4648
  });
4422
4649
  if (el.dataset.vectoProjectionLines !== signature) {
4423
4650
  this.preserveContentSelectionAcrossRebuild(el, () => {
4424
4651
  el.replaceChildren();
4425
- for (let index = 0; index < lines.length; index++) {
4652
+ for (let index = lineWindow.start; index < lineWindow.end; index++) {
4426
4653
  const line = lines[index];
4427
4654
  const lineElement = document.createElement("span");
4428
4655
  const lineFont = line.font ?? projection.font ?? "";
@@ -4463,13 +4690,20 @@ var Scene = class _Scene {
4463
4690
  }
4464
4691
  });
4465
4692
  el.dataset.vectoProjectionLines = signature;
4693
+ if (lineWindow.gated) {
4694
+ el.dataset.vectoProjectionWindow = `${lineWindow.start}-${lineWindow.end}/${lines.length}`;
4695
+ } else {
4696
+ delete el.dataset.vectoProjectionWindow;
4697
+ }
4466
4698
  }
4467
4699
  } else {
4468
- if (el.textContent !== projection.text) {
4700
+ const demoted = tier === "coarse" && el.children.length > 0;
4701
+ if (el.textContent !== projection.text || demoted) {
4469
4702
  this.releaseContentSelectionForRebuild(el);
4470
4703
  el.textContent = projection.text;
4471
4704
  }
4472
4705
  delete el.dataset.vectoProjectionLines;
4706
+ if (tier === "coarse") delete el.dataset.vectoProjectionWindow;
4473
4707
  }
4474
4708
  const font = projection.font ?? "";
4475
4709
  if (el.style.font !== font) el.style.font = font;
@@ -4502,20 +4736,41 @@ var Scene = class _Scene {
4502
4736
  if (node.width > 0) el.style.width = `${node.width}px`;
4503
4737
  if (node.height > 0) el.style.height = `${node.height}px`;
4504
4738
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4505
- const visible = this.projectionBoxVisible(node, worldTf, 0);
4506
4739
  const display = visible ? "" : "none";
4507
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
+ }
4508
4762
  }
4509
4763
  /**
4510
4764
  * Materialize a prepared grid in logical source order while positioning each
4511
4765
  * carrier from the shared canvas geometry. Browser font measurement happens
4512
4766
  * later in one cold read/write batch, never inside projection synchronization.
4513
4767
  */
4514
- syncContentGridProjection(node, el, projection, grid) {
4768
+ syncContentGridProjection(node, el, projection, grid, lineBand) {
4515
4769
  if (grid.source !== projection.text) {
4516
4770
  throw new Error("ContentProjection.grid.source must equal ContentProjection.text");
4517
4771
  }
4518
- const signature = `${grid.revision}`;
4772
+ const gridWindow = projectionGridLineWindow(grid, projection.lines, lineBand);
4773
+ const signature = gridWindow.gated ? `${grid.revision}:${gridWindow.start}-${gridWindow.end}` : `${grid.revision}`;
4519
4774
  if (el.dataset.vectoContentGrid !== signature) {
4520
4775
  const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
4521
4776
  this.clearContentGridState(node.id, el, false);
@@ -4523,7 +4778,8 @@ var Scene = class _Scene {
4523
4778
  const selectionLine = this.contentGridSelectionLine(el);
4524
4779
  let rebuiltSelectionLine = false;
4525
4780
  const existingLines = el.children;
4526
- for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
4781
+ for (let lineIndex = gridWindow.start; lineIndex < gridWindow.end; lineIndex++) {
4782
+ const domIndex = lineIndex - gridWindow.start;
4527
4783
  const gridLine = grid.lines[lineIndex];
4528
4784
  const projectedLine = projectionLines[lineIndex];
4529
4785
  const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
@@ -4538,7 +4794,7 @@ var Scene = class _Scene {
4538
4794
  lineFont,
4539
4795
  lineIndex === 0
4540
4796
  );
4541
- const reusable = existingLines[lineIndex];
4797
+ const reusable = existingLines[domIndex];
4542
4798
  if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
4543
4799
  continue;
4544
4800
  }
@@ -4611,12 +4867,13 @@ var Scene = class _Scene {
4611
4867
  lineElement.appendChild(marker);
4612
4868
  }
4613
4869
  }
4614
- const occupant = el.children[lineIndex];
4870
+ const occupant = el.children[domIndex];
4615
4871
  if (occupant) el.replaceChild(lineElement, occupant);
4616
4872
  else el.appendChild(lineElement);
4617
4873
  }
4618
- while (el.children.length > grid.lines.length) {
4619
- if (selectionLine !== null && selectionLine >= grid.lines.length) {
4874
+ const windowLength = gridWindow.end - gridWindow.start;
4875
+ while (el.children.length > windowLength) {
4876
+ if (selectionLine !== null && selectionLine >= gridWindow.end) {
4620
4877
  rebuiltSelectionLine = true;
4621
4878
  }
4622
4879
  el.lastElementChild?.remove();
@@ -4624,6 +4881,11 @@ var Scene = class _Scene {
4624
4881
  if (rebuiltSelectionLine) this.releaseContentSelectionForRebuild(el);
4625
4882
  el.dataset.vectoProjectionLines = signature;
4626
4883
  el.dataset.vectoContentGrid = signature;
4884
+ if (gridWindow.gated) {
4885
+ el.dataset.vectoProjectionWindow = `${gridWindow.start}-${gridWindow.end}/${grid.lines.length}`;
4886
+ } else {
4887
+ delete el.dataset.vectoProjectionWindow;
4888
+ }
4627
4889
  el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
4628
4890
  if (typeof performance !== "undefined") {
4629
4891
  const materializeMs = performance.now() - materializeStart;
@@ -5899,6 +6161,8 @@ var TextEntity = class extends Entity {
5899
6161
  hoveredFillStyle = "#ffffff";
5900
6162
  lineWidth = 1;
5901
6163
  isHovered = false;
6164
+ /** Bumped by {@link applyLayout}; read by `Scene` to skip an unchanged sync. */
6165
+ contentEpoch = 0;
5902
6166
  constructor(text, atlas, maxWidth, fontSize = 24) {
5903
6167
  super();
5904
6168
  this.text = text;
@@ -5919,6 +6183,9 @@ var TextEntity = class extends Entity {
5919
6183
  if (!this.text) return null;
5920
6184
  return { text: this.text, font: `${this.fontSize}px sans-serif` };
5921
6185
  }
6186
+ getContentEpoch() {
6187
+ return this.contentEpoch;
6188
+ }
5922
6189
  /**
5923
6190
  * Replace the text content. Runs the **cold** measurement pass (re-segment +
5924
6191
  * re-measure) since the glyphs changed, then re-lays out.
@@ -5965,6 +6232,7 @@ var TextEntity = class extends Entity {
5965
6232
  }
5966
6233
  /** Hot pass: place the cached {@link PreparedText} and refresh the a11y box. */
5967
6234
  applyLayout() {
6235
+ this.contentEpoch++;
5968
6236
  const result = this.layout.layoutPrepared(this.prepared);
5969
6237
  this.nodes = result.nodes;
5970
6238
  this.width = result.totalWidth;
@@ -6670,6 +6938,7 @@ export {
6670
6938
  VectoJSEvent,
6671
6939
  WebGPUParticleSystemManager,
6672
6940
  beginVectoUserTiming,
6941
+ contentLineInHint,
6673
6942
  createWebGLPointRenderer,
6674
6943
  endVectoUserTiming,
6675
6944
  installRendererDevTraps,
@@ -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 _chunkZ3HFY75Rjs = require('./chunk-Z3HFY75R.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 = _chunkZ3HFY75Rjs.MSDFTextEntity; exports.SVGEntity = _chunkZ3HFY75Rjs.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-MVFPN4Y5.mjs";
4
+ } from "./chunk-RWN3ITBD.mjs";
5
5
 
6
6
  // src/text/index.ts
7
7
  export * from "@vectojs/text";