@vectojs/core 1.7.0 → 1.8.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
@@ -3,7 +3,7 @@ import {
3
3
  LayoutResultBuffer,
4
4
  computeLineSegments,
5
5
  createCanvasMeasurer
6
- } from "./chunk-V7GUWG5C.mjs";
6
+ } from "./chunk-X7I465AQ.mjs";
7
7
  import {
8
8
  CanvasRenderer,
9
9
  SVGRenderer,
@@ -25,13 +25,14 @@ import {
25
25
  VectoJSEvent,
26
26
  clearCssLineBoxMetrics,
27
27
  cssLineBoxBaseline,
28
- isTweenConfig
29
- } from "./chunk-3CODWSF3.mjs";
28
+ isTweenConfig,
29
+ prepareContentGrid
30
+ } from "./chunk-RQ2ETTBN.mjs";
30
31
  import {
31
32
  ArabicShaper,
32
33
  BidiResolver,
33
34
  LayoutWorkerManager
34
- } from "./chunk-STWPTWO4.mjs";
35
+ } from "./chunk-IESDTEJ4.mjs";
35
36
 
36
37
  // src/tree/ComputeParticleEntity.ts
37
38
  var PARTICLE_STRIDE_FLOATS = 8;
@@ -317,6 +318,297 @@ function parseInlinePx(value) {
317
318
  const n = parseFloat(value);
318
319
  return Number.isFinite(n) && n > 0 ? n : null;
319
320
  }
