@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 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()` rebuilds the tree (forward render; the cross-frame retained-DOM
10
- * reconciler is Stage S3). Preview / non-parity see the module header.
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()` rebuilds the tree (forward render; the cross-frame retained-DOM
76
- * reconciler is Stage S3). Preview / non-parity see the module header.
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.#defCounter = 0;
115
- this.root.replaceChildren();
116
- this.root.style.width = `${list.size.w}px`;
117
- this.root.style.height = `${list.size.h}px`;
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
- const stamp = (el, i) => {
121
- const id = ids[i];
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 wrap = doc.createElement("div");
149
- wrap.style.position = "absolute";
150
- wrap.style.transformOrigin = "0 0";
151
- wrap.style.transform = cssMatrix(cmd.m);
152
- stamp(wrap, i);
153
- cursor.appendChild(wrap);
154
- cursor = wrap;
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 id = `gsclip${this.#defCounter++}`;
159
- const svg = island();
160
- const defs = doc.createElementNS(SVG_NS, "defs");
161
- const cp = doc.createElementNS(SVG_NS, "clipPath");
162
- cp.setAttribute("id", id);
163
- cp.setAttribute("clipPathUnits", "userSpaceOnUse");
164
- const p = doc.createElementNS(SVG_NS, "path");
165
- p.setAttribute("d", segsToD(pathSegs(cmd.path)));
166
- p.setAttribute("clip-rule", cmd.rule ?? "nonzero");
167
- cp.appendChild(p);
168
- defs.appendChild(cp);
169
- svg.appendChild(defs);
170
- cursor.appendChild(svg);
171
- const wrap = doc.createElement("div");
172
- wrap.style.position = "absolute";
173
- wrap.style.left = "0";
174
- wrap.style.top = "0";
175
- wrap.style.clipPath = `url(#${id})`;
176
- stamp(wrap, i);
177
- cursor.appendChild(wrap);
178
- cursor = wrap;
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 svg = island();
183
- const path = doc.createElementNS(SVG_NS, "path");
184
- path.setAttribute("d", segsToD(pathSegs(cmd.path)));
185
- path.setAttribute("fill", this.#resolvePaint(cmd.paint, svg, path));
186
- stamp(path, i);
187
- svg.appendChild(path);
188
- cursor.appendChild(svg);
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 svg = island();
193
- const path = doc.createElementNS(SVG_NS, "path");
194
- path.setAttribute("d", segsToD(pathSegs(cmd.path)));
195
- path.setAttribute("fill", "none");
196
- path.setAttribute("stroke", this.#resolvePaint(cmd.paint, svg, path));
197
- applyStroke(path, cmd.stroke);
198
- stamp(path, i);
199
- svg.appendChild(path);
200
- cursor.appendChild(svg);
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 div = doc.createElement("div");
205
- div.style.position = "absolute";
206
- div.style.left = `${cmd.x}px`;
207
- div.style.top = `${cmd.y}px`;
208
- div.style.transform = "translateY(-0.8em)";
209
- div.style.whiteSpace = "pre";
210
- div.style.font = fontString(cmd.font);
211
- if (cmd.font.fontVariationSettings !== void 0) div.style.fontVariationSettings = cmd.font.fontVariationSettings;
212
- div.style.color = this.#solid(cmd.paint);
213
- if (cmd.paint.kind !== "color") div.setAttribute("data-approx", "true");
214
- if (cmd.align) div.style.textAlign = cmd.align;
215
- div.textContent = cmd.text;
216
- stamp(div, i);
217
- cursor.appendChild(div);
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 img = doc.createElement("img");
224
- img.style.position = "absolute";
225
- img.style.left = `${cmd.dst.x}px`;
226
- img.style.top = `${cmd.dst.y}px`;
227
- img.style.width = `${cmd.dst.w}px`;
228
- img.style.height = `${cmd.dst.h}px`;
229
- img.style.objectFit = "fill";
230
- if (cmd.smoothing === false) img.style.imageRendering = "pixelated";
231
- if (assetId !== void 0) {
232
- img.setAttribute("data-asset-id", assetId);
233
- const src = this.#imageSrc(assetId);
234
- if (src !== void 0) img.src = src;
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, i);
237
- cursor.appendChild(img);
314
+ this.#stamp(o, img, id);
238
315
  break;
239
316
  }
240
317
  case "pushGroup": {
241
- const wrap = doc.createElement("div");
242
- wrap.style.position = "absolute";
243
- wrap.style.left = "0";
244
- wrap.style.top = "0";
245
- if (cmd.opacity !== 1) wrap.style.opacity = String(cmd.opacity);
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
- if (blend !== "normal") wrap.style.mixBlendMode = blend;
248
- if (cmd.filters.length > 0) wrap.style.filter = filtersToCanvasFilter(cmd.filters);
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, i);
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
- /** Resolve a `Paint` to an SVG fill/stroke value, appending any gradient def to
331
- * `svg`'s `<defs>`. `mesh` degrades to a solid (CSS/SVG has no mesh gradient);
332
- * a degraded paint stamps `data-approx="true"` on `el` so an editor can badge
333
- * the approximation (design-agent consumer ask). */
334
- #resolvePaint(paint, svg, el) {
335
- if (paint.kind === "color") return paint.color;
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
- el.setAttribute("data-approx", "true");
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
- el.setAttribute("data-approx", "true");
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", 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.0",
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/scene": "0.21.0-pre.0",
22
- "@glissade/core": "0.21.0-pre.0"
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",