@vectojs/core 1.7.1 → 1.9.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-J2P6QZJV.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.
@@ -482,16 +792,88 @@ var Scene = class _Scene {
482
792
  this.a11yRoot.style.zIndex = "10";
483
793
  this.a11yRoot.style.userSelect = "text";
484
794
  this.a11yRoot.addEventListener("mousedown", (e) => {
795
+ if (e.button !== 0) return;
485
796
  const target = e.target;
486
- if (target !== this.a11yRoot && target.closest("[data-vecto-content]") && getComputedStyle(target.closest("[data-vecto-content]")).pointerEvents === "auto") {
487
- this.a11yRoot.style.pointerEvents = "auto";
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();
488
825
  }
489
826
  });
490
- const endDrag = () => {
491
- if (this.a11yRoot) this.a11yRoot.style.pointerEvents = "none";
492
- };
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();
493
872
  this.a11yRoot.addEventListener("mouseup", endDrag);
494
873
  this.a11yRoot.addEventListener("mouseleave", endDrag);
874
+ window.addEventListener("mouseup", endDrag);
875
+ window.addEventListener("blur", endDrag);
876
+ this.contentSelectionEndListener = endDrag;
495
877
  if (canvas.parentElement) {
496
878
  canvas.parentElement.appendChild(this.a11yRoot);
497
879
  }
@@ -534,6 +916,7 @@ var Scene = class _Scene {
534
916
  if (typeof document !== "undefined" && document.fonts) {
535
917
  this.fontLoadHandler = () => {
536
918
  clearCssLineBoxMetrics();
919
+ this.contentFontEpoch++;
537
920
  this.markDirty();
538
921
  };
539
922
  document.fonts.ready.then(this.fontLoadHandler);
@@ -541,6 +924,18 @@ var Scene = class _Scene {
541
924
  }
542
925
  this.setupEvents();
543
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
+ }
544
939
  /**
545
940
  * Expose the underlying {@link IRenderer} for advanced direct-draw operations.
546
941
  *
@@ -571,6 +966,24 @@ var Scene = class _Scene {
571
966
  this.root.add(entity);
572
967
  return this;
573
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
+ }
574
987
  removeA11yRecursively(node) {
575
988
  if (node.isDOMPortal) {
576
989
  node.domElement.remove();
@@ -580,6 +993,7 @@ var Scene = class _Scene {
580
993
  }
581
994
  const contentEl = this.contentElements.get(node.id);
582
995
  if (contentEl) {
996
+ this.clearContentGridState(node.id, contentEl);
583
997
  contentEl.remove();
584
998
  this.contentElements.delete(node.id);
585
999
  this.a11yNeedsReorder = true;
@@ -661,6 +1075,11 @@ var Scene = class _Scene {
661
1075
  if (typeof window !== "undefined" && !this.disableWindowResize) {
662
1076
  window.removeEventListener("resize", this.resizeHandler);
663
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
+ }
664
1083
  if (typeof window !== "undefined" && this.canvas && typeof this.canvas.removeEventListener === "function") {
665
1084
  if (this.pointerMoveListener) {
666
1085
  this.canvas.removeEventListener("pointermove", this.pointerMoveListener);
@@ -674,6 +1093,15 @@ var Scene = class _Scene {
674
1093
  this.a11yElements.clear();
675
1094
  for (const el of this.contentElements.values()) el.remove();
676
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();
677
1105
  this.pointRenderer?.destroy();
678
1106
  this.renderer.dispose?.();
679
1107
  this.glCanvas?.remove();
@@ -958,6 +1386,10 @@ var Scene = class _Scene {
958
1386
  if (attrs.label !== void 0 && el.getAttribute("aria-label") !== attrs.label) {
959
1387
  el.setAttribute("aria-label", attrs.label);
960
1388
  }
1389
+ const semanticPointerEvents = attrs.pointerEvents ?? "auto";
1390
+ if (el.style.pointerEvents !== semanticPointerEvents) {
1391
+ el.style.pointerEvents = semanticPointerEvents;
1392
+ }
961
1393
  const implicitTabIndex = !isNativelyFocusable(el) && attrs.role && INTERACTIVE_A11Y_ROLES.has(attrs.role) ? 0 : null;
962
1394
  const desiredTabIndex = attrs.tabIndex ?? implicitTabIndex;
963
1395
  if (desiredTabIndex === null) {
@@ -1074,6 +1506,7 @@ var Scene = class _Scene {
1074
1506
  let el = this.contentElements.get(node.id);
1075
1507
  if (!projection || !projection.text) {
1076
1508
  if (el) {
1509
+ this.clearContentGridState(node.id, el);
1077
1510
  el.remove();
1078
1511
  this.contentElements.delete(node.id);
1079
1512
  this.a11yNeedsReorder = true;
@@ -1105,13 +1538,19 @@ var Scene = class _Scene {
1105
1538
  this.a11yNeedsReorder = true;
1106
1539
  }
1107
1540
  const lines = projection.lines;
1108
- 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) {
1109
1547
  const signature = JSON.stringify({
1110
1548
  lines,
1111
1549
  fallbackFont: projection.font ?? "",
1112
1550
  fallbackLineHeight: projection.lineHeight ?? 16
1113
1551
  });
1114
1552
  if (el.dataset.vectoProjectionLines !== signature) {
1553
+ this.releaseContentSelectionForRebuild(el);
1115
1554
  el.replaceChildren();
1116
1555
  for (let index = 0; index < lines.length; index++) {
1117
1556
  const line = lines[index];
@@ -1143,13 +1582,21 @@ var Scene = class _Scene {
1143
1582
  el.dataset.vectoProjectionLines = signature;
1144
1583
  }
1145
1584
  } else {
1146
- 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
+ }
1147
1589
  delete el.dataset.vectoProjectionLines;
1148
1590
  }
1149
1591
  const font = projection.font ?? "";
1150
1592
  if (el.style.font !== font) el.style.font = font;
1151
1593
  const lineHeight = projection.lineHeight !== void 0 ? `${projection.lineHeight}px` : "";
1152
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
+ }
1153
1600
  const hidden = node.interactive ? "true" : null;
1154
1601
  if (el.getAttribute("aria-hidden") !== hidden) {
1155
1602
  if (hidden) el.setAttribute("aria-hidden", hidden);
@@ -1211,6 +1658,245 @@ var Scene = class _Scene {
1211
1658
  const display = visible ? "" : "none";
1212
1659
  if (el.style.display !== display) el.style.display = display;
1213
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
+ }
1214
1900
  enforceA11yDomOrder() {
1215
1901
  if (!this.a11yRoot) return;
1216
1902
  this.fullViewportElements.length = 0;
@@ -1738,6 +2424,7 @@ var Scene = class _Scene {
1738
2424
  resize(width, height) {
1739
2425
  this.width = width;
1740
2426
  this.height = height;
2427
+ this.contentFontEpoch++;
1741
2428
  if (typeof this.renderer.resize === "function") {
1742
2429
  this.renderer.resize(width, height);
1743
2430
  }
@@ -2423,6 +3110,128 @@ async function loadSpline(url) {
2423
3110
  return await res.json();
2424
3111
  }
2425
3112
 
3113
+ // src/components/Rect.ts
3114
+ var Rect = class extends Entity {
3115
+ fill;
3116
+ stroke;
3117
+ strokeWidth;
3118
+ radius;
3119
+ constructor(opts = {}) {
3120
+ super();
3121
+ this.width = opts.width ?? 0;
3122
+ this.height = opts.height ?? 0;
3123
+ this.fill = opts.fill === void 0 ? "#38bdf8" : opts.fill;
3124
+ this.stroke = opts.stroke ?? null;
3125
+ this.strokeWidth = opts.strokeWidth ?? 1;
3126
+ this.radius = opts.radius ?? 0;
3127
+ }
3128
+ getBounds() {
3129
+ return { x: 0, y: 0, width: this.width, height: this.height };
3130
+ }
3131
+ /**
3132
+ * Solid-fill, square-cornered, unstroked rectangles opt into the GPU
3133
+ * instanced-rect batch (WebGL `pointBackend` only). Any stroke or corner
3134
+ * radius needs the exact Canvas path, so return `null` to fall back to
3135
+ * {@link render}. Read each frame, so an animated `fill` still batches.
3136
+ */
3137
+ getBatchRect() {
3138
+ if (!this.fill || this.stroke || this.radius > 0) return null;
3139
+ return { width: this.width, height: this.height, color: this.fill };
3140
+ }
3141
+ isPointInside(globalX, globalY) {
3142
+ const local = this.worldToLocal(globalX, globalY);
3143
+ if (!local) return false;
3144
+ return local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height;
3145
+ }
3146
+ render(renderer) {
3147
+ renderer.beginPath();
3148
+ if (this.radius > 0) {
3149
+ renderer.roundRect(0, 0, this.width, this.height, this.radius);
3150
+ } else {
3151
+ renderer.moveTo(0, 0);
3152
+ renderer.lineTo(this.width, 0);
3153
+ renderer.lineTo(this.width, this.height);
3154
+ renderer.lineTo(0, this.height);
3155
+ renderer.closePath();
3156
+ }
3157
+ if (this.fill) renderer.fill(this.fill);
3158
+ if (this.stroke) renderer.stroke(this.stroke, this.strokeWidth);
3159
+ }
3160
+ };
3161
+
3162
+ // src/components/Circle.ts
3163
+ var Circle = class extends Entity {
3164
+ fill;
3165
+ stroke;
3166
+ strokeWidth;
3167
+ _radius;
3168
+ constructor(opts = {}) {
3169
+ super();
3170
+ this._radius = opts.radius ?? 0;
3171
+ this.fill = opts.fill === void 0 ? "#38bdf8" : opts.fill;
3172
+ this.stroke = opts.stroke ?? null;
3173
+ this.strokeWidth = opts.strokeWidth ?? 1;
3174
+ this.syncBox();
3175
+ }
3176
+ get radius() {
3177
+ return this._radius;
3178
+ }
3179
+ set radius(v) {
3180
+ this._radius = v;
3181
+ this.syncBox();
3182
+ }
3183
+ /** Keep the a11y box (a square around the centered disc) in sync with radius. */
3184
+ syncBox() {
3185
+ this.width = this._radius * 2;
3186
+ this.height = this._radius * 2;
3187
+ this.a11yOffsetX = -this._radius;
3188
+ this.a11yOffsetY = -this._radius;
3189
+ }
3190
+ getBounds() {
3191
+ return {
3192
+ x: -this._radius,
3193
+ y: -this._radius,
3194
+ width: this._radius * 2,
3195
+ height: this._radius * 2
3196
+ };
3197
+ }
3198
+ /**
3199
+ * A solid-fill, unstroked circle opts into the renderer's circle batch
3200
+ * (center = entity origin, radius scaled by world scale). A stroke needs the
3201
+ * exact Canvas path, so return `null` there. Read each frame, so an animated
3202
+ * `fill`/`radius` still batches.
3203
+ */
3204
+ getBatchCircle() {
3205
+ if (!this.fill || this.stroke) return null;
3206
+ return { radius: this._radius, color: this.fill };
3207
+ }
3208
+ isPointInside(globalX, globalY) {
3209
+ const local = this.worldToLocal(globalX, globalY);
3210
+ if (!local) return false;
3211
+ return local.x * local.x + local.y * local.y <= this._radius * this._radius;
3212
+ }
3213
+ render(renderer) {
3214
+ renderer.beginPath();
3215
+ renderer.arc(0, 0, this._radius, 0, Math.PI * 2);
3216
+ renderer.closePath();
3217
+ if (this.fill) renderer.fill(this.fill);
3218
+ if (this.stroke) renderer.stroke(this.stroke, this.strokeWidth);
3219
+ }
3220
+ };
3221
+
3222
+ // src/components/Group.ts
3223
+ var Group = class extends Entity {
3224
+ constructor(...children) {
3225
+ super();
3226
+ if (children.length > 0) this.add(...children);
3227
+ }
3228
+ isPointInside() {
3229
+ return false;
3230
+ }
3231
+ render(_renderer) {
3232
+ }
3233
+ };
3234
+
2426
3235
  // src/math/SpatialHashGrid.ts
2427
3236
  var SpatialHashGrid = class {
2428
3237
  cellSize;
@@ -2624,11 +3433,13 @@ export {
2624
3433
  ArabicShaper,
2625
3434
  BidiResolver,
2626
3435
  CanvasRenderer,
3436
+ Circle,
2627
3437
  ComputeParticleEntity,
2628
3438
  DOMPortalEntity,
2629
3439
  Easing,
2630
3440
  Entity,
2631
3441
  GridTextEntity,
3442
+ Group,
2632
3443
  LayoutEngine,
2633
3444
  LayoutResultBuffer,
2634
3445
  LayoutWorkerManager,
@@ -2644,6 +3455,7 @@ export {
2644
3455
  PARTICLE_OFFSET_VELOCITY_Y,
2645
3456
  PARTICLE_STRIDE_FLOATS,
2646
3457
  REDUCED_MOTION_FPS,
3458
+ Rect,
2647
3459
  SVGEntity,
2648
3460
  SVGRenderer,
2649
3461
  Scene,
@@ -2665,5 +3477,6 @@ export {
2665
3477
  loadSpline,
2666
3478
  parseColorToRGBA,
2667
3479
  polySegmentToBezier,
3480
+ prepareContentGrid,
2668
3481
  sanitizeUrl
2669
3482
  };