@glissade/backend-dom 0.21.0-pre.0 → 0.21.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/README.md +30 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +369 -112
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -56,6 +56,36 @@ stage.addEventListener('click', (e) => {
|
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
> **One `emitWithIds()` feeds BOTH `setIds` and `render`.** The id stream is
|
|
60
|
+
> positional by command index, so `setIds(...)` and `render(...)` must come from
|
|
61
|
+
> the *same* emit — `setIds(emitWithIds(sceneA, …).ids)` then
|
|
62
|
+
> `render(evaluate(sceneB, …))` silently mis-maps `data-node-id`. Always destructure
|
|
63
|
+
> one call: `const { displayList, ids } = emitWithIds(scene, tl, t)`.
|
|
64
|
+
|
|
65
|
+
## No-build (`<script src>`)
|
|
66
|
+
|
|
67
|
+
The DOM tier ships as a separate **optional** IIFE, `glissade-dom.browser.js`, a
|
|
68
|
+
second `<script>` loaded *after* the base bundle — it augments `window.glissade`
|
|
69
|
+
with `DomBackend` + `emitWithIds` (the base playback bundle stays lean and
|
|
70
|
+
`DomBackend`-free):
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<script src="https://unpkg.com/@glissade/browser/dist/glissade.browser.js"></script>
|
|
74
|
+
<script src="https://unpkg.com/@glissade/browser/dist/glissade-dom.browser.js"></script>
|
|
75
|
+
<script type="module">
|
|
76
|
+
const backend = new glissade.DomBackend(document.getElementById('stage'));
|
|
77
|
+
function frame(t) {
|
|
78
|
+
for (const m of movements) m.run(t);
|
|
79
|
+
const { displayList, ids } = glissade.emitWithIds(scene, EMPTY, t); // ONE emit
|
|
80
|
+
backend.setIds(ids);
|
|
81
|
+
backend.render(displayList);
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Load order is fail-loud: `glissade-dom.browser.js` throws a clear error if the
|
|
87
|
+
base bundle is absent or a different version (never a cryptic `undefined`).
|
|
88
|
+
|
|
59
89
|
## What it maps
|
|
60
90
|
|
|
61
91
|
| IR op | DOM/SVG |
|
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { NodeIdStream } from "@glissade/scene/identity";
|
|
|
6
6
|
/**
|
|
7
7
|
* A DOM/SVG `RenderBackend`. Construct with a host element (renders into it) or a
|
|
8
8
|
* bare `Document` (builds a detached `root` you read off `backend.root`). Each
|
|
9
|
-
* `render()`
|
|
10
|
-
*
|
|
9
|
+
* `render()` REUSES + PATCHES a retained tree keyed on `data-node-id` (Stage S3),
|
|
10
|
+
* so inline-edit state, host overlays, listeners, and CSS transitions survive a
|
|
11
|
+
* re-render. Preview / non-parity — see the module header.
|
|
11
12
|
*/
|
|
12
13
|
declare class DomBackend implements RenderBackend {
|
|
13
14
|
#private;
|
package/dist/index.js
CHANGED
|
@@ -69,11 +69,22 @@ function segsToD(segs) {
|
|
|
69
69
|
}
|
|
70
70
|
return parts.join(" ");
|
|
71
71
|
}
|
|
72
|
+
/** Short, stable FNV-1a hash (hex) over a string — for deterministic def ids
|
|
73
|
+
* that survive reorder (same scope+key → same id across frames). */
|
|
74
|
+
function hashKey(s) {
|
|
75
|
+
let h = 2166136261;
|
|
76
|
+
for (let i = 0; i < s.length; i++) {
|
|
77
|
+
h ^= s.charCodeAt(i);
|
|
78
|
+
h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return h.toString(36);
|
|
81
|
+
}
|
|
72
82
|
/**
|
|
73
83
|
* A DOM/SVG `RenderBackend`. Construct with a host element (renders into it) or a
|
|
74
84
|
* bare `Document` (builds a detached `root` you read off `backend.root`). Each
|
|
75
|
-
* `render()`
|
|
76
|
-
*
|
|
85
|
+
* `render()` REUSES + PATCHES a retained tree keyed on `data-node-id` (Stage S3),
|
|
86
|
+
* so inline-edit state, host overlays, listeners, and CSS transitions survive a
|
|
87
|
+
* re-render. Preview / non-parity — see the module header.
|
|
77
88
|
*/
|
|
78
89
|
var DomBackend = class {
|
|
79
90
|
root;
|
|
@@ -81,13 +92,16 @@ var DomBackend = class {
|
|
|
81
92
|
#host;
|
|
82
93
|
#images = /* @__PURE__ */ new Map();
|
|
83
94
|
#videos = /* @__PURE__ */ new Map();
|
|
95
|
+
#frame = 0;
|
|
96
|
+
#recon = /* @__PURE__ */ new WeakMap();
|
|
97
|
+
#owned = /* @__PURE__ */ new WeakMap();
|
|
84
98
|
#ids = [];
|
|
85
|
-
#defCounter = 0;
|
|
86
99
|
#measureSpan = null;
|
|
87
100
|
#warnedMeasure = false;
|
|
88
101
|
#warnedMesh = false;
|
|
89
102
|
#warnedGradientInterp = false;
|
|
90
103
|
#warnedShader = false;
|
|
104
|
+
#warnedUnbalanced = false;
|
|
91
105
|
constructor(target) {
|
|
92
106
|
const isDoc = target.nodeType === 9;
|
|
93
107
|
this.#doc = isDoc ? target : target.ownerDocument ?? target;
|
|
@@ -111,16 +125,18 @@ var DomBackend = class {
|
|
|
111
125
|
render(list) {
|
|
112
126
|
const doc = this.#doc;
|
|
113
127
|
const ids = this.#ids;
|
|
114
|
-
this.#
|
|
115
|
-
this.root
|
|
116
|
-
|
|
117
|
-
|
|
128
|
+
this.#frame++;
|
|
129
|
+
this.#enterCursor(this.root, "");
|
|
130
|
+
{
|
|
131
|
+
const w = `${list.size.w}px`;
|
|
132
|
+
const h = `${list.size.h}px`;
|
|
133
|
+
if (this.root.style.width !== w) this.root.style.width = w;
|
|
134
|
+
if (this.root.style.height !== h) this.root.style.height = h;
|
|
135
|
+
}
|
|
118
136
|
let cursor = this.root;
|
|
119
137
|
const stack = [];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (id !== void 0) el.setAttribute("data-node-id", id);
|
|
123
|
-
};
|
|
138
|
+
let scope = "";
|
|
139
|
+
const scopeStack = [];
|
|
124
140
|
const pathSegs = (id) => {
|
|
125
141
|
const res = list.resources[id];
|
|
126
142
|
return res && res.kind === "path" ? res.segs : [];
|
|
@@ -137,130 +153,215 @@ var DomBackend = class {
|
|
|
137
153
|
return svg;
|
|
138
154
|
};
|
|
139
155
|
list.commands.forEach((cmd, i) => {
|
|
156
|
+
const id = ids[i];
|
|
140
157
|
switch (cmd.op) {
|
|
141
158
|
case "save":
|
|
142
159
|
stack.push(cursor);
|
|
160
|
+
scopeStack.push(scope);
|
|
143
161
|
break;
|
|
144
162
|
case "restore":
|
|
163
|
+
this.#pruneCursor(cursor);
|
|
145
164
|
cursor = stack.pop() ?? this.root;
|
|
165
|
+
scope = scopeStack.pop() ?? "";
|
|
146
166
|
break;
|
|
147
167
|
case "transform": {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
const key = this.#keyFor(cursor, id, "transform");
|
|
169
|
+
const o = this.#matchOrCreate(cursor, key, "transform", () => {
|
|
170
|
+
const wrap = doc.createElement("div");
|
|
171
|
+
wrap.style.position = "absolute";
|
|
172
|
+
wrap.style.transformOrigin = "0 0";
|
|
173
|
+
return {
|
|
174
|
+
op: "transform",
|
|
175
|
+
el: wrap,
|
|
176
|
+
props: {}
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
this.#setStyle(o, o.el, "transform", cssMatrix(cmd.m));
|
|
180
|
+
this.#stamp(o, o.el, id);
|
|
181
|
+
cursor = o.el;
|
|
182
|
+
scope = scope + "/" + key;
|
|
183
|
+
this.#enterCursor(cursor, scope);
|
|
155
184
|
break;
|
|
156
185
|
}
|
|
157
186
|
case "clip": {
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const key = this.#keyFor(cursor, id, "clip");
|
|
188
|
+
const defId = "gsclip_" + hashKey(scope + " " + key);
|
|
189
|
+
const o = this.#matchOrCreate(cursor, key, "clip", () => {
|
|
190
|
+
const svg = island();
|
|
191
|
+
const defs = doc.createElementNS(SVG_NS, "defs");
|
|
192
|
+
const cp = doc.createElementNS(SVG_NS, "clipPath");
|
|
193
|
+
cp.setAttribute("clipPathUnits", "userSpaceOnUse");
|
|
194
|
+
const p = doc.createElementNS(SVG_NS, "path");
|
|
195
|
+
cp.appendChild(p);
|
|
196
|
+
defs.appendChild(cp);
|
|
197
|
+
svg.appendChild(defs);
|
|
198
|
+
const wrap = doc.createElement("div");
|
|
199
|
+
wrap.style.position = "absolute";
|
|
200
|
+
wrap.style.left = "0";
|
|
201
|
+
wrap.style.top = "0";
|
|
202
|
+
return {
|
|
203
|
+
op: "clip",
|
|
204
|
+
el: wrap,
|
|
205
|
+
aux: svg,
|
|
206
|
+
path: p,
|
|
207
|
+
props: {}
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
o.defId = defId;
|
|
211
|
+
const p = o.path;
|
|
212
|
+
const cp = o.aux.querySelector("clipPath");
|
|
213
|
+
this.#setAttr(p, o, "d", "d", segsToD(pathSegs(cmd.path)));
|
|
214
|
+
this.#setAttr(p, o, "clipRule", "clip-rule", cmd.rule ?? "nonzero");
|
|
215
|
+
this.#setAttr(cp, o, "cpId", "id", defId);
|
|
216
|
+
this.#setStyle(o, o.el, "clipPath", `url(#${defId})`);
|
|
217
|
+
this.#stamp(o, o.el, id);
|
|
218
|
+
cursor = o.el;
|
|
219
|
+
scope = scope + "/" + key;
|
|
220
|
+
this.#enterCursor(cursor, scope);
|
|
179
221
|
break;
|
|
180
222
|
}
|
|
181
223
|
case "fillPath": {
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
224
|
+
const key = this.#keyFor(cursor, id, "fillPath");
|
|
225
|
+
const o = this.#matchOrCreate(cursor, key, "fillPath", () => {
|
|
226
|
+
const svg = island();
|
|
227
|
+
const path = doc.createElementNS(SVG_NS, "path");
|
|
228
|
+
svg.appendChild(path);
|
|
229
|
+
return {
|
|
230
|
+
op: "fillPath",
|
|
231
|
+
el: svg,
|
|
232
|
+
path,
|
|
233
|
+
props: {}
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
const path = o.path;
|
|
237
|
+
this.#setAttr(path, o, "d", "d", segsToD(pathSegs(cmd.path)));
|
|
238
|
+
this.#setAttr(path, o, "fill", "fill", this.#resolvePaint(cmd.paint, o, scope, key));
|
|
239
|
+
this.#stamp(o, path, id);
|
|
189
240
|
break;
|
|
190
241
|
}
|
|
191
242
|
case "strokePath": {
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
243
|
+
const key = this.#keyFor(cursor, id, "strokePath");
|
|
244
|
+
const o = this.#matchOrCreate(cursor, key, "strokePath", () => {
|
|
245
|
+
const svg = island();
|
|
246
|
+
const path = doc.createElementNS(SVG_NS, "path");
|
|
247
|
+
svg.appendChild(path);
|
|
248
|
+
return {
|
|
249
|
+
op: "strokePath",
|
|
250
|
+
el: svg,
|
|
251
|
+
path,
|
|
252
|
+
props: {}
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
const path = o.path;
|
|
256
|
+
this.#setAttr(path, o, "d", "d", segsToD(pathSegs(cmd.path)));
|
|
257
|
+
this.#setAttr(path, o, "fill", "fill", "none");
|
|
258
|
+
this.#setAttr(path, o, "stroke", "stroke", this.#resolvePaint(cmd.paint, o, scope, key));
|
|
259
|
+
this.#applyStroke(path, o, cmd.stroke);
|
|
260
|
+
this.#stamp(o, path, id);
|
|
201
261
|
break;
|
|
202
262
|
}
|
|
203
263
|
case "fillText": {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
264
|
+
const key = this.#keyFor(cursor, id, "fillText");
|
|
265
|
+
const o = this.#matchOrCreate(cursor, key, "fillText", () => {
|
|
266
|
+
const div = doc.createElement("div");
|
|
267
|
+
div.style.position = "absolute";
|
|
268
|
+
div.style.transform = "translateY(-0.8em)";
|
|
269
|
+
div.style.whiteSpace = "pre";
|
|
270
|
+
return {
|
|
271
|
+
op: "fillText",
|
|
272
|
+
el: div,
|
|
273
|
+
props: {}
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
const div = o.el;
|
|
277
|
+
this.#setStyle(o, div, "left", `${cmd.x}px`);
|
|
278
|
+
this.#setStyle(o, div, "top", `${cmd.y}px`);
|
|
279
|
+
this.#setStyle(o, div, "font", fontString(cmd.font));
|
|
280
|
+
this.#setStyle(o, div, "fontVariationSettings", cmd.font.fontVariationSettings !== void 0 ? cmd.font.fontVariationSettings : void 0);
|
|
281
|
+
this.#setStyle(o, div, "color", this.#solid(cmd.paint));
|
|
282
|
+
this.#setAttr(div, o, "dataApprox", "data-approx", cmd.paint.kind !== "color" ? "true" : void 0);
|
|
283
|
+
this.#setStyle(o, div, "textAlign", cmd.align !== void 0 ? cmd.align : void 0);
|
|
284
|
+
this.#setText(div, o, cmd.text);
|
|
285
|
+
this.#stamp(o, div, id);
|
|
218
286
|
break;
|
|
219
287
|
}
|
|
220
288
|
case "drawImage": {
|
|
221
289
|
const res = list.resources[cmd.image];
|
|
222
290
|
const assetId = res && (res.kind === "image" || res.kind === "videoFrame") ? res.assetId : void 0;
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
291
|
+
const key = this.#keyFor(cursor, id, "drawImage");
|
|
292
|
+
const o = this.#matchOrCreate(cursor, key, "drawImage", () => {
|
|
293
|
+
const img = doc.createElement("img");
|
|
294
|
+
img.style.position = "absolute";
|
|
295
|
+
img.style.objectFit = "fill";
|
|
296
|
+
return {
|
|
297
|
+
op: "drawImage",
|
|
298
|
+
el: img,
|
|
299
|
+
props: {}
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
const img = o.el;
|
|
303
|
+
this.#setStyle(o, img, "left", `${cmd.dst.x}px`);
|
|
304
|
+
this.#setStyle(o, img, "top", `${cmd.dst.y}px`);
|
|
305
|
+
this.#setStyle(o, img, "width", `${cmd.dst.w}px`);
|
|
306
|
+
this.#setStyle(o, img, "height", `${cmd.dst.h}px`);
|
|
307
|
+
this.#setStyle(o, img, "imageRendering", cmd.smoothing === false ? "pixelated" : void 0);
|
|
308
|
+
this.#setAttr(img, o, "dataAssetId", "data-asset-id", assetId);
|
|
309
|
+
const src = assetId !== void 0 ? this.#imageSrc(assetId) : void 0;
|
|
310
|
+
if (src !== void 0 && o.props["src"] !== src) {
|
|
311
|
+
img.src = src;
|
|
312
|
+
o.props["src"] = src;
|
|
235
313
|
}
|
|
236
|
-
stamp(img,
|
|
237
|
-
cursor.appendChild(img);
|
|
314
|
+
this.#stamp(o, img, id);
|
|
238
315
|
break;
|
|
239
316
|
}
|
|
240
317
|
case "pushGroup": {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
318
|
+
const key = this.#keyFor(cursor, id, "pushGroup");
|
|
319
|
+
const o = this.#matchOrCreate(cursor, key, "pushGroup", () => {
|
|
320
|
+
const wrap = doc.createElement("div");
|
|
321
|
+
wrap.style.position = "absolute";
|
|
322
|
+
wrap.style.left = "0";
|
|
323
|
+
wrap.style.top = "0";
|
|
324
|
+
return {
|
|
325
|
+
op: "pushGroup",
|
|
326
|
+
el: wrap,
|
|
327
|
+
props: {}
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
const wrap = o.el;
|
|
331
|
+
this.#setStyle(o, wrap, "opacity", cmd.opacity !== 1 ? String(cmd.opacity) : void 0);
|
|
246
332
|
const blend = blendToCss(cmd.blend);
|
|
247
|
-
|
|
248
|
-
|
|
333
|
+
this.#setStyle(o, wrap, "mixBlendMode", blend !== "normal" ? blend : void 0);
|
|
334
|
+
this.#setStyle(o, wrap, "filter", cmd.filters.length > 0 ? filtersToCanvasFilter(cmd.filters) : void 0);
|
|
249
335
|
if (cmd.shader !== void 0 && !this.#warnedShader) {
|
|
250
336
|
emitDevWarning("@glissade/backend-dom: a ShaderEffect (pushGroup.shader) has no DOM analogue — ignored (caps.shaders=false).");
|
|
251
337
|
this.#warnedShader = true;
|
|
252
338
|
}
|
|
253
|
-
stamp(wrap,
|
|
254
|
-
cursor.appendChild(wrap);
|
|
339
|
+
this.#stamp(o, wrap, id);
|
|
255
340
|
stack.push(cursor);
|
|
341
|
+
scopeStack.push(scope);
|
|
256
342
|
cursor = wrap;
|
|
343
|
+
scope = scope + "/" + key;
|
|
344
|
+
this.#enterCursor(cursor, scope);
|
|
257
345
|
break;
|
|
258
346
|
}
|
|
259
347
|
case "popGroup":
|
|
348
|
+
this.#pruneCursor(cursor);
|
|
260
349
|
cursor = stack.pop() ?? this.root;
|
|
350
|
+
scope = scopeStack.pop() ?? "";
|
|
261
351
|
break;
|
|
262
352
|
}
|
|
263
353
|
});
|
|
354
|
+
if (cursor !== this.root) {
|
|
355
|
+
if (!this.#warnedUnbalanced) {
|
|
356
|
+
emitDevWarning("@glissade/backend-dom: render() ended with an unbalanced cursor (a transform/clip with no enclosing save/restore) — the DisplayList is malformed; draining open cursors.");
|
|
357
|
+
this.#warnedUnbalanced = true;
|
|
358
|
+
}
|
|
359
|
+
while (cursor !== this.root) {
|
|
360
|
+
this.#pruneCursor(cursor);
|
|
361
|
+
cursor = stack.pop() ?? this.root;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.#pruneCursor(this.root);
|
|
264
365
|
}
|
|
265
366
|
measureText(text, font) {
|
|
266
367
|
const size = font.size;
|
|
@@ -303,6 +404,154 @@ var DomBackend = class {
|
|
|
303
404
|
this.#images.clear();
|
|
304
405
|
this.#videos.clear();
|
|
305
406
|
}
|
|
407
|
+
/** Get-or-create this cursor's recon record; reset per-render scratch once per
|
|
408
|
+
* frame (the first time a reused cursor is entered this render). */
|
|
409
|
+
#enterCursor(cursor, _scope) {
|
|
410
|
+
let rec = this.#recon.get(cursor);
|
|
411
|
+
if (!rec) {
|
|
412
|
+
rec = {
|
|
413
|
+
children: /* @__PURE__ */ new Map(),
|
|
414
|
+
owns: /* @__PURE__ */ new Set(),
|
|
415
|
+
occ: /* @__PURE__ */ new Map(),
|
|
416
|
+
seen: /* @__PURE__ */ new Set(),
|
|
417
|
+
frame: -1,
|
|
418
|
+
anchor: null
|
|
419
|
+
};
|
|
420
|
+
this.#recon.set(cursor, rec);
|
|
421
|
+
}
|
|
422
|
+
if (rec.frame !== this.#frame) {
|
|
423
|
+
rec.frame = this.#frame;
|
|
424
|
+
rec.occ.clear();
|
|
425
|
+
rec.seen.clear();
|
|
426
|
+
rec.anchor = cursor.firstChild;
|
|
427
|
+
while (rec.anchor && !rec.owns.has(rec.anchor)) rec.anchor = rec.anchor.nextSibling;
|
|
428
|
+
}
|
|
429
|
+
return rec;
|
|
430
|
+
}
|
|
431
|
+
/** Sibling-scoped key under one cursor: `(id|∅) op occ`. occ disambiguates a
|
|
432
|
+
* node that emits the same op twice and id-less nodes positionally. */
|
|
433
|
+
#keyFor(cursor, id, op) {
|
|
434
|
+
const rec = this.#recon.get(cursor);
|
|
435
|
+
const base = (id ?? "∅") + " " + op;
|
|
436
|
+
const n = rec.occ.get(base) ?? 0;
|
|
437
|
+
rec.occ.set(base, n + 1);
|
|
438
|
+
return base + " " + n;
|
|
439
|
+
}
|
|
440
|
+
/** Reuse the owned element for `key` under `cursor`, or create it via the
|
|
441
|
+
* factory. Place it (move-on-reorder, foreign-safe). The SOLE creation site. */
|
|
442
|
+
#matchOrCreate(cursor, key, op, create) {
|
|
443
|
+
const rec = this.#recon.get(cursor);
|
|
444
|
+
rec.seen.add(key);
|
|
445
|
+
let el = rec.children.get(key);
|
|
446
|
+
let o;
|
|
447
|
+
if (!el) {
|
|
448
|
+
o = create();
|
|
449
|
+
el = o.el;
|
|
450
|
+
rec.children.set(key, el);
|
|
451
|
+
rec.owns.add(el);
|
|
452
|
+
if (o.aux) rec.owns.add(o.aux);
|
|
453
|
+
this.#owned.set(el, o);
|
|
454
|
+
} else {
|
|
455
|
+
o = this.#owned.get(el);
|
|
456
|
+
if (o.op !== op) {
|
|
457
|
+
if (o.aux) rec.owns.delete(o.aux);
|
|
458
|
+
rec.owns.delete(el);
|
|
459
|
+
o.aux?.remove();
|
|
460
|
+
el.remove();
|
|
461
|
+
rec.children.delete(key);
|
|
462
|
+
this.#owned.delete(el);
|
|
463
|
+
o = create();
|
|
464
|
+
el = o.el;
|
|
465
|
+
rec.children.set(key, el);
|
|
466
|
+
rec.owns.add(el);
|
|
467
|
+
if (o.aux) rec.owns.add(o.aux);
|
|
468
|
+
this.#owned.set(el, o);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (o.aux) this.#place(cursor, rec, o.aux);
|
|
472
|
+
this.#place(cursor, rec, el);
|
|
473
|
+
return o;
|
|
474
|
+
}
|
|
475
|
+
/** Place `node` at the running anchor, moving it only if out of place; then
|
|
476
|
+
* advance the anchor past it and over any interleaved FOREIGN siblings. */
|
|
477
|
+
#place(cursor, rec, node) {
|
|
478
|
+
if (node !== rec.anchor) {
|
|
479
|
+
const ae = this.#doc.activeElement;
|
|
480
|
+
if (ae === null || node !== ae && !node.contains(ae)) cursor.insertBefore(node, rec.anchor);
|
|
481
|
+
}
|
|
482
|
+
rec.anchor = node.nextSibling;
|
|
483
|
+
while (rec.anchor && !rec.owns.has(rec.anchor)) rec.anchor = rec.anchor.nextSibling;
|
|
484
|
+
}
|
|
485
|
+
/** Remove owned children of `cursor` not seen this frame. Iterates the
|
|
486
|
+
* children Map ONLY — foreign nodes are not keys, so they are unreachable. */
|
|
487
|
+
#pruneCursor(cursor) {
|
|
488
|
+
const rec = this.#recon.get(cursor);
|
|
489
|
+
if (!rec) return;
|
|
490
|
+
for (const [k, el] of rec.children) if (!rec.seen.has(k)) {
|
|
491
|
+
const o = this.#owned.get(el);
|
|
492
|
+
if (o?.aux) rec.owns.delete(o.aux);
|
|
493
|
+
rec.owns.delete(el);
|
|
494
|
+
o?.aux?.remove();
|
|
495
|
+
el.remove();
|
|
496
|
+
rec.children.delete(k);
|
|
497
|
+
this.#owned.delete(el);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** Write a style prop only when it changed; clear (to default) on undefined. */
|
|
501
|
+
#setStyle(o, target, prop, value) {
|
|
502
|
+
if (o.props[prop] === value) return;
|
|
503
|
+
const style = target.style;
|
|
504
|
+
style[prop] = value ?? "";
|
|
505
|
+
o.props[prop] = value;
|
|
506
|
+
}
|
|
507
|
+
/** Write an attribute only when its cached slot changed; remove on undefined. */
|
|
508
|
+
#setAttr(target, o, slot, name, value) {
|
|
509
|
+
if (o.props[slot] === value) return;
|
|
510
|
+
if (value === void 0) target.removeAttribute(name);
|
|
511
|
+
else target.setAttribute(name, value);
|
|
512
|
+
o.props[slot] = value;
|
|
513
|
+
}
|
|
514
|
+
/** Stamp `data-node-id` (guarded) on the element S2 stamped per op. */
|
|
515
|
+
#stamp(o, el, id) {
|
|
516
|
+
if (id === void 0) return;
|
|
517
|
+
this.#setAttr(el, o, "nodeId", "data-node-id", id);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Caret-preserving text write. RULE B (freeze): never touch the text while the
|
|
521
|
+
* div (or a descendant) is the focused contentEditable. RULE A (patch-only):
|
|
522
|
+
* write nothing when unchanged; otherwise mutate the SAME Text node's `.data`
|
|
523
|
+
* (least-destructive — never `textContent=` on a retained subtree, which would
|
|
524
|
+
* collapse the caret / drop a selection).
|
|
525
|
+
*/
|
|
526
|
+
#setText(div, o, text) {
|
|
527
|
+
if (this.#isEditing(div)) return;
|
|
528
|
+
if (o.props["text"] === text) return;
|
|
529
|
+
let tn = null;
|
|
530
|
+
for (let n = div.firstChild; n; n = n.nextSibling) if (n.nodeType === 3) {
|
|
531
|
+
tn = n;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
if (tn) tn.data = text;
|
|
535
|
+
else div.appendChild(this.#doc.createTextNode(text));
|
|
536
|
+
o.props["text"] = text;
|
|
537
|
+
}
|
|
538
|
+
/** The div (or a descendant) is the focused contentEditable host. Uses the
|
|
539
|
+
* computed `isContentEditable` (real browsers) with an attribute fallback
|
|
540
|
+
* (jsdom and other environments that don't compute it). */
|
|
541
|
+
#isEditing(div) {
|
|
542
|
+
if (!(div.isContentEditable || div.getAttribute("contenteditable") === "true")) return false;
|
|
543
|
+
const active = this.#doc.activeElement;
|
|
544
|
+
return active === div || active !== null && active !== this.#doc.body && div.contains(active);
|
|
545
|
+
}
|
|
546
|
+
/** Map a `StrokeStyle` onto an SVG `<path>`'s stroke-* attributes (guarded). */
|
|
547
|
+
#applyStroke(path, o, stroke) {
|
|
548
|
+
this.#setAttr(path, o, "strokeWidth", "stroke-width", String(stroke.width));
|
|
549
|
+
this.#setAttr(path, o, "strokeCap", "stroke-linecap", stroke.cap);
|
|
550
|
+
this.#setAttr(path, o, "strokeJoin", "stroke-linejoin", stroke.join);
|
|
551
|
+
this.#setAttr(path, o, "strokeMiter", "stroke-miterlimit", stroke.miterLimit !== void 0 ? String(stroke.miterLimit) : void 0);
|
|
552
|
+
this.#setAttr(path, o, "strokeDash", "stroke-dasharray", stroke.dash && stroke.dash.length > 0 ? stroke.dash.join(" ") : void 0);
|
|
553
|
+
this.#setAttr(path, o, "strokeDashOff", "stroke-dashoffset", stroke.dashOffset !== void 0 ? String(stroke.dashOffset) : void 0);
|
|
554
|
+
}
|
|
306
555
|
#ensureMeasureSpan() {
|
|
307
556
|
if (this.#measureSpan) return this.#measureSpan;
|
|
308
557
|
const span = this.#doc.createElement("span");
|
|
@@ -327,14 +576,21 @@ var DomBackend = class {
|
|
|
327
576
|
if (paint.kind === "mesh") return paint.bg ?? paint.points[0]?.color ?? "#000";
|
|
328
577
|
return paint.stops[0]?.color ?? "#000";
|
|
329
578
|
}
|
|
330
|
-
/**
|
|
331
|
-
*
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
579
|
+
/** A signature of the gradient paint — a `<defs>` subtree is rebuilt only when
|
|
580
|
+
* this changes (kind / coords / stops), avoiding per-frame churn. */
|
|
581
|
+
#gradKey(paint) {
|
|
582
|
+
return `${paint.kind === "linear" ? `L|${paint.from ?? ""}|${paint.to ?? ""}` : `R|${paint.center ?? ""}|${paint.radius ?? ""}`}|${paint.stops.map((s) => `${s.offset}:${s.color}`).join(",")}`;
|
|
583
|
+
}
|
|
584
|
+
/** Resolve a `Paint` to an SVG fill/stroke value, building/refreshing the
|
|
585
|
+
* gradient `<defs>` on the owned svg (`o.el`) keyed by a deterministic def id.
|
|
586
|
+
* `mesh` degrades to a solid; a degraded paint stamps `data-approx="true"`. */
|
|
587
|
+
#resolvePaint(paint, o, scope, key) {
|
|
588
|
+
if (paint.kind === "color") {
|
|
589
|
+
this.#setAttr(o.path, o, "dataApprox", "data-approx", void 0);
|
|
590
|
+
return paint.color;
|
|
591
|
+
}
|
|
336
592
|
if (paint.kind === "mesh") {
|
|
337
|
-
|
|
593
|
+
this.#setAttr(o.path, o, "dataApprox", "data-approx", "true");
|
|
338
594
|
if (!this.#warnedMesh) {
|
|
339
595
|
emitDevWarning("@glissade/backend-dom: mesh-gradient paint has no SVG analogue — degraded to a solid fill.");
|
|
340
596
|
this.#warnedMesh = true;
|
|
@@ -342,21 +598,32 @@ var DomBackend = class {
|
|
|
342
598
|
return this.#solid(paint);
|
|
343
599
|
}
|
|
344
600
|
if (paint.interpolation !== void 0 && paint.interpolation !== "linear") {
|
|
345
|
-
|
|
601
|
+
this.#setAttr(o.path, o, "dataApprox", "data-approx", "true");
|
|
346
602
|
if (!this.#warnedGradientInterp) {
|
|
347
603
|
emitDevWarning(`@glissade/backend-dom: gradient interpolation '${paint.interpolation}' has no SVG analogue — degraded to linear stops.`);
|
|
348
604
|
this.#warnedGradientInterp = true;
|
|
349
605
|
}
|
|
606
|
+
} else this.#setAttr(o.path, o, "dataApprox", "data-approx", void 0);
|
|
607
|
+
const svg = o.el;
|
|
608
|
+
const defId = o.defId ?? (o.defId = "gsgrad_" + hashKey(scope + " " + key));
|
|
609
|
+
const sig = this.#gradKey(paint);
|
|
610
|
+
if (o.gradKey !== sig) {
|
|
611
|
+
o.gradKey = sig;
|
|
612
|
+
this.#buildGradient(svg, defId, paint);
|
|
350
613
|
}
|
|
614
|
+
return `url(#${defId})`;
|
|
615
|
+
}
|
|
616
|
+
/** (Re)build the gradient `<defs>` subtree for `defId` on `svg`. */
|
|
617
|
+
#buildGradient(svg, defId, paint) {
|
|
351
618
|
const doc = this.#doc;
|
|
352
|
-
const id = `gsgrad${this.#defCounter++}`;
|
|
353
619
|
let defs = svg.querySelector("defs");
|
|
354
620
|
if (!defs) {
|
|
355
621
|
defs = doc.createElementNS(SVG_NS, "defs");
|
|
356
622
|
svg.insertBefore(defs, svg.firstChild);
|
|
357
623
|
}
|
|
624
|
+
for (const g of Array.from(defs.children)) if (g.getAttribute("id") === defId) g.remove();
|
|
358
625
|
const grad = doc.createElementNS(SVG_NS, paint.kind === "radial" ? "radialGradient" : "linearGradient");
|
|
359
|
-
grad.setAttribute("id",
|
|
626
|
+
grad.setAttribute("id", defId);
|
|
360
627
|
if (paint.kind === "linear") {
|
|
361
628
|
if (paint.from && paint.to) {
|
|
362
629
|
grad.setAttribute("gradientUnits", "userSpaceOnUse");
|
|
@@ -378,17 +645,7 @@ var DomBackend = class {
|
|
|
378
645
|
grad.appendChild(s);
|
|
379
646
|
}
|
|
380
647
|
defs.appendChild(grad);
|
|
381
|
-
return `url(#${id})`;
|
|
382
648
|
}
|
|
383
649
|
};
|
|
384
|
-
/** Map a `StrokeStyle` onto an SVG `<path>`'s stroke-* attributes 1:1. */
|
|
385
|
-
function applyStroke(path, stroke) {
|
|
386
|
-
path.setAttribute("stroke-width", String(stroke.width));
|
|
387
|
-
if (stroke.cap) path.setAttribute("stroke-linecap", stroke.cap);
|
|
388
|
-
if (stroke.join) path.setAttribute("stroke-linejoin", stroke.join);
|
|
389
|
-
if (stroke.miterLimit !== void 0) path.setAttribute("stroke-miterlimit", String(stroke.miterLimit));
|
|
390
|
-
if (stroke.dash && stroke.dash.length > 0) path.setAttribute("stroke-dasharray", stroke.dash.join(" "));
|
|
391
|
-
if (stroke.dashOffset !== void 0) path.setAttribute("stroke-dashoffset", String(stroke.dashOffset));
|
|
392
|
-
}
|
|
393
650
|
//#endregion
|
|
394
651
|
export { DomBackend };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/backend-dom",
|
|
3
|
-
"version": "0.21.0-pre.
|
|
3
|
+
"version": "0.21.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/
|
|
22
|
-
"@glissade/
|
|
21
|
+
"@glissade/core": "0.21.0-pre.2",
|
|
22
|
+
"@glissade/scene": "0.21.0-pre.2"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|