321
+ function collectTextNodes(root) {
322
+ const out = [];
323
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
324
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) out.push(n);
325
+ return out;
326
+ }
327
+ var caretGraphemeSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
328
+ var caretWordSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
329
+ function graphemeBoundaries(text) {
330
+ if (!caretGraphemeSegmenter) {
331
+ const boundaries2 = [0];
332
+ for (let offset = 0; offset < text.length; ) {
333
+ const codePoint = text.codePointAt(offset) ?? 0;
334
+ offset += codePoint > 65535 ? 2 : 1;
335
+ boundaries2.push(offset);
336
+ }
337
+ return boundaries2;
338
+ }
339
+ const boundaries = [0];
340
+ for (const segment of caretGraphemeSegmenter.segment(text)) {
341
+ const end = segment.index + segment.segment.length;
342
+ if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
343
+ }
344
+ return boundaries;
345
+ }
346
+ function distanceToRectSquared(rect, x, y) {
347
+ const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
348
+ const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
349
+ return dx * dx + dy * dy;
350
+ }
351
+ function nearestOffsetForPoint(node, x, y) {
352
+ const boundaries = graphemeBoundaries(node.data);
353
+ const range = document.createRange();
354
+ let nearest = { offset: boundaries[0] ?? 0, distance: Infinity };
355
+ for (const offset of boundaries) {
356
+ range.setStart(node, offset);
357
+ range.collapse(true);
358
+ const rect = range.getBoundingClientRect();
359
+ const distance = distanceToRectSquared(rect, x, y);
360
+ if (distance < nearest.distance) nearest = { offset, distance };
361
+ }
362
+ return nearest;
363
+ }
364
+ function gridCellCaret(cell, localX) {
365
+ const node = cell.firstChild;
366
+ if (!(node instanceof Text)) return null;
367
+ const sourceLength = Number(cell.dataset.vectoGridSourceLength ?? node.data.length);
368
+ const level = Number(cell.dataset.vectoGridLevel ?? 0);
369
+ const cellX = Number(cell.dataset.vectoGridX ?? 0);
370
+ const advance = Number(cell.dataset.vectoGridAdvance ?? 0);
371
+ const caretOffsets = (cell.dataset.vectoGridCaretOffsets ?? `0,${sourceLength}`).split(",").map(Number).filter((offset) => Number.isInteger(offset) && offset >= 0 && offset <= sourceLength);
372
+ const visuallyRtl = (level & 1) !== 0;
373
+ const visualFraction = advance > 0 ? Math.max(0, Math.min(1, (localX - cellX) / advance)) : 0;
374
+ const sourceFraction = visuallyRtl ? 1 - visualFraction : visualFraction;
375
+ const caretIndex = Math.round(sourceFraction * Math.max(0, caretOffsets.length - 1));
376
+ return {
377
+ node,
378
+ offset: caretOffsets[caretIndex] ?? 0
379
+ };
380
+ }
381
+ function nearestGridPositionInLine(line, localX) {
382
+ let nearest = null;
383
+ for (const cell of line.querySelectorAll("[data-vecto-grid-cell]")) {
384
+ const x = Number(cell.dataset.vectoGridX ?? 0);
385
+ const advance = Number(cell.dataset.vectoGridAdvance ?? 0);
386
+ if (localX >= x && localX <= x + advance) return gridCellCaret(cell, localX);
387
+ const distance = localX < x ? x - localX : localX - (x + advance);
388
+ if (!nearest || distance < nearest.distance) nearest = { cell, distance };
389
+ }
390
+ if (!nearest) return null;
391
+ return gridCellCaret(nearest.cell, localX);
392
+ }
393
+ function parseCssMatrix(transform) {
394
+ if (!transform || transform === "none") return [1, 0, 0, 1];
395
+ const values = transform.slice(transform.indexOf("(") + 1, transform.lastIndexOf(")")).split(",").map(Number);
396
+ return values.length >= 4 && values.slice(0, 4).every(Number.isFinite) ? [values[0], values[1], values[2], values[3]] : [1, 0, 0, 1];
397
+ }
398
+ function clientToGridLocal(contentEl, canvas, clientX, clientY) {
399
+ const line = contentEl.querySelector("[data-vecto-grid-line]");
400
+ const originMarker = line?.querySelector('[data-vecto-grid-basis="origin"]');
401
+ const xMarker = line?.querySelector('[data-vecto-grid-basis="x"]');
402
+ const yMarker = line?.querySelector('[data-vecto-grid-basis="y"]');
403
+ if (line && originMarker && xMarker && yMarker) {
404
+ const origin = originMarker.getBoundingClientRect();
405
+ const xPoint = xMarker.getBoundingClientRect();
406
+ const yPoint = yMarker.getBoundingClientRect();
407
+ const xx = xPoint.left - origin.left;
408
+ const xy = xPoint.top - origin.top;
409
+ const yx = yPoint.left - origin.left;
410
+ const yy = yPoint.top - origin.top;
411
+ const determinant2 = xx * yy - xy * yx;
412
+ if (Number.isFinite(determinant2) && Math.abs(determinant2) > 1e-9) {
413
+ const dx2 = clientX - origin.left;
414
+ const dy2 = clientY - origin.top;
415
+ return {
416
+ x: (Number.parseFloat(line.style.left) || 0) + (yy * dx2 - yx * dy2) / determinant2,
417
+ y: (Number.parseFloat(line.style.top) || 0) + (-xy * dx2 + xx * dy2) / determinant2
418
+ };
419
+ }
420
+ }
421
+ const [a, b, c, d] = parseCssMatrix(getComputedStyle(contentEl).transform);
422
+ const canvasRect = canvas.getBoundingClientRect();
423
+ const logicalWidth = Number.parseFloat(canvas.style.width) || canvas.clientWidth || canvas.width;
424
+ const logicalHeight = Number.parseFloat(canvas.style.height) || canvas.clientHeight || canvas.height;
425
+ const scaleX = logicalWidth > 0 ? canvasRect.width / logicalWidth : 1;
426
+ const scaleY = logicalHeight > 0 ? canvasRect.height / logicalHeight : 1;
427
+ const worldX = (clientX - canvasRect.left) / scaleX;
428
+ const worldY = (clientY - canvasRect.top) / scaleY;
429
+ const dx = worldX - (Number.parseFloat(contentEl.style.left) || 0);
430
+ const dy = worldY - (Number.parseFloat(contentEl.style.top) || 0);
431
+ const determinant = a * d - b * c;
432
+ if (!Number.isFinite(determinant) || Math.abs(determinant) <= 1e-9) return null;
433
+ return {
434
+ x: (d * dx - c * dy) / determinant,
435
+ y: (-b * dx + a * dy) / determinant
436
+ };
437
+ }
438
+ function nearestGridPosition(contentEl, canvas, clientX, clientY) {
439
+ const lines = [...contentEl.querySelectorAll("[data-vecto-grid-line]")];
440
+ if (lines.length === 0) return null;
441
+ const [a, b, c, d] = parseCssMatrix(getComputedStyle(contentEl).transform);
442
+ if (a > 0 && d > 0 && Math.abs(b) <= 1e-9 && Math.abs(c) <= 1e-9) {
443
+ let nearest2 = null;
444
+ for (const line of lines) {
445
+ const rect = line.getBoundingClientRect();
446
+ const dy = clientY < rect.top ? rect.top - clientY : clientY > rect.bottom ? clientY - rect.bottom : 0;
447
+ const dx = clientX < rect.left ? rect.left - clientX : clientX > rect.right ? clientX - rect.right : 0;
448
+ const distance = dy * 4096 + dx;
449
+ if (!nearest2 || distance < nearest2.distance) nearest2 = { line, distance, rect };
450
+ }
451
+ if (!nearest2) return null;
452
+ const localWidth = Number.parseFloat(nearest2.line.style.width) || 0;
453
+ const scaleX = localWidth > 0 && nearest2.rect.width > 0 ? nearest2.rect.width / localWidth : 1;
454
+ const localX = (clientX - nearest2.rect.left) / scaleX;
455
+ return nearestGridPositionInLine(nearest2.line, localX);
456
+ }
457
+ const local = clientToGridLocal(contentEl, canvas, clientX, clientY);
458
+ if (!local) return null;
459
+ let nearest = null;
460
+ for (const line of lines) {
461
+ const left = Number.parseFloat(line.style.left) || 0;
462
+ const top = Number.parseFloat(line.style.top) || 0;
463
+ const width = Number.parseFloat(line.style.width) || 0;
464
+ const height = Number.parseFloat(line.style.height) || 0;
465
+ const dy = local.y < top ? top - local.y : local.y > top + height ? local.y - top - height : 0;
466
+ const dx = local.x < left ? left - local.x : local.x > left + width ? local.x - left - width : 0;
467
+ const distance = dy * 4096 + dx;
468
+ if (!nearest || distance < nearest.distance) nearest = { line, distance };
469
+ }
470
+ if (!nearest) return null;
471
+ const lineLeft = Number.parseFloat(nearest.line.style.left) || 0;
472
+ return nearestGridPositionInLine(nearest.line, local.x - lineLeft);
473
+ }
474
+ function nearestTextPositionInLine(line, x, y) {
475
+ const texts = collectTextNodes(line);
476
+ if (texts.length === 0) return null;
477
+ let nearest = null;
478
+ for (const node of texts) {
479
+ const candidate = nearestOffsetForPoint(node, x, y);
480
+ if (!nearest || candidate.distance < nearest.distance) {
481
+ let { offset } = candidate;
482
+ while (offset > 0 && node.data[offset - 1] === "\n") offset--;
483
+ nearest = { position: { node, offset }, distance: candidate.distance };
484
+ }
485
+ }
486
+ return nearest?.position ?? null;
487
+ }
488
+ function nearestTextPositionInProjection(contentEl, canvas, x, y, eventTarget) {
489
+ if (contentEl.dataset.vectoContentGrid !== void 0) {
490
+ return nearestGridPosition(contentEl, canvas, x, y);
491
+ }
492
+ let targetLine = eventTarget;
493
+ while (targetLine && targetLine.parentElement !== contentEl) {
494
+ if (!contentEl.contains(targetLine)) {
495
+ targetLine = null;
496
+ break;
497
+ }
498
+ targetLine = targetLine.parentElement;
499
+ }
500
+ if (targetLine?.parentElement === contentEl) {
501
+ return nearestTextPositionInLine(targetLine, x, y);
502
+ }
503
+ let bestLine = null;
504
+ let bestDist = Infinity;
505
+ for (let i = 0; i < contentEl.children.length; i++) {
506
+ const child = contentEl.children[i];
507
+ const rect = child.getBoundingClientRect();
508
+ if (rect.width <= 0 && rect.height <= 0) continue;
509
+ const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
510
+ const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
511
+ const dist = dy * 4096 + dx;
512
+ if (dist < bestDist) {
513
+ bestDist = dist;
514
+ bestLine = child;
515
+ }
516
+ }
517
+ if (bestLine) return nearestTextPositionInLine(bestLine, x, y);
518
+ return nearestTextPositionInLine(contentEl, x, y);
519
+ }
520
+ function projectionAbsoluteOffset(root, caret) {
521
+ let offset = 0;
522
+ for (const node of collectTextNodes(root)) {
523
+ if (node === caret.node) return offset + Math.min(caret.offset, node.data.length);
524
+ offset += node.data.length;
525
+ }
526
+ return null;
527
+ }
528
+ function projectionCaretAt(root, absoluteOffset, affinity) {
529
+ const nodes = collectTextNodes(root);
530
+ if (nodes.length === 0) return null;
531
+ let remaining = Math.max(0, absoluteOffset);
532
+ for (let index = 0; index < nodes.length; index++) {
533
+ const node = nodes[index];
534
+ if (remaining < node.data.length || remaining === node.data.length && affinity === "backward") {
535
+ return { node, offset: remaining };
536
+ }
537
+ if (remaining === node.data.length && index === nodes.length - 1) {
538
+ return { node, offset: remaining };
539
+ }
540
+ remaining -= node.data.length;
541
+ }
542
+ const last = nodes[nodes.length - 1];
543
+ return { node: last, offset: last.data.length };
544
+ }
545
+ function selectProjectionUnit(selection, root, caret, unit) {
546
+ const absoluteOffset = projectionAbsoluteOffset(root, caret);
547
+ const text = root.textContent ?? "";
548
+ if (absoluteOffset === null || text.length === 0) return false;
549
+ let start = absoluteOffset;
550
+ let end = absoluteOffset;
551
+ if (unit === "line") {
552
+ for (let index = Math.max(0, absoluteOffset - 1); index >= 0; index--) {
553
+ if (text[index] === "\n" || text[index] === "\r") {
554
+ start = index + 1;
555
+ if (text[index] === "\r" && text[index + 1] === "\n") start++;
556
+ break;
557
+ }
558
+ }
559
+ const cr = text.indexOf("\r", absoluteOffset);
560
+ const lf = text.indexOf("\n", absoluteOffset);
561
+ const separator = [cr, lf].filter((index) => index >= 0).sort((a, b) => a - b)[0];
562
+ end = separator === void 0 ? text.length : separator;
563
+ } else if (caretWordSegmenter) {
564
+ const segments = [...caretWordSegmenter.segment(text)];
565
+ const selected = segments.find(
566
+ (segment) => segment.isWordLike && absoluteOffset >= segment.index && absoluteOffset <= segment.index + segment.segment.length
567
+ ) ?? segments.find((segment) => segment.isWordLike && segment.index >= absoluteOffset) ?? [...segments].reverse().find((segment) => segment.isWordLike);
568
+ if (selected) {
569
+ start = selected.index;
570
+ end = selected.index + selected.segment.length;
571
+ }
572
+ } else {
573
+ const isWord = (character) => /[\p{L}\p{N}_]/u.test(character);
574
+ while (start > 0 && isWord(text[start - 1])) start--;
575
+ while (end < text.length && isWord(text[end])) end++;
576
+ }
577
+ const anchor = projectionCaretAt(root, start, "forward");
578
+ const focus = projectionCaretAt(root, end, "backward");
579
+ if (!anchor || !focus) return false;
580
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
581
+ return true;
582
+ }
583
+ function extendSelection(selection, anchor, focus) {
584
+ try {
585
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
586
+ return;
587
+ } catch {
588
+ }
589
+ try {
590
+ selection.collapse(anchor.node, anchor.offset);
591
+ selection.extend(focus.node, focus.offset);
592
+ return;
593
+ } catch {
594
+ }
595
+ const anchorRange = document.createRange();
596
+ anchorRange.setStart(anchor.node, anchor.offset);
597
+ anchorRange.collapse(true);
598
+ const focusRange = document.createRange();
599
+ focusRange.setStart(focus.node, focus.offset);
600
+ focusRange.collapse(true);
601
+ const range = document.createRange();
602
+ if (anchorRange.compareBoundaryPoints(Range.START_TO_START, focusRange) <= 0) {
603
+ range.setStart(anchor.node, anchor.offset);
604
+ range.setEnd(focus.node, focus.offset);
605
+ } else {
606
+ range.setStart(focus.node, focus.offset);
607
+ range.setEnd(anchor.node, anchor.offset);
608
+ }
609
+ selection.removeAllRanges();
610
+ selection.addRange(range);
611
+ }
320
612
  var Scene = class _Scene {
321
613
  static webglCreator = null;
322
614
  static webgpuManagerClass = null;
@@ -373,7 +665,25 @@ var Scene = class _Scene {
373
665
  a11yElements = /* @__PURE__ */ new Map();
374
666
  /** DOM nodes mirroring static text content, keyed by entity id. */
375
667
  contentElements = /* @__PURE__ */ new Map();
668
+ /** Pending cold font-calibration frame per projected grid entity. */
669
+ contentGridCalibrationFrames = /* @__PURE__ */ new Map();
670
+ /** Detached, untransformed font probes used by the cold calibration pass. */
671
+ contentGridCalibrationProbes = /* @__PURE__ */ new Map();
672
+ /** Invalidates grid font calibration after browser font availability changes. */
673
+ contentFontEpoch = 0;
674
+ /** Cached Canvas-to-client scale for the current font/viewport epoch. */
675
+ contentMetricScaleEpoch = -1;
676
+ contentMetricScaleX = 1;
376
677
  contentProjectionEnabled = true;
678
+ /**
679
+ * True while a text-selection drag that started on a projection's blank
680
+ * region (no text node under the press) is being driven manually — the
681
+ * browser has no native anchor for it, so mousemove extends the Selection
682
+ * from the position we resolved ourselves.
683
+ */
684
+ blankRegionSelectionDrag = false;
685
+ contentSelectionAnchor = null;
686
+ contentSelectionEndListener = null;
377
687
  // Animation/interactive flags collected during the render walk (tree-walk
378
688
  // fusion): the loop reads last frame's answers instead of re-walking the
379
689
  // tree up to 4× per tick. Start true so the first tick stays conservative.
@@ -480,6 +790,90 @@ var Scene = class _Scene {
480
790
  this.a11yRoot.style.pointerEvents = "none";
481
791
  this.a11yRoot.style.overflow = "hidden";
482
792
  this.a11yRoot.style.zIndex = "10";
793
+ this.a11yRoot.style.userSelect = "text";
794
+ this.a11yRoot.addEventListener("mousedown", (e) => {
795
+ if (e.button !== 0) return;
796
+ const target = e.target;
797
+ const contentEl = target.closest("[data-vecto-content]");
798
+ if (target === this.a11yRoot || !contentEl) return;
799
+ if (getComputedStyle(contentEl).pointerEvents !== "auto") return;
800
+ const selection = window.getSelection();
801
+ if (!selection) return;
802
+ this.a11yRoot.style.pointerEvents = "auto";
803
+ const resolved = nearestTextPositionInProjection(
804
+ contentEl,
805
+ this.canvas,
806
+ e.clientX,
807
+ e.clientY,
808
+ target
809
+ );
810
+ if (resolved) {
811
+ if (e.detail >= 2) {
812
+ selection.removeAllRanges();
813
+ selectProjectionUnit(selection, contentEl, resolved, e.detail >= 3 ? "line" : "word");
814
+ this.endContentSelectionDrag();
815
+ e.preventDefault();
816
+ return;
817
+ }
818
+ const existingAnchor = e.shiftKey && selection.anchorNode instanceof Text ? { node: selection.anchorNode, offset: selection.anchorOffset } : null;
819
+ const anchor = existingAnchor && this.a11yRoot.contains(existingAnchor.node) ? existingAnchor : resolved;
820
+ if (e.shiftKey && existingAnchor) extendSelection(selection, anchor, resolved);
821
+ else selection.collapse(resolved.node, resolved.offset);
822
+ this.contentSelectionAnchor = anchor;
823
+ this.blankRegionSelectionDrag = true;
824
+ e.preventDefault();
825
+ }
826
+ });
827
+ this.a11yRoot.addEventListener("dblclick", (e) => {
828
+ const target = e.target;
829
+ const contentEl = target.closest("[data-vecto-content]");
830
+ if (!contentEl || getComputedStyle(contentEl).pointerEvents !== "auto") return;
831
+ const selection = window.getSelection();
832
+ const caret = nearestTextPositionInProjection(
833
+ contentEl,
834
+ this.canvas,
835
+ e.clientX,
836
+ e.clientY,
837
+ target
838
+ );
839
+ if (!selection || !caret) return;
840
+ selection.removeAllRanges();
841
+ selectProjectionUnit(selection, contentEl, caret, "word");
842
+ this.endContentSelectionDrag();
843
+ e.preventDefault();
844
+ });
845
+ this.a11yRoot.addEventListener("mousemove", (e) => {
846
+ if (!this.blankRegionSelectionDrag) return;
847
+ const selection = window.getSelection();
848
+ if (!selection || selection.rangeCount === 0) return;
849
+ const target = e.target;
850
+ let contentEl = target.closest("[data-vecto-content]");
851
+ if (!contentEl) {
852
+ let bestDistance = Infinity;
853
+ for (const candidate of this.contentElements.values()) {
854
+ if (getComputedStyle(candidate).pointerEvents !== "auto") continue;
855
+ const rect = candidate.getBoundingClientRect();
856
+ const dx = e.clientX < rect.left ? rect.left - e.clientX : e.clientX > rect.right ? e.clientX - rect.right : 0;
857
+ const dy = e.clientY < rect.top ? rect.top - e.clientY : e.clientY > rect.bottom ? e.clientY - rect.bottom : 0;
858
+ const distance = dx * dx + dy * dy;
859
+ if (distance < bestDistance) {
860
+ bestDistance = distance;
861
+ contentEl = candidate;
862
+ }
863
+ }
864
+ }
865
+ const focus = contentEl ? nearestTextPositionInProjection(contentEl, this.canvas, e.clientX, e.clientY, target) : null;
866
+ const anchor = this.contentSelectionAnchor;
867
+ if (focus && anchor) {
868
+ extendSelection(selection, anchor, focus);
869
+ }
870
+ });
871
+ const endDrag = () => this.endContentSelectionDrag();
872
+ this.a11yRoot.addEventListener("mouseup", endDrag);
873
+ this.a11yRoot.addEventListener("mouseleave", endDrag);
874
+ window.addEventListener("mouseup", endDrag);
875
+ window.addEventListener("blur", endDrag);
876
+ this.contentSelectionEndListener = endDrag;
483
877
  if (canvas.parentElement) {
484
878
  canvas.parentElement.appendChild(this.a11yRoot);
485
879
  }
@@ -522,6 +916,7 @@ var Scene = class _Scene {
522
916
  if (typeof document !== "undefined" && document.fonts) {
523
917
  this.fontLoadHandler = () => {
524
918
  clearCssLineBoxMetrics();
919
+ this.contentFontEpoch++;
525
920
  this.markDirty();
526
921
  };
527
922
  document.fonts.ready.then(this.fontLoadHandler);
@@ -529,6 +924,18 @@ var Scene = class _Scene {
529
924
  }
530
925
  this.setupEvents();
531
926
  }
927
+ endContentSelectionDrag() {
928
+ this.blankRegionSelectionDrag = false;
929
+ this.contentSelectionAnchor = null;
930
+ if (this.a11yRoot) this.a11yRoot.style.pointerEvents = "none";
931
+ }
932
+ releaseContentSelectionForRebuild(el) {
933
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
934
+ const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
935
+ if (!ownsSelection) return;
936
+ this.endContentSelectionDrag();
937
+ selection?.removeAllRanges();
938
+ }
532
939
  /**
533
940
  * Expose the underlying {@link IRenderer} for advanced direct-draw operations.
534
941
  *
@@ -559,6 +966,24 @@ var Scene = class _Scene {
559
966
  this.root.add(entity);
560
967
  return this;
561
968
  }
969
+ clearContentGridState(entityId, el) {
970
+ const calibrationFrame = this.contentGridCalibrationFrames.get(entityId);
971
+ if (calibrationFrame !== void 0 && typeof cancelAnimationFrame === "function") {
972
+ cancelAnimationFrame(calibrationFrame);
973
+ }
974
+ this.contentGridCalibrationFrames.delete(entityId);
975
+ this.contentGridCalibrationProbes.get(entityId)?.remove();
976
+ this.contentGridCalibrationProbes.delete(entityId);
977
+ delete el.dataset.vectoGridCalibrationPending;
978
+ delete el.dataset.vectoGridCalibration;
979
+ delete el.dataset.vectoGridReady;
980
+ delete el.dataset.vectoContentGrid;
981
+ delete el.dataset.vectoGridCarriers;
982
+ delete el.dataset.vectoGridMaterializeMs;
983
+ delete el.dataset.vectoGridCalibrationSamples;
984
+ delete el.dataset.vectoGridCalibrationMs;
985
+ this.releaseContentSelectionForRebuild(el);
986
+ }
562
987
  removeA11yRecursively(node) {
563
988
  if (node.isDOMPortal) {
564
989
  node.domElement.remove();
@@ -568,6 +993,7 @@ var Scene = class _Scene {
568
993
  }
569
994
  const contentEl = this.contentElements.get(node.id);
570
995
  if (contentEl) {
996
+ this.clearContentGridState(node.id, contentEl);
571
997
  contentEl.remove();
572
998
  this.contentElements.delete(node.id);
573
999
  this.a11yNeedsReorder = true;
@@ -649,6 +1075,11 @@ var Scene = class _Scene {
649
1075
  if (typeof window !== "undefined" && !this.disableWindowResize) {
650
1076
  window.removeEventListener("resize", this.resizeHandler);
651
1077
  }
1078
+ if (typeof window !== "undefined" && this.contentSelectionEndListener) {
1079
+ window.removeEventListener("mouseup", this.contentSelectionEndListener);
1080
+ window.removeEventListener("blur", this.contentSelectionEndListener);
1081
+ this.contentSelectionEndListener = null;
1082
+ }
652
1083
  if (typeof window !== "undefined" && this.canvas && typeof this.canvas.removeEventListener === "function") {
653
1084
  if (this.pointerMoveListener) {
654
1085
  this.canvas.removeEventListener("pointermove", this.pointerMoveListener);
@@ -662,6 +1093,15 @@ var Scene = class _Scene {
662
1093
  this.a11yElements.clear();
663
1094
  for (const el of this.contentElements.values()) el.remove();
664
1095
  this.contentElements.clear();
1096
+ if (typeof cancelAnimationFrame === "function") {
1097
+ for (const frame of this.contentGridCalibrationFrames.values()) {
1098
+ cancelAnimationFrame(frame);
1099
+ }
1100
+ }
1101
+ this.contentGridCalibrationFrames.clear();
1102
+ for (const probe of this.contentGridCalibrationProbes.values()) probe.remove();
1103
+ this.contentGridCalibrationProbes.clear();
1104
+ this.endContentSelectionDrag();
665
1105
  this.pointRenderer?.destroy();
666
1106
  this.renderer.dispose?.();
667
1107
  this.glCanvas?.remove();
@@ -946,6 +1386,10 @@ var Scene = class _Scene {
946
1386
  if (attrs.label !== void 0 && el.getAttribute("aria-label") !== attrs.label) {
947
1387
  el.setAttribute("aria-label", attrs.label);
948
1388
  }
1389
+ const semanticPointerEvents = attrs.pointerEvents ?? "auto";
1390
+ if (el.style.pointerEvents !== semanticPointerEvents) {
1391
+ el.style.pointerEvents = semanticPointerEvents;
1392
+ }
949
1393
  const implicitTabIndex = !isNativelyFocusable(el) && attrs.role && INTERACTIVE_A11Y_ROLES.has(attrs.role) ? 0 : null;
950
1394
  const desiredTabIndex = attrs.tabIndex ?? implicitTabIndex;
951
1395
  if (desiredTabIndex === null) {
@@ -1062,6 +1506,7 @@ var Scene = class _Scene {
1062
1506
  let el = this.contentElements.get(node.id);
1063
1507
  if (!projection || !projection.text) {
1064
1508
  if (el) {
1509
+ this.clearContentGridState(node.id, el);
1065
1510
  el.remove();
1066
1511
  this.contentElements.delete(node.id);
1067
1512
  this.a11yNeedsReorder = true;
@@ -1080,7 +1525,6 @@ var Scene = class _Scene {
1080
1525
  s.forcedColorAdjust = "none";
1081
1526
  s.setProperty("-webkit-text-fill-color", "transparent");
1082
1527
  s.whiteSpace = "pre-wrap";
1083
- s.overflow = "hidden";
1084
1528
  s.zIndex = "0";
1085
1529
  el.addEventListener(
1086
1530
  "wheel",
@@ -1094,13 +1538,19 @@ var Scene = class _Scene {
1094
1538
  this.a11yNeedsReorder = true;
1095
1539
  }
1096
1540
  const lines = projection.lines;
1097
- if (lines && lines.length > 0) {
1541
+ if (!projection.grid && el.dataset.vectoContentGrid !== void 0) {
1542
+ this.clearContentGridState(node.id, el);
1543
+ }
1544
+ if (projection.grid) {
1545
+ this.syncContentGridProjection(node, el, projection, projection.grid);
1546
+ } else if (lines && lines.length > 0) {
1098
1547
  const signature = JSON.stringify({
1099
1548
  lines,
1100
1549
  fallbackFont: projection.font ?? "",
1101
1550
  fallbackLineHeight: projection.lineHeight ?? 16
1102
1551
  });
1103
1552
  if (el.dataset.vectoProjectionLines !== signature) {
1553
+ this.releaseContentSelectionForRebuild(el);
1104
1554
  el.replaceChildren();
1105
1555
  for (let index = 0; index < lines.length; index++) {
1106
1556
  const line = lines[index];
@@ -1132,13 +1582,21 @@ var Scene = class _Scene {
1132
1582
  el.dataset.vectoProjectionLines = signature;
1133
1583
  }
1134
1584
  } else {
1135
- if (el.textContent !== projection.text) el.textContent = projection.text;
1585
+ if (el.textContent !== projection.text) {
1586
+ this.releaseContentSelectionForRebuild(el);
1587
+ el.textContent = projection.text;
1588
+ }
1136
1589
  delete el.dataset.vectoProjectionLines;
1137
1590
  }
1138
1591
  const font = projection.font ?? "";
1139
1592
  if (el.style.font !== font) el.style.font = font;
1140
1593
  const lineHeight = projection.lineHeight !== void 0 ? `${projection.lineHeight}px` : "";
1141
1594
  if (el.style.lineHeight !== lineHeight) el.style.lineHeight = lineHeight;
1595
+ const ligatures = projection.ligatures === "none" ? "none" : "";
1596
+ if (el.style.getPropertyValue("font-variant-ligatures") !== ligatures) {
1597
+ el.style.setProperty("font-variant-ligatures", ligatures);
1598
+ el.style.setProperty("font-kerning", ligatures ? "none" : "");
1599
+ }
1142
1600
  const hidden = node.interactive ? "true" : null;
1143
1601
  if (el.getAttribute("aria-hidden") !== hidden) {
1144
1602
  if (hidden) el.setAttribute("aria-hidden", hidden);
@@ -1200,6 +1658,245 @@ var Scene = class _Scene {
1200
1658
  const display = visible ? "" : "none";
1201
1659
  if (el.style.display !== display) el.style.display = display;
1202
1660
  }
1661
+ /**
1662
+ * Materialize a prepared grid in logical source order while positioning each
1663
+ * carrier from the shared canvas geometry. Browser font measurement happens
1664
+ * later in one cold read/write batch, never inside projection synchronization.
1665
+ */
1666
+ syncContentGridProjection(node, el, projection, grid) {
1667
+ if (grid.source !== projection.text) {
1668
+ throw new Error("ContentProjection.grid.source must equal ContentProjection.text");
1669
+ }
1670
+ const signature = `${grid.revision}`;
1671
+ if (el.dataset.vectoContentGrid !== signature) {
1672
+ const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
1673
+ this.clearContentGridState(node.id, el);
1674
+ el.replaceChildren();
1675
+ const projectionLines = projection.lines ?? [];
1676
+ for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
1677
+ const gridLine = grid.lines[lineIndex];
1678
+ const projectedLine = projectionLines[lineIndex];
1679
+ const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
1680
+ const baseline = projectedLine?.baseline ?? grid.baseline;
1681
+ const lineFont = projectedLine?.font ?? grid.font;
1682
+ const lineElement = document.createElement("span");
1683
+ lineElement.dir = "ltr";
1684
+ lineElement.dataset.vectoGridLine = `${lineIndex}`;
1685
+ lineElement.style.position = "absolute";
1686
+ lineElement.style.left = `${projectedLine?.x ?? 0}px`;
1687
+ lineElement.style.top = `${(projectedLine?.y ?? lineIndex * grid.lineHeight) + baseline - cssLineBoxBaseline(lineFont, lineHeight)}px`;
1688
+ lineElement.style.width = `${gridLine.width}px`;
1689
+ lineElement.style.height = `${lineHeight}px`;
1690
+ lineElement.style.whiteSpace = "pre";
1691
+ lineElement.style.font = lineFont;
1692
+ lineElement.style.lineHeight = `${lineHeight}px`;
1693
+ if (gridLine.cells.length === 0) {
1694
+ lineElement.textContent = grid.source.slice(gridLine.sourceEnd, gridLine.nextSourceStart);
1695
+ } else {
1696
+ let logicalX = 0;
1697
+ for (let cellIndex = 0; cellIndex < gridLine.cells.length; cellIndex++) {
1698
+ const cell = gridLine.cells[cellIndex];
1699
+ const cellElement = document.createElement("span");
1700
+ cellElement.dir = "ltr";
1701
+ const separator = cellIndex === gridLine.cells.length - 1 ? grid.source.slice(gridLine.sourceEnd, gridLine.nextSourceStart) : "";
1702
+ const sourceText = grid.source.slice(cell.sourceStart, cell.sourceEnd);
1703
+ cellElement.textContent = sourceText + separator;
1704
+ cellElement.dataset.vectoGridCell = `${cellIndex}`;
1705
+ cellElement.dataset.vectoGridSourceLength = `${sourceText.length}`;
1706
+ cellElement.dataset.vectoGridSourceStart = `${cell.sourceStart}`;
1707
+ cellElement.dataset.vectoGridSourceEnd = `${cell.sourceEnd}`;
1708
+ cellElement.dataset.vectoGridCaretOffsets = cell.sourceCaretOffsets.join(",");
1709
+ cellElement.dataset.vectoGridLevel = `${cell.level}`;
1710
+ cellElement.dataset.vectoGridAdvance = `${cell.advance}`;
1711
+ cellElement.dataset.vectoGridX = `${cell.x}`;
1712
+ cellElement.style.position = "relative";
1713
+ cellElement.style.display = "inline-block";
1714
+ cellElement.style.left = `${cell.x - logicalX}px`;
1715
+ cellElement.style.top = "0";
1716
+ cellElement.style.width = `${cell.advance}px`;
1717
+ cellElement.style.height = `${lineHeight}px`;
1718
+ cellElement.style.boxSizing = "border-box";
1719
+ cellElement.style.verticalAlign = "top";
1720
+ cellElement.style.whiteSpace = "pre";
1721
+ cellElement.style.font = lineFont;
1722
+ cellElement.style.lineHeight = `${lineHeight}px`;
1723
+ cellElement.style.transformOrigin = "0 50%";
1724
+ lineElement.appendChild(cellElement);
1725
+ logicalX += cell.advance;
1726
+ }
1727
+ }
1728
+ if (lineIndex === 0) {
1729
+ for (const [basis, left, top] of [
1730
+ ["origin", 0, 0],
1731
+ ["x", 1, 0],
1732
+ ["y", 0, 1]
1733
+ ]) {
1734
+ const marker = document.createElement("span");
1735
+ marker.dataset.vectoGridBasis = basis;
1736
+ marker.setAttribute("aria-hidden", "true");
1737
+ marker.style.position = "absolute";
1738
+ marker.style.left = `${left}px`;
1739
+ marker.style.top = `${top}px`;
1740
+ marker.style.width = "0";
1741
+ marker.style.height = "0";
1742
+ marker.style.pointerEvents = "none";
1743
+ marker.style.userSelect = "none";
1744
+ lineElement.appendChild(marker);
1745
+ }
1746
+ }
1747
+ el.appendChild(lineElement);
1748
+ }
1749
+ el.dataset.vectoProjectionLines = signature;
1750
+ el.dataset.vectoContentGrid = signature;
1751
+ el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
1752
+ if (typeof performance !== "undefined") {
1753
+ el.dataset.vectoGridMaterializeMs = `${performance.now() - materializeStart}`;
1754
+ }
1755
+ delete el.dataset.vectoGridCalibration;
1756
+ delete el.dataset.vectoGridReady;
1757
+ }
1758
+ const pageScaleX = this.getContentMetricScaleX();
1759
+ const calibrationKey = `${signature}:${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
1760
+ if (el.dataset.vectoGridCalibration !== calibrationKey) {
1761
+ this.scheduleContentGridCalibration(node.id, el, calibrationKey, pageScaleX);
1762
+ }
1763
+ }
1764
+ getContentMetricScaleX() {
1765
+ if (this.contentMetricScaleEpoch === this.contentFontEpoch) {
1766
+ return this.contentMetricScaleX;
1767
+ }
1768
+ const rect = this.canvas.getBoundingClientRect();
1769
+ const inlineWidth = parseInlinePx(this.canvas.style.width);
1770
+ const logicalWidth = inlineWidth ?? (this.canvas.clientWidth || this.width);
1771
+ const scale = logicalWidth > 0 ? rect.width / logicalWidth : 1;
1772
+ this.contentMetricScaleX = Number.isFinite(scale) && scale > 0 ? scale : 1;
1773
+ this.contentMetricScaleEpoch = this.contentFontEpoch;
1774
+ return this.contentMetricScaleX;
1775
+ }
1776
+ scheduleContentGridCalibration(entityId, el, calibrationKey, pageScaleX) {
1777
+ if (typeof requestAnimationFrame !== "function") return;
1778
+ if (el.dataset.vectoGridCalibrationPending === calibrationKey) return;
1779
+ const previous = this.contentGridCalibrationFrames.get(entityId);
1780
+ if (previous !== void 0 && typeof cancelAnimationFrame === "function") {
1781
+ cancelAnimationFrame(previous);
1782
+ }
1783
+ this.contentGridCalibrationProbes.get(entityId)?.remove();
1784
+ this.contentGridCalibrationProbes.delete(entityId);
1785
+ const calibrationStart = typeof performance !== "undefined" ? performance.now() : 0;
1786
+ const probe = document.createElement("div");
1787
+ probe.setAttribute("aria-hidden", "true");
1788
+ probe.dataset.vectoGridProbe = entityId;
1789
+ probe.style.position = "absolute";
1790
+ probe.style.left = "-100000px";
1791
+ probe.style.top = "0";
1792
+ probe.style.width = "100000px";
1793
+ probe.style.height = "1px";
1794
+ probe.style.visibility = "hidden";
1795
+ probe.style.pointerEvents = "none";
1796
+ probe.style.whiteSpace = "pre";
1797
+ probe.style.contain = "layout style paint";
1798
+ const probeOrigin = document.createElement("span");
1799
+ probeOrigin.style.position = "absolute";
1800
+ probeOrigin.style.left = "0";
1801
+ probeOrigin.style.top = "0";
1802
+ const probeX = document.createElement("span");
1803
+ probeX.style.position = "absolute";
1804
+ probeX.style.left = "1px";
1805
+ probeX.style.top = "0";
1806
+ probe.append(probeOrigin, probeX);
1807
+ const measurements = [];
1808
+ const measurementsByKey = /* @__PURE__ */ new Map();
1809
+ for (const target of el.querySelectorAll("[data-vecto-grid-cell]")) {
1810
+ const sourceLength = Number(target.dataset.vectoGridSourceLength ?? 0);
1811
+ const targetWidth = Number(target.dataset.vectoGridAdvance ?? 0);
1812
+ if (sourceLength <= 0 || targetWidth <= 0) continue;
1813
+ const sourceText = target.textContent?.slice(0, sourceLength) ?? "";
1814
+ if (!sourceText) continue;
1815
+ const measurementKey = JSON.stringify([
1816
+ target.style.font,
1817
+ target.style.lineHeight,
1818
+ targetWidth,
1819
+ sourceText
1820
+ ]);
1821
+ const shared = measurementsByKey.get(measurementKey);
1822
+ if (shared) {
1823
+ shared.targets.push(target);
1824
+ continue;
1825
+ }
1826
+ const carrier = document.createElement("span");
1827
+ carrier.dir = "ltr";
1828
+ carrier.style.position = "absolute";
1829
+ carrier.style.left = "0";
1830
+ carrier.style.top = "0";
1831
+ carrier.style.whiteSpace = "pre";
1832
+ carrier.style.font = target.style.font;
1833
+ carrier.style.lineHeight = target.style.lineHeight;
1834
+ carrier.style.fontVariantLigatures = "none";
1835
+ carrier.style.fontKerning = "none";
1836
+ const source = document.createTextNode(sourceText);
1837
+ carrier.appendChild(source);
1838
+ probe.appendChild(carrier);
1839
+ const measurement = { targets: [target], targetWidth, sourceLength, source };
1840
+ measurements.push(measurement);
1841
+ measurementsByKey.set(measurementKey, measurement);
1842
+ }
1843
+ (this.a11yRoot ?? document.body ?? document.documentElement).appendChild(probe);
1844
+ el.dataset.vectoGridCalibrationSamples = `${measurements.length}`;
1845
+ this.contentGridCalibrationProbes.set(entityId, probe);
1846
+ el.dataset.vectoGridCalibrationPending = calibrationKey;
1847
+ delete el.dataset.vectoGridReady;
1848
+ const readFrame = requestAnimationFrame(() => {
1849
+ if (!el.isConnected || el.dataset.vectoGridCalibrationPending !== calibrationKey) {
1850
+ probe.remove();
1851
+ this.contentGridCalibrationProbes.delete(entityId);
1852
+ this.contentGridCalibrationFrames.delete(entityId);
1853
+ return;
1854
+ }
1855
+ const updates = [];
1856
+ const probeOriginRect = probeOrigin.getBoundingClientRect();
1857
+ const probeXRect = probeX.getBoundingClientRect();
1858
+ const basisScale = Math.abs(probeXRect.left - probeOriginRect.left);
1859
+ const projectionPageScaleX = Number.isFinite(basisScale) && basisScale > 0 ? basisScale : pageScaleX;
1860
+ let valid = true;
1861
+ for (const measurement of measurements) {
1862
+ const range = document.createRange();
1863
+ range.setStart(measurement.source, 0);
1864
+ range.setEnd(measurement.source, measurement.sourceLength);
1865
+ const natural = range.getBoundingClientRect().width;
1866
+ if (!Number.isFinite(natural) || natural <= 0) {
1867
+ valid = false;
1868
+ break;
1869
+ }
1870
+ const scale = measurement.targetWidth * projectionPageScaleX / natural;
1871
+ for (const element of measurement.targets) updates.push({ element, scale });
1872
+ }
1873
+ probe.remove();
1874
+ this.contentGridCalibrationProbes.delete(entityId);
1875
+ if (!valid) {
1876
+ delete el.dataset.vectoGridCalibrationPending;
1877
+ this.contentGridCalibrationFrames.delete(entityId);
1878
+ return;
1879
+ }
1880
+ const writeFrame = requestAnimationFrame(() => {
1881
+ if (!el.isConnected || el.dataset.vectoGridCalibrationPending !== calibrationKey) {
1882
+ this.contentGridCalibrationFrames.delete(entityId);
1883
+ return;
1884
+ }
1885
+ for (const { element, scale } of updates) {
1886
+ element.style.transform = Math.abs(scale - 1) <= 1e-3 ? "" : `scaleX(${scale})`;
1887
+ }
1888
+ el.dataset.vectoGridCalibration = calibrationKey;
1889
+ el.dataset.vectoGridReady = "true";
1890
+ if (typeof performance !== "undefined") {
1891
+ el.dataset.vectoGridCalibrationMs = `${performance.now() - calibrationStart}`;
1892
+ }
1893
+ delete el.dataset.vectoGridCalibrationPending;
1894
+ this.contentGridCalibrationFrames.delete(entityId);
1895
+ });
1896
+ this.contentGridCalibrationFrames.set(entityId, writeFrame);
1897
+ });
1898
+ this.contentGridCalibrationFrames.set(entityId, readFrame);
1899
+ }
1203
1900
  enforceA11yDomOrder() {
1204
1901
  if (!this.a11yRoot) return;
1205
1902
  this.fullViewportElements.length = 0;
@@ -1727,6 +2424,7 @@ var Scene = class _Scene {
1727
2424
  resize(width, height) {
1728
2425
  this.width = width;
1729
2426
  this.height = height;
2427
+ this.contentFontEpoch++;
1730
2428
  if (typeof this.renderer.resize === "function") {
1731
2429
  this.renderer.resize(width, height);
1732
2430
  }
@@ -2654,5 +3352,6 @@ export {
2654
3352
  loadSpline,
2655
3353
  parseColorToRGBA,
2656
3354
  polySegmentToBezier,
3355
+ prepareContentGrid,
2657
3356
  sanitizeUrl
2658
3357
  };