@vectojs/core 1.38.0 → 1.39.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.
@@ -107,6 +107,17 @@ var CanvasRenderer = class _CanvasRenderer {
107
107
  batchCount = 0;
108
108
  _cachedFont = "";
109
109
  _cachedFill = "";
110
+ // Stroke-side counterparts of the fill/font caches, read by stroke()'s
111
+ // style-elision branch. Reset wherever the context state can change behind
112
+ // our back: restore(), resize(), contextrestored, dispose().
113
+ _cachedStroke = "";
114
+ _cachedLineWidth = -1;
115
+ _cachedLineCap = "";
116
+ _cachedLineJoin = "";
117
+ // Context-loss listeners, held for removal in dispose() so a canvas that
118
+ // outlives the renderer cannot retain it through these closures.
119
+ _onContextLost;
120
+ _onContextRestored;
110
121
  /** Backend discriminator; see {@link IRenderer.kind}. */
111
122
  kind = "canvas2d";
112
123
  /**
@@ -160,11 +171,11 @@ var CanvasRenderer = class _CanvasRenderer {
160
171
  */
161
172
  setupContextLossRecovery() {
162
173
  if (typeof this.canvas.addEventListener !== "function") return;
163
- this.canvas.addEventListener("contextlost", (e) => {
174
+ this._onContextLost = (e) => {
164
175
  e.preventDefault();
165
176
  this.contextLost = true;
166
- });
167
- this.canvas.addEventListener("contextrestored", () => {
177
+ };
178
+ this._onContextRestored = () => {
168
179
  const ctx = this.canvas.getContext("2d");
169
180
  if (!ctx) return;
170
181
  this.ctx = ctx;
@@ -174,11 +185,17 @@ var CanvasRenderer = class _CanvasRenderer {
174
185
  ctx.scale(restoredDPR, restoredDPR);
175
186
  this._cachedFont = "";
176
187
  this._cachedFill = "";
188
+ this._cachedStroke = "";
189
+ this._cachedLineWidth = -1;
190
+ this._cachedLineCap = "";
191
+ this._cachedLineJoin = "";
177
192
  this.batchActive = false;
178
193
  this.batchCount = 0;
179
194
  this.contextLost = false;
180
195
  this.contextRestoredCb?.();
181
- });
196
+ };
197
+ this.canvas.addEventListener("contextlost", this._onContextLost);
198
+ this.canvas.addEventListener("contextrestored", this._onContextRestored);
182
199
  }
183
200
  /**
184
201
  * Expose the underlying `CanvasRenderingContext2D` for operations not
@@ -228,11 +245,17 @@ var CanvasRenderer = class _CanvasRenderer {
228
245
  this.height = height;
229
246
  this.ctx.canvas.width = width * dpr;
230
247
  this.ctx.canvas.height = height * dpr;
231
- this.ctx.canvas.style.width = `${width}px`;
232
- this.ctx.canvas.style.height = `${height}px`;
248
+ if (this.ctx.canvas.style) {
249
+ this.ctx.canvas.style.width = `${width}px`;
250
+ this.ctx.canvas.style.height = `${height}px`;
251
+ }
233
252
  this.ctx.scale(dpr, dpr);
234
253
  this._cachedFont = "";
235
254
  this._cachedFill = "";
255
+ this._cachedStroke = "";
256
+ this._cachedLineWidth = -1;
257
+ this._cachedLineCap = "";
258
+ this._cachedLineJoin = "";
236
259
  this.batchActive = false;
237
260
  this.batchCount = 0;
238
261
  }
@@ -286,6 +309,10 @@ var CanvasRenderer = class _CanvasRenderer {
286
309
  this.ctx.restore();
287
310
  this._cachedFont = "";
288
311
  this._cachedFill = "";
312
+ this._cachedStroke = "";
313
+ this._cachedLineWidth = -1;
314
+ this._cachedLineCap = "";
315
+ this._cachedLineJoin = "";
289
316
  }
290
317
  /** @inheritdoc */
291
318
  translate(x, y) {
@@ -410,10 +437,17 @@ var CanvasRenderer = class _CanvasRenderer {
410
437
  stroke(color, lineWidth = 1) {
411
438
  this.flush();
412
439
  if (this.counters) this.counters.strokes++;
413
- this.ctx.strokeStyle = color;
414
- this.ctx.lineWidth = lineWidth;
415
- this.ctx.lineCap = "round";
416
- this.ctx.lineJoin = "round";
440
+ if (this._cachedStroke !== color || this._cachedLineWidth !== lineWidth || this._cachedLineCap !== "round" || this._cachedLineJoin !== "round") {
441
+ if (this.counters) this.counters.stateSwitches++;
442
+ this.ctx.strokeStyle = color;
443
+ this.ctx.lineWidth = lineWidth;
444
+ this.ctx.lineCap = "round";
445
+ this.ctx.lineJoin = "round";
446
+ this._cachedStroke = color;
447
+ this._cachedLineWidth = lineWidth;
448
+ this._cachedLineCap = "round";
449
+ this._cachedLineJoin = "round";
450
+ }
417
451
  this.ctx.stroke();
418
452
  }
419
453
  /** @inheritdoc */
@@ -452,6 +486,17 @@ var CanvasRenderer = class _CanvasRenderer {
452
486
  this.batchActive = false;
453
487
  this._cachedFont = "";
454
488
  this._cachedFill = "";
489
+ this._cachedStroke = "";
490
+ this._cachedLineWidth = -1;
491
+ this._cachedLineCap = "";
492
+ this._cachedLineJoin = "";
493
+ if (typeof this.canvas.removeEventListener === "function") {
494
+ if (this._onContextLost) this.canvas.removeEventListener("contextlost", this._onContextLost);
495
+ if (this._onContextRestored)
496
+ this.canvas.removeEventListener("contextrestored", this._onContextRestored);
497
+ }
498
+ this._onContextLost = void 0;
499
+ this._onContextRestored = void 0;
455
500
  }
456
501
  };
457
502
 
@@ -474,9 +519,13 @@ function hasSafeSchemeOrIsRelative(url) {
474
519
  return SAFE_SCHEMES.has(`${candidate.toLowerCase()}:`);
475
520
  }
476
521
  var CHAR_REF = /&(?:#\d+|#x[\da-f]+|colon|tab|newline);/i;
522
+ var MAX_CODE_POINT = 1114111;
523
+ function decodeCodePoint(value) {
524
+ return value <= MAX_CODE_POINT ? String.fromCodePoint(value) : "\uFFFD";
525
+ }
477
526
  function decodeCharacterReferences(value) {
478
527
  if (value.indexOf("&") < 0 || !CHAR_REF.test(value)) return value;
479
- return value.replace(/&#x([\da-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))).replace(/&colon;/gi, ":").replace(/&tab;/gi, " ").replace(/&newline;/gi, "\n");
528
+ return value.replace(/&#x([\da-f]+);/gi, (_, hex) => decodeCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => decodeCodePoint(parseInt(dec, 10))).replace(/&colon;/gi, ":").replace(/&tab;/gi, " ").replace(/&newline;/gi, "\n");
480
529
  }
481
530
  function sanitizeUrl(href) {
482
531
  if (typeof href !== "string") return "";
@@ -578,9 +627,12 @@ var SVGRenderer = class {
578
627
  // Cache for generated gradient defs
579
628
  gradientCounter = 0;
580
629
  gradientCache = /* @__PURE__ */ new Map();
581
- constructor(width, height) {
630
+ /** Root font size used to resolve `em`/`rem` font sizes (default 16px). */
631
+ rootFontSize;
632
+ constructor(width, height, options) {
582
633
  this.width = width;
583
634
  this.height = height;
635
+ this.rootFontSize = options?.rootFontSize ?? 16;
584
636
  installRendererDevTraps(this, "SVGRenderer");
585
637
  }
586
638
  clear() {
@@ -768,12 +820,22 @@ var SVGRenderer = class {
768
820
  `<path d="${dStr}" transform="${transformStr}" fill="none" stroke="${strokeVal}" stroke-width="${lineWidth}" stroke-opacity="${this.globalAlpha}" />`
769
821
  );
770
822
  }
823
+ /**
824
+ * Emit a `<text>` element for a text run.
825
+ *
826
+ * Font-size unit handling: `px` sizes pass through; `em` and `rem` are both
827
+ * scaled against the renderer's `rootFontSize` (constructor option, default
828
+ * 16 — matching the browser default, not necessarily the host page's root,
829
+ * so pass `{ rootFontSize }` when exporting against a non-default root).
830
+ * A percentage size (`'100% Inter'`) has no absolute meaning in this
831
+ * context and silently falls back to the default 16px font size.
832
+ */
771
833
  fillText(text, x, y, font, color) {
772
834
  this.flush();
773
835
  const sizeToken = parseFontSizeToken(font);
774
836
  let fontSize = sizeToken ? sizeToken.value : 16;
775
- if (sizeToken && sizeToken.unit !== "px") {
776
- fontSize = fontSize * 16;
837
+ if (sizeToken && (sizeToken.unit === "em" || sizeToken.unit === "rem")) {
838
+ fontSize = fontSize * this.rootFontSize;
777
839
  }
778
840
  const lowerFont = font.toLowerCase();
779
841
  const fontStyle = lowerFont.includes("italic") ? "italic" : lowerFont.includes("oblique") ? "oblique" : "normal";
@@ -955,11 +1017,11 @@ var SVGRenderer = class {
955
1017
  }
956
1018
  /**
957
1019
  * SVGRenderer accumulates strings in memory; nothing external is allocated.
958
- * Drop the buffers for GC and become idempotent.
1020
+ * Drop the buffers for GC and become idempotent. `clear()` already empties
1021
+ * the gradient cache, so no second clear is needed here.
959
1022
  */
960
1023
  dispose() {
961
1024
  this.clear();
962
- this.gradientCache.clear();
963
1025
  }
964
1026
  };
965
1027
 
@@ -1331,13 +1393,22 @@ function createWebGLPointRenderer(canvas) {
1331
1393
  premultipliedAlpha: false
1332
1394
  });
1333
1395
  if (!gl) return null;
1334
- const pointProgram = link(gl, POINT_VERT, POINT_FRAG);
1335
- const rectProgram = link(gl, RECT_VERT, RECT_FRAG);
1336
- const spriteProgram = link(gl, SPRITE_VERT, SPRITE_FRAG);
1337
- const msdfProgram = link(gl, SPRITE_VERT, MSDF_FRAG);
1338
- const circleQuadProgram = link(gl, SPRITE_VERT, CIRCLE_QUAD_FRAG);
1339
- if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram)
1396
+ const programs = [];
1397
+ const linkTracked = (vsSrc, fsSrc) => {
1398
+ const program = link(gl, vsSrc, fsSrc);
1399
+ if (program) programs.push(program);
1400
+ return program;
1401
+ };
1402
+ const pointProgram = linkTracked(POINT_VERT, POINT_FRAG);
1403
+ const rectProgram = linkTracked(RECT_VERT, RECT_FRAG);
1404
+ const spriteProgram = linkTracked(SPRITE_VERT, SPRITE_FRAG);
1405
+ const msdfProgram = linkTracked(SPRITE_VERT, MSDF_FRAG);
1406
+ const circleQuadProgram = linkTracked(SPRITE_VERT, CIRCLE_QUAD_FRAG);
1407
+ if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram) {
1408
+ for (const program of programs) gl.deleteProgram(program);
1409
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
1340
1410
  return null;
1411
+ }
1341
1412
  gl.enable(gl.BLEND);
1342
1413
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1343
1414
  const pAPos = gl.getAttribLocation(pointProgram, "a_pos");
@@ -1517,6 +1588,7 @@ function createWebGLPointRenderer(canvas) {
1517
1588
  ensureQuadIndices(spriteCount);
1518
1589
  gl.drawElements(gl.TRIANGLES, spriteCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1519
1590
  frameDrawCalls++;
1591
+ gl.bindVertexArray(null);
1520
1592
  spriteCount = 0;
1521
1593
  };
1522
1594
  const drawGlyphs = () => {
@@ -1795,6 +1867,7 @@ function createWebGLPointRenderer(canvas) {
1795
1867
  }
1796
1868
 
1797
1869
  // src/renderer/WebGPUParticleSystemManager.ts
1870
+ var recordComputePassScratch = new Float32Array(20);
1798
1871
  var COMPUTE_SHADER = `
1799
1872
  struct Particle {
1800
1873
  position: vec2<f32>,
@@ -1971,14 +2044,20 @@ var WebGPUParticleSystemManager = class {
1971
2044
  device;
1972
2045
  computePipeline = null;
1973
2046
  renderPipeline = null;
2047
+ // Held so destroy() can release them — modules were previously unreachable
2048
+ // after initPipelines and lived until context loss (#684).
2049
+ computeModule = null;
2050
+ renderModule = null;
1974
2051
  computeBindGroupLayout = null;
1975
2052
  renderBindGroupLayout = null;
1976
2053
  constructor(device) {
1977
2054
  this.device = device;
1978
2055
  }
1979
2056
  initPipelines(format) {
1980
- const computeModule = this.device.createShaderModule({ code: COMPUTE_SHADER });
1981
- const renderModule = this.device.createShaderModule({ code: RENDER_SHADER });
2057
+ this.computeModule = this.device.createShaderModule({ code: COMPUTE_SHADER });
2058
+ this.renderModule = this.device.createShaderModule({ code: RENDER_SHADER });
2059
+ const computeModule = this.computeModule;
2060
+ const renderModule = this.renderModule;
1982
2061
  this.computeBindGroupLayout = this.device.createBindGroupLayout({
1983
2062
  entries: [
1984
2063
  {
@@ -2037,6 +2116,8 @@ var WebGPUParticleSystemManager = class {
2037
2116
  });
2038
2117
  }
2039
2118
  setupEntityResources(entity) {
2119
+ releaseGpuObject(entity.gpuStorageBuffer);
2120
+ releaseGpuObject(entity.gpuUniformBuffer);
2040
2121
  const storageSize = entity.maxParticles * 32;
2041
2122
  entity.gpuStorageBuffer = this.device.createBuffer({
2042
2123
  size: storageSize,
@@ -2063,7 +2144,7 @@ var WebGPUParticleSystemManager = class {
2063
2144
  }
2064
2145
  recordComputePass(pass, entity, dt, mouseX, mouseY, width, height) {
2065
2146
  if (!this.computePipeline || !entity.computeBindGroup) return;
2066
- const uniformArray = new Float32Array(20);
2147
+ const uniformArray = recordComputePassScratch;
2067
2148
  const color = parseColorToRGBA(entity.baseColor);
2068
2149
  let opacity = 1;
2069
2150
  for (let current = entity; current; current = current.parent) {
@@ -2107,12 +2188,23 @@ var WebGPUParticleSystemManager = class {
2107
2188
  pass.draw(6, entity.maxParticles);
2108
2189
  }
2109
2190
  destroy() {
2191
+ releaseGpuObject(this.computePipeline);
2192
+ releaseGpuObject(this.renderPipeline);
2193
+ releaseGpuObject(this.computeModule);
2194
+ releaseGpuObject(this.renderModule);
2110
2195
  this.computePipeline = null;
2111
2196
  this.renderPipeline = null;
2197
+ this.computeModule = null;
2198
+ this.renderModule = null;
2112
2199
  this.computeBindGroupLayout = null;
2113
2200
  this.renderBindGroupLayout = null;
2114
2201
  }
2115
2202
  };
2203
+ function releaseGpuObject(obj) {
2204
+ if (obj && typeof obj.destroy === "function") {
2205
+ obj.destroy();
2206
+ }
2207
+ }
2116
2208
 
2117
2209
  // src/renderer/GlyphRasterAtlas.ts
2118
2210
  var HARD_MAX_SIZE = 8192;
@@ -2193,7 +2285,7 @@ var GlyphRasterAtlas = class {
2193
2285
  * (headless, unrasterizable, or too large to pack).
2194
2286
  */
2195
2287
  get(font, color, glyph) {
2196
- const key = font + "\0" + color + "\0" + glyph;
2288
+ const key = `${font.length}\0${font}${color.length}\0${color}${glyph.length}\0${glyph}`;
2197
2289
  const existing = this.slots.get(key);
2198
2290
  if (existing !== void 0) {
2199
2291
  if (existing) this._hits++;
@@ -2347,7 +2439,7 @@ var TextRasterCache = class {
2347
2439
  * fall back to {@link IRenderer.fillText}).
2348
2440
  */
2349
2441
  get(font, color, text) {
2350
- const key = font + "\0" + color + "\0" + text;
2442
+ const key = `${font.length}\0${font}${color.length}\0${color}${text.length}\0${text}`;
2351
2443
  const hit = this.cache.get(key);
2352
2444
  if (hit) {
2353
2445
  this._hits++;