@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.
- package/dist/index.js +132 -15
- 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
|
-
*
|
|
25
|
-
*
|
|
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 = [
|
|
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
|
-
/**
|
|
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",
|
|
148
|
-
svg.setAttribute("height",
|
|
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
|
-
|
|
164
|
-
cursor
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
"@glissade/scene": "0.22.0-pre.
|
|
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",
|