@vectojs/core 0.2.6 → 0.2.8

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.
@@ -32,25 +32,48 @@ function computeLineSegments(top, bottom, maxWidth, exclusions) {
32
32
  }
33
33
  var LayoutEngine = (_class = class {
34
34
 
35
+ /**
36
+ * Horizontal alignment. `'justify'` stretches inter-word spaces (or, for
37
+ * space-less CJK lines, inter-character gaps) so wrapped lines end flush at
38
+ * `maxWidth`; the last line of each paragraph stays ragged. Only applies to
39
+ * the object layout path without exclusion shapes.
40
+ */
41
+ __init() {this.textAlign = "left"}
35
42
 
36
- __init() {this.preserveLeadingSpaces = false}
43
+ __init2() {this.preserveLeadingSpaces = false}
37
44
 
38
45
 
39
- __init2() {this.wordCache = /* @__PURE__ */ new Map()}
40
- __init3() {this.graphemeCache = /* @__PURE__ */ new Map()}
46
+ __init3() {this.wordCache = /* @__PURE__ */ new Map()}
47
+ __init4() {this.graphemeCache = /* @__PURE__ */ new Map()}
41
48
  // Paragraph-level memo so re-`prepare()` of mostly-unchanged text (streaming
42
49
  // append, live logs) reuses untouched paragraphs by reference instead of
43
50
  // re-segmenting/re-measuring the whole document — turning per-token cost from
44
51
  // O(document) into O(changed paragraph). Keyed by fontSize + text; invalidated
45
52
  // when the font atlas (which drives glyph widths) changes.
46
- __init4() {this.paragraphCache = /* @__PURE__ */ new Map()}
53
+ __init5() {this.paragraphCache = /* @__PURE__ */ new Map()}
47
54
  // Same memo for the rich path ({@link prepareRich}); keyed by fontSize + text +
48
55
  // a per-paragraph *value* signature of the inline styles, so a streaming
49
56
  // typewriter that appends styled runs reuses its untouched paragraphs.
50
- __init5() {this.richParagraphCache = /* @__PURE__ */ new Map()}
51
- __init6() {this.lastAtlas = null}
57
+ __init6() {this.richParagraphCache = /* @__PURE__ */ new Map()}
58
+ __init7() {this.lastAtlas = null}
52
59
 
53
- constructor(maxWidth, maxHeight, measurer) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);
60
+ __init8() {this._hyphenate = null}
61
+ /**
62
+ * Optional hyphenator: given a word, return its break parts (e.g.
63
+ * `['hyphen', 'ation']`). Used at wrap time when a word doesn't fit; a
64
+ * visible '-' is drawn at the chosen break. Soft hyphens (U+00AD) in the
65
+ * source work without any hyphenator. Setting this clears the prepared
66
+ * caches (break opportunities are baked in during prepare()).
67
+ */
68
+ get hyphenate() {
69
+ return this._hyphenate;
70
+ }
71
+ set hyphenate(fn) {
72
+ this._hyphenate = fn;
73
+ this.paragraphCache.clear();
74
+ this.richParagraphCache.clear();
75
+ }
76
+ constructor(maxWidth, maxHeight, measurer) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);_class.prototype.__init7.call(this);_class.prototype.__init8.call(this);
54
77
  this.maxWidth = maxWidth;
55
78
  this.maxHeight = maxHeight;
56
79
  this.measurer = _nullishCoalesce(measurer, () => ( null));
