@glissade/backend-dom 0.22.0-pre.0 → 0.22.0-pre.2

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.
Files changed (2) hide show
  1. package/dist/index.js +132 -15
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -20,17 +20,20 @@ function ellipsePoint(cx, cy, rx, ry, phi, theta) {
20
20
  const sp = Math.sin(phi);
21
21
  return [cx + rx * ct * cp - ry * st * sp, cy + rx * ct * sp + ry * st * cp];
22
22
  }
23
- /** An `['E', cx, cy, rx, ry, rot, a0, a1]` ellipse seg → SVG path commands
24
- * (`M start A [A …]`). SVG can't draw a ≥360° arc in one `A`, so a full
25
- * ellipse splits into two half-arcs. The dominant `E` producer is
23
+ /** An `['E', cx, cy, rx, ry, rot, a0, a1]` ellipse seg → SVG path commands. When
24
+ * `continues` (an open subpath precedes it e.g. a rounded-rect corner after an
25
+ * edge `L`) it leads with `L start` so the contour stays ONE continuous subpath
26
+ * (a stray `M` would break the fill — e1JP5_1IzI2D); standalone (a Circle's `E`
27
+ * is the first seg) it leads with `M start`. SVG can't draw a ≥360° arc in one
28
+ * `A`, so a full ellipse splits into two half-arcs. The dominant `E` producer is
26
29
  * `roundedRectSegs`/`Circle`, whose quarter/full arcs this reconstructs exactly. */
27
- function ellipseToArcs(seg) {
30
+ function ellipseToArcs(seg, continues) {
28
31
  const [, cx, cy, rx, ry, rot, a0, a1] = seg;
29
32
  const rotDeg = rot * 180 / Math.PI;
30
33
  const delta = a1 - a0;
31
34
  const sweep = delta >= 0 ? 1 : 0;
32
35
  const [sx, sy] = ellipsePoint(cx, cy, rx, ry, rot, a0);
33
- const out = [`M${sx} ${sy}`];
36
+ const out = [`${continues ? "L" : "M"}${sx} ${sy}`];
34
37
  if (Math.abs(delta) >= 2 * Math.PI - 1e-9) {
35
38
  const dir = sweep ? Math.PI : -Math.PI;
36
39
  const [mx, my] = ellipsePoint(cx, cy, rx, ry, rot, a0 + dir);
@@ -47,28 +50,86 @@ function ellipseToArcs(seg) {
47
50
  /** Turn a `PathSeg[]` into an SVG `d` attribute (M/L/C/Q/E/Z — the full set). */
48
51
  function segsToD(segs) {
49
52
  const parts = [];
53
+ let open = false;
50
54
  for (const seg of segs) switch (seg[0]) {
51
55
  case "M":
52
56
  parts.push(`M${seg[1]} ${seg[2]}`);
57
+ open = true;
53
58
  break;
54
59
  case "L":
55
60
  parts.push(`L${seg[1]} ${seg[2]}`);
61
+ open = true;
56
62
  break;
57
63
  case "C":
58
64
  parts.push(`C${seg[1]} ${seg[2]} ${seg[3]} ${seg[4]} ${seg[5]} ${seg[6]}`);
65
+ open = true;
59
66
  break;
60
67
  case "Q":
61
68
  parts.push(`Q${seg[1]} ${seg[2]} ${seg[3]} ${seg[4]}`);
69
+ open = true;
62
70
  break;
63
71
  case "E":
64
- parts.push(ellipseToArcs(seg));
72
+ parts.push(ellipseToArcs(seg, open));
73
+ open = true;
65
74
  break;
66
75
  case "Z":
67
76
  parts.push("Z");
77
+ open = false;
68
78
  break;
69
79
  }
70
80
  return parts.join(" ");
71
81
  }
82
+ /**
83
+ * Axis-aligned bounding box of a path in its LOCAL coordinate space, or null for
84
+ * an empty path. Curve control points give a safe superset (the painted curve
85
+ * never exceeds its hull); `E` uses `max(rx,ry)` so the box contains the ellipse
86
+ * at any rotation (exact for the rounded-rect `rx==ry` case). Used to size each
87
+ * shape's `<svg>` island TIGHTLY around its geometry instead of full-canvas —
88
+ * the paint is unchanged (the viewBox maps local coords 1:1), but the SVG box no
89
+ * longer spans the whole viewport, so shapes don't overlap as giant transparent
90
+ * hit-targets.
91
+ */
92
+ function pathBBox(segs) {
93
+ let minX = Infinity;
94
+ let minY = Infinity;
95
+ let maxX = -Infinity;
96
+ let maxY = -Infinity;
97
+ const pt = (x, y) => {
98
+ if (x < minX) minX = x;
99
+ if (x > maxX) maxX = x;
100
+ if (y < minY) minY = y;
101
+ if (y > maxY) maxY = y;
102
+ };
103
+ for (const seg of segs) switch (seg[0]) {
104
+ case "M":
105
+ case "L":
106
+ pt(seg[1], seg[2]);
107
+ break;
108
+ case "C":
109
+ pt(seg[1], seg[2]);
110
+ pt(seg[3], seg[4]);
111
+ pt(seg[5], seg[6]);
112
+ break;
113
+ case "Q":
114
+ pt(seg[1], seg[2]);
115
+ pt(seg[3], seg[4]);
116
+ break;
117
+ case "E": {
118
+ const r = Math.max(seg[3], seg[4]);
119
+ pt(seg[1] - r, seg[2] - r);
120
+ pt(seg[1] + r, seg[2] + r);
121
+ break;
122
+ }
123
+ case "Z": break;
124
+ }
125
+ if (!Number.isFinite(minX)) return null;
126
+ return {
127
+ x: minX,
128
+ y: minY,
129
+ w: maxX - minX,
130
+ h: maxY - minY
131
+ };
132
+ }
72
133
  /** Short, stable FNV-1a hash (hex) over a string — for deterministic def ids
73
134
  * that survive reorder (same scope+key → same id across frames). */
74
135
  function hashKey(s) {
@@ -141,15 +202,24 @@ var DomBackend = class {
141
202
  const res = list.resources[id];
142
203
  return res && res.kind === "path" ? res.segs : [];
143
204
  };
144
- /** A fresh `<svg>` geometry island absolutely positioned over the cursor. */
205
+ /**
206
+ * A fresh `<svg>` geometry island. Starts collapsed (0×0) — fillPath/
207
+ * strokePath size it TIGHTLY to the path bbox per render (#sizeIsland);
208
+ * clip's defs-only island stays collapsed (it renders nothing, only holds a
209
+ * <clipPath> referenced by id). `overflow:visible` keeps any curve/miter
210
+ * overshoot painted; `pointer-events:none` makes the box's transparent area
211
+ * click-through, so it can't swallow clicks meant for shapes behind it (the
212
+ * painted path re-enables hit-testing with its own `pointer-events`).
213
+ */
145
214
  const island = () => {
146
215
  const svg = doc.createElementNS(SVG_NS, "svg");
147
- svg.setAttribute("width", String(list.size.w));
148
- svg.setAttribute("height", String(list.size.h));
216
+ svg.setAttribute("width", "0");
217
+ svg.setAttribute("height", "0");
149
218
  svg.style.position = "absolute";
150
219
  svg.style.left = "0";
151
220
  svg.style.top = "0";
152
221
  svg.style.overflow = "visible";
222
+ svg.style.pointerEvents = "none";
153
223
  return svg;
154
224
  };
155
225
  list.commands.forEach((cmd, i) => {
@@ -159,11 +229,13 @@ var DomBackend = class {
159
229
  stack.push(cursor);
160
230
  scopeStack.push(scope);
161
231
  break;
162
- case "restore":
163
- this.#pruneCursor(cursor);
164
- cursor = stack.pop() ?? this.root;
232
+ case "restore": {
233
+ const saved = stack.pop() ?? this.root;
234
+ if (cursor !== saved) this.#pruneCursor(cursor);
235
+ cursor = saved;
165
236
  scope = scopeStack.pop() ?? "";
166
237
  break;
238
+ }
167
239
  case "transform": {
168
240
  const key = this.#keyFor(cursor, id, "transform");
169
241
  const o = this.#matchOrCreate(cursor, key, "transform", () => {
@@ -225,6 +297,7 @@ var DomBackend = class {
225
297
  const o = this.#matchOrCreate(cursor, key, "fillPath", () => {
226
298
  const svg = island();
227
299
  const path = doc.createElementNS(SVG_NS, "path");
300
+ path.style.pointerEvents = "auto";
228
301
  svg.appendChild(path);
229
302
  return {
230
303
  op: "fillPath",
@@ -234,8 +307,10 @@ var DomBackend = class {
234
307
  };
235
308
  });
236
309
  const path = o.path;
237
- this.#setAttr(path, o, "d", "d", segsToD(pathSegs(cmd.path)));
310
+ const segs = pathSegs(cmd.path);
311
+ this.#setAttr(path, o, "d", "d", segsToD(segs));
238
312
  this.#setAttr(path, o, "fill", "fill", this.#resolvePaint(cmd.paint, o, scope, key));
313
+ this.#sizeIsland(o.el, o, segs, 0);
239
314
  this.#stamp(o, path, id);
240
315
  break;
241
316
  }
@@ -244,6 +319,7 @@ var DomBackend = class {
244
319
  const o = this.#matchOrCreate(cursor, key, "strokePath", () => {
245
320
  const svg = island();
246
321
  const path = doc.createElementNS(SVG_NS, "path");
322
+ path.style.pointerEvents = "stroke";
247
323
  svg.appendChild(path);
248
324
  return {
249
325
  op: "strokePath",
@@ -253,10 +329,12 @@ var DomBackend = class {
253
329
  };
254
330
  });
255
331
  const path = o.path;
256
- this.#setAttr(path, o, "d", "d", segsToD(pathSegs(cmd.path)));
332
+ const segs = pathSegs(cmd.path);
333
+ this.#setAttr(path, o, "d", "d", segsToD(segs));
257
334
  this.#setAttr(path, o, "fill", "fill", "none");
258
335
  this.#setAttr(path, o, "stroke", "stroke", this.#resolvePaint(cmd.paint, o, scope, key));
259
336
  this.#applyStroke(path, o, cmd.stroke);
337
+ this.#sizeIsland(o.el, o, segs, cmd.stroke.width);
260
338
  this.#stamp(o, path, id);
261
339
  break;
262
340
  }
@@ -371,6 +449,7 @@ var DomBackend = class {
371
449
  measureText(text, font) {
372
450
  const size = font.size;
373
451
  const span = this.#ensureMeasureSpan();
452
+ if (!span.isConnected) this.#mountMeasureSpan(span);
374
453
  span.style.font = fontString(font);
375
454
  span.style.fontVariationSettings = font.fontVariationSettings ?? "normal";
376
455
  span.style.letterSpacing = font.letterSpacing !== void 0 ? `${font.letterSpacing}px` : "normal";
@@ -523,6 +602,32 @@ var DomBackend = class {
523
602
  this.#setAttr(el, o, "nodeId", "data-node-id", id);
524
603
  }
525
604
  /**
605
+ * Size a geometry island's `<svg>` box tightly to its path bbox (in the
606
+ * cursor's local space) via the `viewBox`, so the painted coordinates are
607
+ * UNCHANGED (1:1 mapping) while the element box shrinks from full-canvas to the
608
+ * shape. Cached writes — only touches the DOM when the bbox moves. `pad`
609
+ * (stroke width) grows the box so it contains a stroke straddling the
610
+ * centerline; `overflow:visible` covers any residual curve/miter overshoot.
611
+ */
612
+ #sizeIsland(svg, o, segs, pad) {
613
+ const bb = pathBBox(segs);
614
+ if (bb === null) {
615
+ this.#setAttr(svg, o, "svgW", "width", "0");
616
+ this.#setAttr(svg, o, "svgH", "height", "0");
617
+ this.#setAttr(svg, o, "svgVB", "viewBox", void 0);
618
+ return;
619
+ }
620
+ const x = bb.x - pad;
621
+ const y = bb.y - pad;
622
+ const w = bb.w + 2 * pad;
623
+ const h = bb.h + 2 * pad;
624
+ this.#setStyle(o, svg, "left", `${x}px`);
625
+ this.#setStyle(o, svg, "top", `${y}px`);
626
+ this.#setAttr(svg, o, "svgW", "width", String(w));
627
+ this.#setAttr(svg, o, "svgH", "height", String(h));
628
+ this.#setAttr(svg, o, "svgVB", "viewBox", `${x} ${y} ${w} ${h}`);
629
+ }
630
+ /**
526
631
  * Caret-preserving text write. RULE B (freeze): never touch the text while the
527
632
  * div (or a descendant) is the focused contentEditable. RULE A (patch-only):
528
633
  * write nothing when unchanged; otherwise mutate the SAME Text node's `.data`
@@ -566,10 +671,22 @@ var DomBackend = class {
566
671
  span.style.whiteSpace = "pre";
567
672
  span.style.left = "-99999px";
568
673
  span.style.top = "0";
569
- (this.#host ?? this.#doc.body ?? this.root).appendChild(span);
570
674
  this.#measureSpan = span;
675
+ this.#mountMeasureSpan(span);
571
676
  return span;
572
677
  }
678
+ /**
679
+ * Attach the measuring span to a CONNECTED layout tree. A detached element
680
+ * reports a 0-width rect in real browsers too — so a measurer mounted under a
681
+ * not-yet-connected host silently falls back to the coarse estimate, mis-breaks
682
+ * long Text, and captions overflow their `width` (aJsLQp0fSs5L). Prefer the
683
+ * document body (reliably live), then a connected host, else the root (headless
684
+ * jsdom has no layout anyway → 0 → estimate, which is expected there).
685
+ */
686
+ #mountMeasureSpan(span) {
687
+ const body = this.#doc.body;
688
+ ((body && body.isConnected !== false ? body : null) ?? (this.#host?.isConnected ? this.#host : null) ?? this.root).appendChild(span);
689
+ }
573
690
  /** Best-effort `src` for a registered image asset (an `HTMLImageElement` or a
574
691
  * URL string); other shapes have no DOM-loadable src in this preview tier. */
575
692
  #imageSrc(assetId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/backend-dom",
3
- "version": "0.22.0-pre.0",
3
+ "version": "0.22.0-pre.2",
4
4
  "description": "glissade DOM render backend: DisplayList -> HTML/SVG elements. A preview / non-parity realtime tier (accessibility, selectable text, CSS-native embedding) — NOT a Skia-export twin.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -18,8 +18,8 @@
18
18
  "dist"
19
19
  ],
20
20
  "dependencies": {
21
- "@glissade/core": "0.22.0-pre.0",
22
- "@glissade/scene": "0.22.0-pre.0"
21
+ "@glissade/core": "0.22.0-pre.2",
22
+ "@glissade/scene": "0.22.0-pre.2"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",