@@ -157,7 +180,13 @@ var LayoutEngine = (_class = class {
157
180
  const word = segment.segment;
158
181
  const glyphs = [];
159
182
  let width = 0;
183
+ let breakPoints;
160
184
  for (const char of this.getGraphemes(word)) {
185
+ if (char === "\xAD") {
186
+ (breakPoints ??= []).push(glyphs.length);
187
+ shapedCharIdx += char.length;
188
+ continue;
189
+ }
161
190
  const visualStart = shapedCharIdx;
162
191
  const visualEnd = shapedCharIdx + char.length;
163
192
  const rawStart = indexMap[visualStart];
@@ -182,11 +211,23 @@ var LayoutEngine = (_class = class {
182
211
  width += w;
183
212
  shapedCharIdx += char.length;
184
213
  }
214
+ if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
215
+ const parts = this._hyphenate(word);
216
+ if (parts.length > 1) {
217
+ breakPoints = [];
218
+ let count = 0;
219
+ for (let pi = 0; pi < parts.length - 1; pi++) {
220
+ for (const _g of this.getGraphemes(parts[pi])) count++;
221
+ breakPoints.push(count);
222
+ }
223
+ }
224
+ }
185
225
  words.push({
186
226
  glyphs,
187
227
  width,
188
228
  isWordLike: segment.isWordLike,
189
- isWhitespace: word.trim().length === 0
229
+ isWhitespace: word.trim().length === 0,
230
+ breakPoints
190
231
  });
191
232
  }
192
233
  const prepared = {
@@ -200,7 +241,12 @@ var LayoutEngine = (_class = class {
200
241
  paragraphs.push(prepared);
201
242
  offset += paragraph.length + 1;
202
243
  }
203
- return { paragraphs, fontSize, fallbackToCanvas: fallbackToCanvas || void 0 };
244
+ return {
245
+ paragraphs,
246
+ fontSize,
247
+ fallbackToCanvas: fallbackToCanvas || void 0,
248
+ hyphenWidth: this.glyphWidth(this.glyphKeyFor("-", fontAtlas), fontAtlas, fontSize)
249
+ };
204
250
  }
205
251
  /**
206
252
  * **Cold pass for rich text.** Like {@link prepare}, but takes an array of
@@ -274,7 +320,13 @@ var LayoutEngine = (_class = class {
274
320
  const word = segment.segment;
275
321
  const glyphs = [];
276
322
  let width = 0;
323
+ let breakPoints;
277
324
  for (const char of this.getGraphemes(word)) {
325
+ if (char === "\xAD") {
326
+ (breakPoints ??= []).push(glyphs.length);
327
+ shapedCharIdx += char.length;
328
+ continue;
329
+ }
278
330
  const visualStart = shapedCharIdx;
279
331
  const visualEnd = shapedCharIdx + char.length;
280
332
  const rawStart = indexMap[visualStart];
@@ -302,11 +354,23 @@ var LayoutEngine = (_class = class {
302
354
  width += w;
303
355
  shapedCharIdx += char.length;
304
356
  }
357
+ if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
358
+ const parts = this._hyphenate(word);
359
+ if (parts.length > 1) {
360
+ breakPoints = [];
361
+ let count = 0;
362
+ for (let pi = 0; pi < parts.length - 1; pi++) {
363
+ for (const _g of this.getGraphemes(parts[pi])) count++;
364
+ breakPoints.push(count);
365
+ }
366
+ }
367
+ }
305
368
  words.push({
306
369
  glyphs,
307
370
  width,
308
371
  isWordLike: segment.isWordLike,
309
- isWhitespace: word.trim().length === 0
372
+ isWhitespace: word.trim().length === 0,
373
+ breakPoints
310
374
  });
311
375
  }
312
376
  const prepared = {
@@ -350,7 +414,7 @@ var LayoutEngine = (_class = class {
350
414
  let si = 0;
351
415
  let currentLineNodes = [];
352
416
  let paragraphBaseLevel = 0;
353
- const commitLine = () => {
417
+ const commitLine = (justifyTo) => {
354
418
  if (currentLineNodes.length === 0) return;
355
419
  const runs = [];
356
420
  let currentRun = [];
@@ -375,6 +439,37 @@ var LayoutEngine = (_class = class {
375
439
  node.isRTL = node.level % 2 === 1;
376
440
  x += node.width;
377
441
  }
442
+ if (justifyTo !== void 0 && runs.length === 1) {
443
+ let lastContent = run.length - 1;
444
+ while (lastContent >= 0 && run[lastContent].char.trim() === "") lastContent--;
445
+ if (lastContent > 0) {
446
+ const contentEnd = run[lastContent].x + run[lastContent].width;
447
+ const slack = justifyTo - contentEnd;
448
+ if (slack > 0 && slack <= (justifyTo - runStartX) * 0.5) {
449
+ const spaceIdx = [];
450
+ for (let k = 1; k < lastContent; k++) {
451
+ if (run[k].char.trim() === "") spaceIdx.push(k);
452
+ }
453
+ if (spaceIdx.length > 0) {
454
+ const extra = slack / spaceIdx.length;
455
+ let shift = 0;
456
+ let nextSpace = 0;
457
+ for (let k = 0; k <= lastContent; k++) {
458
+ run[k].x += shift;
459
+ if (nextSpace < spaceIdx.length && k === spaceIdx[nextSpace]) {
460
+ run[k].width += extra;
461
+ shift += extra;
462
+ nextSpace++;
463
+ }
464
+ }
465
+ } else {
466
+ const extra = slack / lastContent;
467
+ for (let k = 1; k <= lastContent; k++) run[k].x += extra * k;
468
+ }
469
+ if (justifyTo > maxLineWidth) maxLineWidth = justifyTo;
470
+ }
471
+ }
472
+ }
378
473
  for (const node of run) {
379
474
  layoutNodes.push(node);
380
475
  }
@@ -394,6 +489,8 @@ var LayoutEngine = (_class = class {
394
489
  }
395
490
  return false;
396
491
  };
492
+ const justifyTarget = this.textAlign === "justify" && !hasEx ? this.maxWidth : void 0;
493
+ const hyphenWidth = _nullishCoalesce(prepared.hyphenWidth, () => ( fontSize * 0.3));
397
494
  for (const paragraph of prepared.paragraphs) {
398
495
  if (paragraph.isEmpty) {
399
496
  commitLine();
@@ -411,16 +508,65 @@ var LayoutEngine = (_class = class {
411
508
  }
412
509
  const lineHeight = pMax * 1.5;
413
510
  if (!startLine(lineHeight)) break;
414
- for (const word of paragraph.words) {
415
- if (currentX + word.width > segs[si].x1 && currentX > segs[si].x0) {
416
- if (word.isWordLike === false && word.isWhitespace) continue;
417
- if (si < segs.length - 1) {
418
- si++;
419
- currentX = segs[si].x0;
420
- } else {
421
- commitLine();
422
- currentY += lineHeight;
423
- if (!startLine(lineHeight)) break;
511
+ const wordQueue = paragraph.words.slice();
512
+ for (let qi = 0; qi < wordQueue.length; qi++) {
513
+ const word = wordQueue[qi];
514
+ if (currentX + word.width > segs[si].x1) {
515
+ if (!hasEx && word.breakPoints && word.breakPoints.length > 0) {
516
+ const avail = segs[si].x1 - currentX;
517
+ let chosen = -1;
518
+ let prefixWidth = 0;
519
+ let acc = 0;
520
+ let bpIdx = 0;
521
+ for (let g = 0; g < word.glyphs.length && bpIdx < word.breakPoints.length; g++) {
522
+ acc += word.glyphs[g].width;
523
+ if (g + 1 === word.breakPoints[bpIdx]) {
524
+ if (acc + hyphenWidth <= avail) {
525
+ chosen = word.breakPoints[bpIdx];
526
+ prefixWidth = acc;
527
+ }
528
+ bpIdx++;
529
+ }
530
+ }
531
+ if (chosen > 0) {
532
+ const anchorGlyph = word.glyphs[chosen - 1];
533
+ const prefix = {
534
+ glyphs: [
535
+ ...word.glyphs.slice(0, chosen),
536
+ {
537
+ char: "-",
538
+ width: hyphenWidth,
539
+ level: anchorGlyph.level,
540
+ sourceIndex: anchorGlyph.sourceIndex,
541
+ sourceLength: 0
542
+ }
543
+ ],
544
+ width: prefixWidth + hyphenWidth,
545
+ isWordLike: true,
546
+ isWhitespace: false
547
+ };
548
+ const rest = {
549
+ glyphs: word.glyphs.slice(chosen),
550
+ width: word.width - prefixWidth,
551
+ isWordLike: true,
552
+ isWhitespace: false,
553
+ breakPoints: word.breakPoints.filter((bp) => bp > chosen).map((bp) => bp - chosen)
554
+ };
555
+ wordQueue.splice(qi, 1, prefix, rest);
556
+ qi--;
557
+ continue;
558
+ }
559
+ }
560
+ if (currentX > segs[si].x0) {
561
+ if (word.isWordLike === false && word.isWhitespace) continue;
562
+ if (si < segs.length - 1) {
563
+ si++;
564
+ currentX = segs[si].x0;
565
+ } else {
566
+ commitLine(justifyTarget);
567
+ currentY += lineHeight;
568
+ if (!startLine(lineHeight)) break;
569
+ }
424
570
  }
425
571
  }
426
572
  for (const glyph of word.glyphs) {
@@ -433,7 +579,7 @@ var LayoutEngine = (_class = class {
433
579
  si++;
434
580
  currentX = segs[si].x0;
435
581
  } else {
436
- commitLine();
582
+ commitLine(justifyTarget);
437
583
  currentY += lineHeight;
438
584
  if (!startLine(lineHeight)) break;
439
585
  }
@@ -549,20 +695,20 @@ var LayoutEngine = (_class = class {
549
695
  }
550
696
  }
551
697
  }, _class);
552
- var LayoutResultBuffer = (_class2 = class _LayoutResultBuffer {constructor() { _class2.prototype.__init7.call(this);_class2.prototype.__init8.call(this);_class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this); }
698
+ var LayoutResultBuffer = (_class2 = class _LayoutResultBuffer {constructor() { _class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this);_class2.prototype.__init13.call(this);_class2.prototype.__init14.call(this); }
553
699
  static __initStatic() {this.CAPACITY = 16384}
554
700
  /** X positions of each glyph. */
555
- __init7() {this.xs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
701
+ __init9() {this.xs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
556
702
  /** Y positions of each glyph. */
557
- __init8() {this.ys = new Float32Array(_LayoutResultBuffer.CAPACITY)}
703
+ __init10() {this.ys = new Float32Array(_LayoutResultBuffer.CAPACITY)}
558
704
  /** Widths of each glyph. */
559
- __init9() {this.ws = new Float32Array(_LayoutResultBuffer.CAPACITY)}
705
+ __init11() {this.ws = new Float32Array(_LayoutResultBuffer.CAPACITY)}
560
706
  /** Heights of each glyph. */
561
- __init10() {this.hs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
707
+ __init12() {this.hs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
562
708
  /** Character for each glyph slot. */
563
- __init11() {this.chars = Array.from({ length: _LayoutResultBuffer.CAPACITY })}
709
+ __init13() {this.chars = Array.from({ length: _LayoutResultBuffer.CAPACITY })}
564
710
  /** Number of valid glyphs written in this buffer. */
565
- __init12() {this.count = 0}
711
+ __init14() {this.count = 0}
566
712
  /** Reset the buffer for reuse. Does NOT free memory. */
567
713
  reset() {
568
714
  this.count = 0;
@@ -434,6 +434,7 @@ var Entity = (_class4 = class {
434
434
  startTime: -1,
435
435
  startProps: {}
436
436
  });
437
+ _optionalChain([this, 'access', _40 => _40.scene, 'optionalAccess', _41 => _41.markDirty, 'call', _42 => _42()]);
437
438
  return this;
438
439
  }
439
440
  /** Write a driver-computed value to a backing field without re-triggering the setter. */
@@ -489,10 +490,10 @@ var Entity = (_class4 = class {
489
490
  const active = driver;
490
491
  const onDone = active.onDone;
491
492
  active.onDone = void 0;
492
- _optionalChain([onDone, 'optionalCall', _40 => _40()]);
493
+ _optionalChain([onDone, 'optionalCall', _43 => _43()]);
493
494
  }
494
495
  _spawnDriver(prop, to, cfg) {
495
- if (prop !== "opacity" && _optionalChain([this, 'access', _41 => _41.scene, 'optionalAccess', _42 => _42.prefersReducedMotion])) {
496
+ if (prop !== "opacity" && _optionalChain([this, 'access', _44 => _44.scene, 'optionalAccess', _45 => _45.prefersReducedMotion])) {
496
497
  const existing2 = this._drivers.get(prop);
497
498
  if (existing2) this._settleDriver(existing2);
498
499
  this._drivers.delete(prop);
@@ -508,11 +509,11 @@ var Entity = (_class4 = class {
508
509
  const from = this._currentOf(prop);
509
510
  const driver = isTweenConfig(cfg) ? new TweenDriver(from, to, cfg) : new SpringDriver(from, to, cfg === "spring" ? {} : cfg);
510
511
  this._drivers.set(prop, driver);
511
- _optionalChain([this, 'access', _43 => _43.scene, 'optionalAccess', _44 => _44.markDirty, 'call', _45 => _45()]);
512
+ _optionalChain([this, 'access', _46 => _46.scene, 'optionalAccess', _47 => _47.markDirty, 'call', _48 => _48()]);
512
513
  }
513
514
  /** Assignment path when a declarative transition is configured for `prop`. */
514
515
  _animateProp(prop, to) {
515
- const cfg = _optionalChain([this, 'access', _46 => _46._transitions, 'optionalAccess', _47 => _47.get, 'call', _48 => _48(prop)]);
516
+ const cfg = _optionalChain([this, 'access', _49 => _49._transitions, 'optionalAccess', _50 => _50.get, 'call', _51 => _51(prop)]);
516
517
  if (!cfg) {
517
518
  this._applyAnimated(prop, to);
518
519
  return;
@@ -562,7 +563,7 @@ var Entity = (_class4 = class {
562
563
  this._applyAnimated(prop, driver.value);
563
564
  }
564
565
  }
565
- _optionalChain([this, 'access', _49 => _49.scene, 'optionalAccess', _50 => _50.markDirty, 'call', _51 => _51()]);
566
+ _optionalChain([this, 'access', _52 => _52.scene, 'optionalAccess', _53 => _53.markDirty, 'call', _54 => _54()]);
566
567
  }
567
568
  /**
568
569
  * Advance the entity's internal state for one frame.
@@ -616,7 +617,7 @@ var Entity = (_class4 = class {
616
617
  * @example entity.on('click', (e) => console.log('clicked', e));
617
618
  */
618
619
  on(event, callback, options) {
619
- const map = _optionalChain([options, 'optionalAccess', _52 => _52.capture]) ? this.captureListeners : this.listeners;
620
+ const map = _optionalChain([options, 'optionalAccess', _55 => _55.capture]) ? this.captureListeners : this.listeners;
620
621
  if (!map.has(event)) {
621
622
  map.set(event, []);
622
623
  }
@@ -632,7 +633,7 @@ var Entity = (_class4 = class {
632
633
  * @returns `this` for method chaining.
633
634
  */
634
635
  off(event, callback, options) {
635
- const handlers = (_optionalChain([options, 'optionalAccess', _53 => _53.capture]) ? this.captureListeners : this.listeners).get(event);
636
+ const handlers = (_optionalChain([options, 'optionalAccess', _56 => _56.capture]) ? this.captureListeners : this.listeners).get(event);
636
637
  if (handlers) {
637
638
  const idx = handlers.indexOf(callback);
638
639
  if (idx !== -1) handlers.splice(idx, 1);
@@ -892,6 +893,19 @@ var Entity = (_class4 = class {
892
893
  getBatchRect() {
893
894
  return null;
894
895
  }
896
+ /**
897
+ * Opt into DOM content projection for entities that render static text.
898
+ * The {@link Scene} mirrors the returned text as a transparent DOM node
899
+ * positioned over the drawn glyphs, making canvas text findable (Ctrl+F),
900
+ * readable by screen readers and crawlers, translatable, and — when
901
+ * `selectable` is set — natively selectable. Returns `null` by default.
902
+ * Read on the a11y sync cadence, so text changes propagate automatically.
903
+ *
904
+ * @returns The projection descriptor, or `null` to project nothing.
905
+ */
906
+ getContentProjection() {
907
+ return null;
908
+ }
895
909
  /**
896
910
  * Whether this entity still has a queued/running tween animation, or an
897
911
  * active {@link setTransition}/{@link animateTo}/{@link springTo} property
@@ -1063,10 +1077,22 @@ var MSDFTextEntity = (_class6 = class extends Entity {
1063
1077
  if (res.seqId < this.lastRenderedSeqId) return;
1064
1078
  this.lastRenderedSeqId = res.seqId;
1065
1079
  this.layoutResult = res;
1066
- _optionalChain([this, 'access', _54 => _54.scene, 'optionalAccess', _55 => _55.markDirty, 'call', _56 => _56()]);
1080
+ _optionalChain([this, 'access', _57 => _57.scene, 'optionalAccess', _58 => _58.markDirty, 'call', _59 => _59()]);
1067
1081
  }
1068
1082
  });
1069
1083
  }
1084
+ /**
1085
+ * Mirror the rendered text into the DOM content layer: find-in-page, screen
1086
+ * readers, crawlers, and translation see the same string the canvas draws.
1087
+ */
1088
+ getContentProjection() {
1089
+ if (!this.text) return null;
1090
+ return {
1091
+ text: this.text,
1092
+ font: `${this.fontSize}px ${this.fallbackFont}`,
1093
+ lineHeight: this.lineHeight
1094
+ };
1095
+ }
1070
1096
  isPointInside(globalX, globalY) {
1071
1097
  if (!this.layoutResult) return false;
1072
1098
  const local = this.worldToLocal(globalX, globalY);
@@ -34,6 +34,10 @@ var CanvasRenderer = class _CanvasRenderer {
34
34
  this.height = size?.height ?? (typeof window !== "undefined" ? window.innerHeight : canvas.height || 0);
35
35
  canvas.width = this.width * dpr;
36
36
  canvas.height = this.height * dpr;
37
+ if (canvas.style) {
38
+ canvas.style.width = `${this.width}px`;
39
+ canvas.style.height = `${this.height}px`;
40
+ }
37
41
  const ctx = canvas.getContext("2d");
38
42
  this.ctx = ctx;
39
43
  if (ctx) ctx.scale(dpr, dpr);
@@ -821,6 +825,19 @@ void main() {
821
825
  vec4 t = texture(u_tex, v_uv);
822
826
  outColor = vec4(t.rgb * v_tint.rgb, t.a * v_tint.a);
823
827
  }`;
828
+ var CIRCLE_QUAD_FRAG = `#version 300 es
829
+ precision mediump float;
830
+ in vec2 v_uv;
831
+ in vec4 v_tint;
832
+ out vec4 outColor;
833
+ void main() {
834
+ vec2 c = v_uv - 0.5;
835
+ float d = length(c);
836
+ float aa = fwidth(d);
837
+ float alpha = 1.0 - smoothstep(0.5 - aa, 0.5, d);
838
+ if (alpha <= 0.0) discard;
839
+ outColor = vec4(v_tint.rgb, v_tint.a * alpha);
840
+ }`;
824
841
  var MSDF_FRAG = `#version 300 es
825
842
  precision mediump float;
826
843
  uniform sampler2D u_tex;
@@ -887,7 +904,9 @@ function createWebGLPointRenderer(canvas) {
887
904
  const rectProgram = link(gl, RECT_VERT, RECT_FRAG);
888
905
  const spriteProgram = link(gl, SPRITE_VERT, SPRITE_FRAG);
889
906
  const msdfProgram = link(gl, SPRITE_VERT, MSDF_FRAG);
890
- if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram) return null;
907
+ const circleQuadProgram = link(gl, SPRITE_VERT, CIRCLE_QUAD_FRAG);
908
+ if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram)
909
+ return null;
891
910
  gl.enable(gl.BLEND);
892
911
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
893
912
  const pAPos = gl.getAttribLocation(pointProgram, "a_pos");
@@ -947,6 +966,20 @@ function createWebGLPointRenderer(canvas) {
947
966
  gl.vertexAttribPointer(gAUv, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 8);
948
967
  gl.enableVertexAttribArray(gATint);
949
968
  gl.vertexAttribPointer(gATint, 4, gl.FLOAT, false, SPRITE_VERT_STRIDE, 16);
969
+ const cAPos = gl.getAttribLocation(circleQuadProgram, "a_pos");
970
+ const cAUv = gl.getAttribLocation(circleQuadProgram, "a_uv");
971
+ const cATint = gl.getAttribLocation(circleQuadProgram, "a_tint");
972
+ const cURes = gl.getUniformLocation(circleQuadProgram, "u_resolution");
973
+ const circleQuadBuffer = gl.createBuffer();
974
+ const circleQuadVAO = gl.createVertexArray();
975
+ gl.bindVertexArray(circleQuadVAO);
976
+ gl.bindBuffer(gl.ARRAY_BUFFER, circleQuadBuffer);
977
+ gl.enableVertexAttribArray(cAPos);
978
+ gl.vertexAttribPointer(cAPos, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 0);
979
+ gl.enableVertexAttribArray(cAUv);
980
+ gl.vertexAttribPointer(cAUv, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 8);
981
+ gl.enableVertexAttribArray(cATint);
982
+ gl.vertexAttribPointer(cATint, 4, gl.FLOAT, false, SPRITE_VERT_STRIDE, 16);
950
983
  gl.bindVertexArray(null);
951
984
  let texture = null;
952
985
  let textureSource = null;
@@ -961,10 +994,16 @@ function createWebGLPointRenderer(canvas) {
961
994
  let spriteCount = 0;
962
995
  let glyphData = new Float32Array(FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE * 1024);
963
996
  let glyphCount = 0;
997
+ let circleQuadData = new Float32Array(
998
+ FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE * 64
999
+ );
1000
+ let circleQuadCount = 0;
964
1001
  let logicalW = 0;
965
1002
  let logicalH = 0;
966
1003
  let dpr = 1;
967
1004
  let destroyed = false;
1005
+ const pointSizeRange = typeof gl.getParameter === "function" ? gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) : null;
1006
+ const maxPointSize = pointSizeRange ? pointSizeRange[1] : Infinity;
968
1007
  const drawGlyphs = () => {
969
1008
  if (glyphCount === 0 || !msdfTexture) return;
970
1009
  const floats = glyphCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
@@ -997,6 +1036,7 @@ function createWebGLPointRenderer(canvas) {
997
1036
  rectCount = 0;
998
1037
  spriteCount = 0;
999
1038
  glyphCount = 0;
1039
+ circleQuadCount = 0;
1000
1040
  gl.clearColor(0, 0, 0, 0);
1001
1041
  gl.clear(gl.COLOR_BUFFER_BIT);
1002
1042
  },
@@ -1104,6 +1144,35 @@ function createWebGLPointRenderer(canvas) {
1104
1144
  glyphCount++;
1105
1145
  },
1106
1146
  addCircle(x, y, radius, color, alpha = 1) {
1147
+ const needsQuad = logicalW > 0 && (x < radius || y < radius || x > logicalW - radius || y > logicalH - radius) || radius * 2 * dpr > maxPointSize;
1148
+ if (needsQuad) {
1149
+ const stride = FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE;
1150
+ circleQuadData = grow(circleQuadData, (circleQuadCount + 1) * stride);
1151
+ const [qr, qg, qb, qa] = parseColorToRGBA(color);
1152
+ const al = qa * alpha;
1153
+ const quad = [
1154
+ [x - radius, y - radius, 0, 0],
1155
+ [x + radius, y - radius, 1, 0],
1156
+ [x + radius, y + radius, 1, 1],
1157
+ [x - radius, y + radius, 0, 1]
1158
+ ];
1159
+ const order = [0, 1, 2, 0, 2, 3];
1160
+ let o2 = circleQuadCount * stride;
1161
+ for (const i of order) {
1162
+ const [vx, vy, vu, vv] = quad[i];
1163
+ circleQuadData[o2] = vx;
1164
+ circleQuadData[o2 + 1] = vy;
1165
+ circleQuadData[o2 + 2] = vu;
1166
+ circleQuadData[o2 + 3] = vv;
1167
+ circleQuadData[o2 + 4] = qr;
1168
+ circleQuadData[o2 + 5] = qg;
1169
+ circleQuadData[o2 + 6] = qb;
1170
+ circleQuadData[o2 + 7] = al;
1171
+ o2 += FLOATS_PER_SPRITE_VERT;
1172
+ }
1173
+ circleQuadCount++;
1174
+ return;
1175
+ }
1107
1176
  pointData = grow(pointData, (pointCount + 1) * FLOATS_PER_POINT);
1108
1177
  const [r, g, b, a] = parseColorToRGBA(color);
1109
1178
  const o = pointCount * FLOATS_PER_POINT;
@@ -1167,6 +1236,15 @@ function createWebGLPointRenderer(canvas) {
1167
1236
  gl.uniform1f(pUDpr, dpr);
1168
1237
  gl.drawArrays(gl.POINTS, 0, pointCount);
1169
1238
  }
1239
+ if (circleQuadCount > 0) {
1240
+ const floats = circleQuadCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
1241
+ gl.useProgram(circleQuadProgram);
1242
+ gl.bindVertexArray(circleQuadVAO);
1243
+ gl.bindBuffer(gl.ARRAY_BUFFER, circleQuadBuffer);
1244
+ gl.bufferData(gl.ARRAY_BUFFER, circleQuadData.subarray(0, floats), gl.DYNAMIC_DRAW);
1245
+ gl.uniform2f(cURes, logicalW, logicalH);
1246
+ gl.drawArrays(gl.TRIANGLES, 0, circleQuadCount * VERTS_PER_SPRITE);
1247
+ }
1170
1248
  if (spriteCount > 0 && texture) {
1171
1249
  const floats = spriteCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
1172
1250
  gl.useProgram(spriteProgram);
@@ -1189,14 +1267,17 @@ function createWebGLPointRenderer(canvas) {
1189
1267
  gl.deleteBuffer(rectBuffer);
1190
1268
  gl.deleteBuffer(spriteBuffer);
1191
1269
  gl.deleteBuffer(glyphBuffer);
1270
+ gl.deleteBuffer(circleQuadBuffer);
1192
1271
  gl.deleteVertexArray(pointVAO);
1193
1272
  gl.deleteVertexArray(rectVAO);
1194
1273
  gl.deleteVertexArray(spriteVAO);
1195
1274
  gl.deleteVertexArray(glyphVAO);
1275
+ gl.deleteVertexArray(circleQuadVAO);
1196
1276
  gl.deleteProgram(pointProgram);
1197
1277
  gl.deleteProgram(rectProgram);
1198
1278
  gl.deleteProgram(spriteProgram);
1199
1279
  gl.deleteProgram(msdfProgram);
1280
+ gl.deleteProgram(circleQuadProgram);
1200
1281
  if (texture) gl.deleteTexture(texture);
1201
1282
  if (msdfTexture) gl.deleteTexture(msdfTexture);
1202
1283
  }
@@ -1,4 +1,4 @@
1
- import { Entity } from '../tree/Entity';
1
+ import { Entity, type ContentProjection } from '../tree/Entity';
2
2
  import { IRenderer } from '../renderer/IRenderer';
3
3
  export declare class TextEntity extends Entity {
4
4
  text: string;
@@ -13,6 +13,11 @@ export declare class TextEntity extends Entity {
13
13
  lineWidth: number;
14
14
  private isHovered;
15
15
  constructor(text: string, atlas: any, maxWidth: number, fontSize?: number);
16
+ /**
17
+ * Mirror the rendered text into the DOM content layer: find-in-page, screen
18
+ * readers, crawlers, and translation see the same string the canvas draws.
19
+ */
20
+ getContentProjection(): ContentProjection | null;
16
21
  /**
17
22
  * Replace the text content. Runs the **cold** measurement pass (re-segment +
18
23
  * re-measure) since the glyphs changed, then re-lays out.
@@ -28,6 +33,17 @@ export declare class TextEntity extends Entity {
28
33
  * @returns `this` for chaining.
29
34
  */
30
35
  setMaxWidth(maxWidth: number): this;
36
+ /**
37
+ * Set horizontal alignment (`'justify'` stretches wrapped lines flush to
38
+ * the wrap width; the last line stays ragged) and reflow.
39
+ */
40
+ setTextAlign(align: 'left' | 'justify'): this;
41
+ /**
42
+ * Plug a hyphenator (word → parts). Break opportunities are baked in during
43
+ * the cold pass, so this re-prepares the current text. Soft hyphens
44
+ * (U+00AD) in the text work without one.
45
+ */
46
+ setHyphenator(fn: ((word: string) => string[]) | null): this;
31
47
  /** Hot pass: place the cached {@link PreparedText} and refresh the a11y box. */
32
48
  private applyLayout;
33
49
  isPointInside(globalX: number, globalY: number): boolean;