@hyperframes/sdk 0.6.113

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 (78) hide show
  1. package/LICENSE +190 -0
  2. package/dist/adapters/fs.d.ts +9 -0
  3. package/dist/adapters/fs.d.ts.map +1 -0
  4. package/dist/adapters/fs.js +122 -0
  5. package/dist/adapters/fs.js.map +1 -0
  6. package/dist/adapters/headless.d.ts +3 -0
  7. package/dist/adapters/headless.d.ts.map +1 -0
  8. package/dist/adapters/headless.js +17 -0
  9. package/dist/adapters/headless.js.map +1 -0
  10. package/dist/adapters/iframe.d.ts +102 -0
  11. package/dist/adapters/iframe.d.ts.map +1 -0
  12. package/dist/adapters/iframe.js +569 -0
  13. package/dist/adapters/iframe.js.map +1 -0
  14. package/dist/adapters/memory.d.ts +5 -0
  15. package/dist/adapters/memory.d.ts.map +1 -0
  16. package/dist/adapters/memory.js +54 -0
  17. package/dist/adapters/memory.js.map +1 -0
  18. package/dist/adapters/types.d.ts +65 -0
  19. package/dist/adapters/types.d.ts.map +1 -0
  20. package/dist/adapters/types.js +2 -0
  21. package/dist/adapters/types.js.map +1 -0
  22. package/dist/document.d.ts +25 -0
  23. package/dist/document.d.ts.map +1 -0
  24. package/dist/document.js +238 -0
  25. package/dist/document.js.map +1 -0
  26. package/dist/engine/apply-patches.d.ts +20 -0
  27. package/dist/engine/apply-patches.d.ts.map +1 -0
  28. package/dist/engine/apply-patches.js +284 -0
  29. package/dist/engine/apply-patches.js.map +1 -0
  30. package/dist/engine/cssWriter.d.ts +18 -0
  31. package/dist/engine/cssWriter.d.ts.map +1 -0
  32. package/dist/engine/cssWriter.js +139 -0
  33. package/dist/engine/cssWriter.js.map +1 -0
  34. package/dist/engine/keyframeBackfill.d.ts +17 -0
  35. package/dist/engine/keyframeBackfill.d.ts.map +1 -0
  36. package/dist/engine/keyframeBackfill.js +43 -0
  37. package/dist/engine/keyframeBackfill.js.map +1 -0
  38. package/dist/engine/model.d.ts +55 -0
  39. package/dist/engine/model.d.ts.map +1 -0
  40. package/dist/engine/model.js +256 -0
  41. package/dist/engine/model.js.map +1 -0
  42. package/dist/engine/mutate.d.ts +26 -0
  43. package/dist/engine/mutate.d.ts.map +1 -0
  44. package/dist/engine/mutate.js +1243 -0
  45. package/dist/engine/mutate.js.map +1 -0
  46. package/dist/engine/patches.d.ts +71 -0
  47. package/dist/engine/patches.d.ts.map +1 -0
  48. package/dist/engine/patches.js +197 -0
  49. package/dist/engine/patches.js.map +1 -0
  50. package/dist/engine/serialize.d.ts +15 -0
  51. package/dist/engine/serialize.d.ts.map +1 -0
  52. package/dist/engine/serialize.js +20 -0
  53. package/dist/engine/serialize.js.map +1 -0
  54. package/dist/engine/variableModel.d.ts +29 -0
  55. package/dist/engine/variableModel.d.ts.map +1 -0
  56. package/dist/engine/variableModel.js +81 -0
  57. package/dist/engine/variableModel.js.map +1 -0
  58. package/dist/history.d.ts +39 -0
  59. package/dist/history.d.ts.map +1 -0
  60. package/dist/history.js +116 -0
  61. package/dist/history.js.map +1 -0
  62. package/dist/index.d.ts +15 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +11 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/persist-queue.d.ts +24 -0
  67. package/dist/persist-queue.d.ts.map +1 -0
  68. package/dist/persist-queue.js +62 -0
  69. package/dist/persist-queue.js.map +1 -0
  70. package/dist/session.d.ts +38 -0
  71. package/dist/session.d.ts.map +1 -0
  72. package/dist/session.js +514 -0
  73. package/dist/session.js.map +1 -0
  74. package/dist/types.d.ts +521 -0
  75. package/dist/types.d.ts.map +1 -0
  76. package/dist/types.js +16 -0
  77. package/dist/types.js.map +1 -0
  78. package/package.json +55 -0
@@ -0,0 +1,1243 @@
1
+ /**
2
+ * Op handlers for Phase 3a (non-parser ops).
3
+ *
4
+ * Each handler: mutates the linkedom Document, returns {forward, inverse} RFC 6902 patches.
5
+ * Pure with respect to events — callers emit events from the patches.
6
+ *
7
+ * Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers.
8
+ */
9
+ import { resolveScoped, escapeHfId, findRoot, getElementStyles, setElementStyles, toCamel, getOwnText, setOwnText, getSiblingIndex, getGsapScript, setGsapScript, getStyleSheet, setStyleSheet, } from "./model.js";
10
+ import { stylePath, textPath, attrPath, timingPath, holdPath, elementPath, variablePath, metaPath, gsapScriptPath, styleSheetPath, scalarChange, scalarDelete, valueChange, patchAdd, patchRemove, } from "./patches.js";
11
+ import { upsertCssRule } from "./cssWriter.js";
12
+ import { mintHfId, EXCLUDED_TAGS } from "@hyperframes/core/hf-ids";
13
+ import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
14
+ import { addAnimationToScript, addAnimationWithKeyframesToScript, updateAnimationInScript, removeAnimationFromScript, removePropertyFromAnimation, addKeyframeToScript, removeKeyframeFromScript, removeAllKeyframesFromScript, convertToKeyframesFromScript, materializeKeyframesFromScript, splitIntoPropertyGroupsFromScript, splitAnimationsInScript, updateKeyframeInScript, addLabelToScript, removeLabelFromScript, setArcPathInScript, updateArcSegmentInScript, removeArcPathFromScript, unrollDynamicAnimations, } from "@hyperframes/core/gsap-writer-acorn";
15
+ import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
16
+ import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
17
+ const EMPTY = { forward: [], inverse: [] };
18
+ // ─── setAttribute safety ────────────────────────────────────────────────────
19
+ // Composition-reserved attributes — changing these breaks element identity or
20
+ // the core/studio data model. Reject before mutating.
21
+ const RESERVED_ATTRS = new Set([
22
+ "data-hf-id",
23
+ "data-composition-id",
24
+ "data-width",
25
+ "data-height",
26
+ "data-start",
27
+ "data-end",
28
+ "data-track-index",
29
+ "data-hold-start",
30
+ "data-hold-end",
31
+ "data-hold-fill",
32
+ ]);
33
+ const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
34
+ const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
35
+ const URI_BEARING_ATTRS = new Set([
36
+ "src",
37
+ "href",
38
+ "action",
39
+ "formaction",
40
+ "poster",
41
+ "srcset",
42
+ "xlink:href",
43
+ ]);
44
+ function validateSetAttribute(name, value) {
45
+ const lower = name.toLowerCase();
46
+ if (RESERVED_ATTRS.has(lower)) {
47
+ throw new Error(`setAttribute: "${name}" is a reserved composition attribute and cannot be reassigned. ` +
48
+ `Use the appropriate typed method (setTiming, setHold, etc.) instead.`);
49
+ }
50
+ if (lower.startsWith("on")) {
51
+ throw new Error(`setAttribute: event-handler attributes ("${name}") are not permitted — ` +
52
+ `they produce executable HTML that cannot be safely serialized.`);
53
+ }
54
+ if (value !== null && URI_BEARING_ATTRS.has(lower)) {
55
+ const trimmed = value.trim();
56
+ if (DANGEROUS_URI_SCHEMES.test(trimmed) || DANGEROUS_DATA_URI.test(trimmed)) {
57
+ throw new Error(`setAttribute: unsafe URI value for "${name}".`);
58
+ }
59
+ }
60
+ }
61
+ export class UnsupportedOpError extends Error {
62
+ // Stable error code — part of the public API contract (F7); hosts switch on
63
+ // err.code rather than the message.
64
+ // fallow-ignore-next-line unused-class-member
65
+ code = "E_UNSUPPORTED_OP";
66
+ constructor(opType) {
67
+ super(`Op '${opType}' requires the Phase 3b parser-backed engine and is not available yet. ` +
68
+ `Use can(op) to feature-detect before dispatching.`);
69
+ this.name = "UnsupportedOpError";
70
+ }
71
+ }
72
+ // ─── Target normalization ────────────────────────────────────────────────────
73
+ function targets(target) {
74
+ return Array.isArray(target) ? target : [target];
75
+ }
76
+ // ─── Op dispatch ────────────────────────────────────────────────────────────
77
+ function dispatchRemoveGsapKeyframe(parsed, op) {
78
+ return handleRemoveGsapKeyframeByPercentage(parsed, op.animationId, op.percentage);
79
+ }
80
+ function applyGsapKeyframeOp(parsed, op) {
81
+ switch (op.type) {
82
+ case "setGsapKeyframe":
83
+ return handleSetGsapKeyframe(parsed, op.animationId, op.keyframeIndex, op.position, op.value, op.ease);
84
+ case "addGsapKeyframe":
85
+ return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
86
+ case "removeGsapKeyframe":
87
+ return dispatchRemoveGsapKeyframe(parsed, op);
88
+ case "removeAllKeyframes":
89
+ return handleRemoveAllKeyframes(parsed, op.animationId);
90
+ case "convertToKeyframes":
91
+ return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
92
+ case "materializeKeyframes":
93
+ return handleMaterializeKeyframes(parsed, op.animationId, op.keyframes, op.easeEach, op.resolvedSelector);
94
+ case "splitIntoPropertyGroups":
95
+ return handleSplitIntoPropertyGroups(parsed, op.animationId);
96
+ case "splitAnimations":
97
+ return handleSplitAnimations(parsed, op);
98
+ default:
99
+ return undefined;
100
+ }
101
+ }
102
+ function applyArcPathOp(parsed, op) {
103
+ const s = getGsapScript(parsed.document) ?? "";
104
+ switch (op.type) {
105
+ case "setArcPath": {
106
+ const cfg = {
107
+ ...op.config,
108
+ segments: op.config.segments.map((seg) => ({ ...seg, curviness: seg.curviness ?? 1 })),
109
+ };
110
+ return handleArcPathScript(parsed, s, setArcPathInScript(s, op.animationId, cfg));
111
+ }
112
+ case "updateArcSegment":
113
+ return handleArcPathScript(parsed, s, updateArcSegmentInScript(s, op.animationId, op.segmentIndex, op.update));
114
+ case "removeArcPath":
115
+ return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
116
+ case "unrollDynamicAnimations":
117
+ return handleArcPathScript(parsed, s, unrollDynamicAnimations(s, op.animationId, op.elements));
118
+ default:
119
+ return undefined;
120
+ }
121
+ }
122
+ function applyGsapWithKeyframesOp(parsed, op) {
123
+ switch (op.type) {
124
+ case "addWithKeyframes":
125
+ return handleAddWithKeyframes(parsed, op);
126
+ case "replaceWithKeyframes":
127
+ return handleReplaceWithKeyframes(parsed, op);
128
+ default:
129
+ return undefined;
130
+ }
131
+ }
132
+ function applyGsapOp(parsed, op) {
133
+ const kf = applyGsapKeyframeOp(parsed, op);
134
+ if (kf !== undefined)
135
+ return kf;
136
+ const arc = applyArcPathOp(parsed, op);
137
+ if (arc !== undefined)
138
+ return arc;
139
+ const wkf = applyGsapWithKeyframesOp(parsed, op);
140
+ if (wkf !== undefined)
141
+ return wkf;
142
+ switch (op.type) {
143
+ case "addGsapTween":
144
+ return handleAddGsapTween(parsed, op.target, op.tween);
145
+ case "setGsapTween":
146
+ return handleSetGsapTween(parsed, op.animationId, op.properties);
147
+ case "removeGsapProperty":
148
+ return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
149
+ case "removeGsapTween":
150
+ return handleRemoveGsapTween(parsed, op.animationId);
151
+ case "deleteAllForSelector":
152
+ return handleDeleteAllForSelector(parsed, op.selector);
153
+ default:
154
+ return undefined;
155
+ }
156
+ }
157
+ export function applyOp(parsed, op) {
158
+ const gsap = applyGsapOp(parsed, op);
159
+ if (gsap !== undefined)
160
+ return gsap;
161
+ switch (op.type) {
162
+ case "setStyle":
163
+ return handleSetStyle(parsed, targets(op.target), op.styles);
164
+ case "setText":
165
+ return handleSetText(parsed, targets(op.target), op.value);
166
+ case "setAttribute":
167
+ return handleSetAttribute(parsed, targets(op.target), op.name, op.value);
168
+ case "setTiming":
169
+ return handleSetTiming(parsed, targets(op.target), {
170
+ start: op.start,
171
+ duration: op.duration,
172
+ trackIndex: op.trackIndex,
173
+ });
174
+ case "setHold":
175
+ return handleSetHold(parsed, targets(op.target), op.hold);
176
+ case "moveElement":
177
+ return handleMoveElement(parsed, targets(op.target), op.x, op.y);
178
+ case "removeElement":
179
+ return handleRemoveElement(parsed, targets(op.target));
180
+ case "addElement":
181
+ return handleAddElement(parsed, op.parent, op.index, op.html);
182
+ case "reorderElements":
183
+ return handleReorderElements(parsed, op.entries);
184
+ case "setCompositionMetadata":
185
+ return handleSetCompositionMetadata(parsed, op);
186
+ case "setVariableValue":
187
+ return handleSetVariableValue(parsed, op.id, op.value);
188
+ case "setClassStyle":
189
+ return handleSetClassStyle(parsed, op.selector, op.styles);
190
+ case "addLabel":
191
+ return handleAddLabel(parsed, op.name, op.position);
192
+ case "removeLabel":
193
+ return handleRemoveLabel(parsed, op.name);
194
+ default:
195
+ throw new UnsupportedOpError(op.type);
196
+ }
197
+ }
198
+ // ─── Op handlers ────────────────────────────────────────────────────────────
199
+ function handleSetStyle(parsed, ids, styles) {
200
+ const result = { forward: [], inverse: [] };
201
+ for (const id of ids) {
202
+ const el = resolveScoped(parsed.document, id);
203
+ if (!el)
204
+ continue;
205
+ const old = getElementStyles(el);
206
+ setElementStyles(el, styles);
207
+ for (const [prop, value] of Object.entries(styles)) {
208
+ // Normalize to the camelCase key the style map + patch grammar use. A
209
+ // hyphenated op key ("transform-origin") otherwise misses the camelCase
210
+ // store, so oldValue is always null → undo deletes/loses the prior value,
211
+ // a removal skips its inverse patch entirely (DOM/patch-log desync), and
212
+ // the patch path/override-set key diverge from the camelCase grammar.
213
+ const key = toCamel(prop);
214
+ const path = stylePath(id, key);
215
+ const oldValue = old[key] ?? null;
216
+ if (value !== null) {
217
+ const p = scalarChange(path, oldValue, value);
218
+ result.forward.push(p.forward);
219
+ result.inverse.push(p.inverse);
220
+ }
221
+ else if (oldValue !== null) {
222
+ const p = scalarDelete(path, oldValue);
223
+ result.forward.push(p.forward);
224
+ result.inverse.push(p.inverse);
225
+ }
226
+ }
227
+ }
228
+ return result;
229
+ }
230
+ function handleMoveElement(parsed, ids, x, y) {
231
+ // HF elements are positioned via data-x / data-y (parsed by htmlParser.ts,
232
+ // emitted by hyperframes generator). CSS left/top is not the convention.
233
+ const rx = handleSetAttribute(parsed, ids, "data-x", String(x));
234
+ const ry = handleSetAttribute(parsed, ids, "data-y", String(y));
235
+ return {
236
+ forward: [...rx.forward, ...ry.forward],
237
+ inverse: [...ry.inverse, ...rx.inverse],
238
+ };
239
+ }
240
+ function handleSetText(parsed, ids, value) {
241
+ const result = { forward: [], inverse: [] };
242
+ for (const id of ids) {
243
+ const el = resolveScoped(parsed.document, id);
244
+ if (!el)
245
+ continue;
246
+ const oldText = getOwnText(el);
247
+ setOwnText(el, value);
248
+ const path = textPath(id);
249
+ // getOwnText always returns string ("" for empty) — use it directly so
250
+ // the forward patch is always op:'replace', not op:'add'. An op:'add' on
251
+ // a text path is semantically wrong for external JSON-patch consumers
252
+ // (the path already exists; add would fail on strict appliers).
253
+ const p = scalarChange(path, oldText, value);
254
+ result.forward.push(p.forward);
255
+ result.inverse.push(p.inverse);
256
+ }
257
+ return result;
258
+ }
259
+ function handleSetAttribute(parsed, ids, name, value) {
260
+ validateSetAttribute(name, value);
261
+ const result = { forward: [], inverse: [] };
262
+ for (const id of ids) {
263
+ const el = resolveScoped(parsed.document, id);
264
+ if (!el)
265
+ continue;
266
+ const oldValue = el.getAttribute(name);
267
+ const path = attrPath(id, name);
268
+ if (value !== null) {
269
+ el.setAttribute(name, value);
270
+ const p = scalarChange(path, oldValue, value);
271
+ result.forward.push(p.forward);
272
+ result.inverse.push(p.inverse);
273
+ }
274
+ else if (oldValue !== null) {
275
+ el.removeAttribute(name);
276
+ const p = scalarDelete(path, oldValue);
277
+ result.forward.push(p.forward);
278
+ result.inverse.push(p.inverse);
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ // fallow-ignore-next-line complexity
284
+ function handleSetTiming(parsed, ids, timing) {
285
+ const result = { forward: [], inverse: [] };
286
+ // Parse GSAP script once; updateAnimationInScript re-parses internally per call but
287
+ // we avoid re-fetching the script element on every iteration.
288
+ const origScript = getGsapScript(parsed.document);
289
+ const parsedGsap = origScript ? parseGsapScriptAcornForWrite(origScript) : null;
290
+ let currentScript = origScript;
291
+ for (const id of ids) {
292
+ const el = resolveScoped(parsed.document, id);
293
+ if (!el)
294
+ continue;
295
+ const oldStartStr = el.getAttribute("data-start");
296
+ const oldEndStr = el.getAttribute("data-end");
297
+ const oldDurationStr = el.getAttribute("data-duration");
298
+ const oldTrackStr = el.getAttribute("data-track-index");
299
+ const oldStart = oldStartStr !== null ? parseFloat(oldStartStr) : null;
300
+ const oldEnd = oldEndStr !== null ? parseFloat(oldEndStr) : null;
301
+ const oldDurationAttr = oldDurationStr !== null ? parseFloat(oldDurationStr) : null;
302
+ // Prefer an explicit data-duration — the attribute clips are authored with and
303
+ // the runtime reads — falling back to data-end − data-start. Reading only
304
+ // data-end left oldDuration null for duration-authored clips, collapsing the
305
+ // GSAP duration-scale ratio to 1 and scaling nothing.
306
+ const oldDuration = oldDurationAttr !== null
307
+ ? oldDurationAttr
308
+ : oldStart !== null && oldEnd !== null
309
+ ? oldEnd - oldStart
310
+ : null;
311
+ const oldTrack = oldTrackStr !== null ? parseInt(oldTrackStr, 10) : null;
312
+ const newStart = timing.start ?? oldStart;
313
+ const newDuration = timing.duration ?? oldDuration;
314
+ if (timing.start !== undefined && newStart !== null) {
315
+ const path = timingPath(id, "start");
316
+ const p = scalarChange(path, oldStart, newStart);
317
+ result.forward.push(p.forward);
318
+ result.inverse.push(p.inverse);
319
+ el.setAttribute("data-start", String(newStart));
320
+ }
321
+ // Write to whichever timing attribute the clip actually uses. A data-duration
322
+ // clip updates data-duration only on a real resize (duration is invariant
323
+ // under a move); a data-end clip updates data-end whenever start or duration
324
+ // changes (end = start + duration). Writing a fresh data-end beside a stale
325
+ // data-duration had no playback effect.
326
+ if (oldDurationStr !== null) {
327
+ if (timing.duration !== undefined && newDuration !== null) {
328
+ const path = timingPath(id, "duration");
329
+ const p = scalarChange(path, oldDurationAttr, newDuration);
330
+ result.forward.push(p.forward);
331
+ result.inverse.push(p.inverse);
332
+ el.setAttribute("data-duration", String(newDuration));
333
+ }
334
+ // A clip carrying BOTH data-duration and data-end must keep data-end in
335
+ // sync (end = start + duration) on any start/duration change, else the
336
+ // stale data-end inverts the clip (end < start) for runtimes that read it.
337
+ if (oldEndStr !== null && newStart !== null && newDuration !== null) {
338
+ const newEnd = newStart + newDuration;
339
+ const endPath = timingPath(id, "end");
340
+ const ep = scalarChange(endPath, oldEnd, newEnd);
341
+ result.forward.push(ep.forward);
342
+ result.inverse.push(ep.inverse);
343
+ el.setAttribute("data-end", String(newEnd));
344
+ }
345
+ }
346
+ else if ((timing.duration !== undefined || timing.start !== undefined) &&
347
+ newStart !== null &&
348
+ newDuration !== null) {
349
+ const newEnd = newStart + newDuration;
350
+ // Store the computed end value directly (not the logical duration) so the inverse
351
+ // patch is self-contained and doesn't require data-start to be restored first.
352
+ const path = timingPath(id, "end");
353
+ const p = scalarChange(path, oldEnd, newEnd);
354
+ result.forward.push(p.forward);
355
+ result.inverse.push(p.inverse);
356
+ el.setAttribute("data-end", String(newEnd));
357
+ }
358
+ if (timing.trackIndex !== undefined) {
359
+ const newTrack = timing.trackIndex;
360
+ const path = timingPath(id, "trackIndex");
361
+ const p = scalarChange(path, oldTrack, newTrack);
362
+ result.forward.push(p.forward);
363
+ result.inverse.push(p.inverse);
364
+ el.setAttribute("data-track-index", String(newTrack));
365
+ }
366
+ // Sync GSAP tween positions: the GSAP script is the source of truth at play time —
367
+ // the timeline rebuilds from it on every seek. Without this, DOM attribute edits
368
+ // have zero playback effect; the script's position/duration silently overrides them.
369
+ // Match against BOTH the element's data-hf-id (the canonical form) AND its DOM
370
+ // id: the Studio GSAP panel / ensureElementAddressable author tweens as
371
+ // `#domId`, which selectorMatchesId(hfId) never matched — so moving/resizing
372
+ // those clips left their tweens unsynced.
373
+ const matchHfId = el.getAttribute("data-hf-id") ?? id;
374
+ const matchDomId = el.getAttribute("id");
375
+ if (parsedGsap && currentScript) {
376
+ // A missing data-start means an implicit start of 0 (matching the server
377
+ // shiftGsapPositions path); a malformed attr parses to NaN. Sanitize to a
378
+ // finite number so a start-less/blank clip still shifts and never feeds
379
+ // NaN into the tween positions.
380
+ const oldStartNum = oldStart !== null && Number.isFinite(oldStart) ? oldStart : 0;
381
+ // Per-tween shift/scale (mirrors shiftGsapPositions/scaleGsapPositions): a
382
+ // multi-tween stagger maps each tween's own intra-clip position by the
383
+ // start DELTA and scales its duration by the clip-duration RATIO. Writing
384
+ // the absolute newStart/newDuration onto every tween would collapse the
385
+ // stagger onto one point and blow each tween's duration to the full clip.
386
+ const startChanged = timing.start !== undefined && newStart !== null;
387
+ const durChanged = timing.duration !== undefined && newDuration !== null;
388
+ const ratio = durChanged && oldDuration !== null && oldDuration > 0 && newDuration !== null
389
+ ? newDuration / oldDuration
390
+ : 1;
391
+ const remapStart = startChanged && newStart !== null ? newStart : oldStartNum;
392
+ for (const { id: animId, animation } of parsedGsap.located) {
393
+ const matches = selectorMatchesId(animation.targetSelector, matchHfId) ||
394
+ (matchDomId !== null && selectorMatchesId(animation.targetSelector, matchDomId));
395
+ if (!matches)
396
+ continue;
397
+ // Skip tweens whose position is a label or relative string ("+=0.5",
398
+ // "<", ">"): relative positions already track their neighbours, and a
399
+ // string position can't be safely shifted by the clip delta here.
400
+ // ponytail: known ceiling — string positions are not re-synced on
401
+ // move/resize; numeric positions only.
402
+ if (typeof animation.position !== "number")
403
+ continue;
404
+ const updates = {};
405
+ // Don't write an absolute position onto an auto-sequenced tween (no
406
+ // explicit position arg → parsed as implicitPosition): the writer would
407
+ // APPEND a position arg, collapsing the stagger onto one point. Duration
408
+ // still scales below.
409
+ if ((startChanged || durChanged) && animation.implicitPosition !== true) {
410
+ const shifted = remapStart + (animation.position - oldStartNum) * ratio;
411
+ updates.position = Math.max(0, Math.round(shifted * 1000) / 1000);
412
+ }
413
+ if (durChanged && typeof animation.duration === "number" && animation.duration > 0) {
414
+ updates.duration = Math.max(0.001, Math.round(animation.duration * ratio * 1000) / 1000);
415
+ }
416
+ if (Object.keys(updates).length === 0)
417
+ continue;
418
+ currentScript = updateAnimationInScript(currentScript, animId, updates);
419
+ }
420
+ }
421
+ }
422
+ // Flush accumulated GSAP script changes as a single patch pair.
423
+ if (origScript && currentScript && currentScript !== origScript) {
424
+ setGsapScript(parsed.document, currentScript);
425
+ const gsapResult = gsapScriptChange(origScript, currentScript);
426
+ result.forward.push(...gsapResult.forward);
427
+ result.inverse.push(...gsapResult.inverse);
428
+ }
429
+ return result;
430
+ }
431
+ function handleSetHold(parsed, ids, hold) {
432
+ const result = { forward: [], inverse: [] };
433
+ for (const id of ids) {
434
+ const el = resolveScoped(parsed.document, id);
435
+ if (!el)
436
+ continue;
437
+ const fields = [
438
+ ["start", String(hold.start)],
439
+ ["end", String(hold.end)],
440
+ ["fill", hold.fill],
441
+ ];
442
+ for (const [field, newVal] of fields) {
443
+ const attrName = `data-hold-${field}`;
444
+ const oldVal = el.getAttribute(attrName);
445
+ const path = holdPath(id, field);
446
+ el.setAttribute(attrName, newVal);
447
+ const p = scalarChange(path, oldVal, newVal);
448
+ result.forward.push(p.forward);
449
+ result.inverse.push(p.inverse);
450
+ }
451
+ }
452
+ return result;
453
+ }
454
+ function handleRemoveElement(parsed, ids) {
455
+ const result = { forward: [], inverse: [] };
456
+ const origScript = getGsapScript(parsed.document);
457
+ let currentScript = origScript;
458
+ for (const id of ids) {
459
+ const el = resolveScoped(parsed.document, id);
460
+ if (!el)
461
+ continue;
462
+ const parentEl = el.parentElement;
463
+ const parentId = parentEl?.getAttribute("data-hf-id") ?? null;
464
+ const siblingIndex = getSiblingIndex(el);
465
+ const html = el.outerHTML;
466
+ // Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade
467
+ // removes animations targeting any sub-composition element, not just the host.
468
+ const subtreeIds = collectSubtreeHfIds(el);
469
+ el.remove();
470
+ const path = elementPath(id);
471
+ result.forward.push(patchRemove(path));
472
+ result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
473
+ if (currentScript) {
474
+ for (const subtreeId of subtreeIds) {
475
+ currentScript = cascadeRemoveAnimations(currentScript, subtreeId);
476
+ }
477
+ }
478
+ }
479
+ if (origScript && currentScript && currentScript !== origScript) {
480
+ setGsapScript(parsed.document, currentScript);
481
+ const gsapResult = gsapScriptChange(origScript, currentScript);
482
+ result.forward.push(...gsapResult.forward);
483
+ result.inverse.push(...gsapResult.inverse);
484
+ }
485
+ return result;
486
+ }
487
+ // ─── addElement handler ───────────────────────────────────────────────────────
488
+ /**
489
+ * Resolve all existing hf-ids in the document into `assigned` so that
490
+ * mintHfId cannot issue an id that already exists in the composition.
491
+ */
492
+ function collectDocumentHfIds(document) {
493
+ const assigned = new Set();
494
+ for (const el of Array.from(document.querySelectorAll("[data-hf-id]"))) {
495
+ const id = el.getAttribute("data-hf-id");
496
+ if (id)
497
+ assigned.add(id);
498
+ }
499
+ return assigned;
500
+ }
501
+ /**
502
+ * Stamp data-hf-id onto every un-stamped element in `root` and its
503
+ * descendants, minting ids against `assigned` (the live document's id set).
504
+ * Returns the minted id of `root` (or its existing id if already stamped).
505
+ */
506
+ function mintFragmentIds(root, assigned) {
507
+ if (!root.getAttribute("data-hf-id") && !EXCLUDED_TAGS.has(root.tagName.toLowerCase())) {
508
+ root.setAttribute("data-hf-id", mintHfId(root, assigned));
509
+ }
510
+ for (const el of Array.from(root.querySelectorAll("*"))) {
511
+ if (EXCLUDED_TAGS.has(el.tagName.toLowerCase()))
512
+ continue;
513
+ if (el.getAttribute("data-hf-id"))
514
+ continue; // pinned
515
+ el.setAttribute("data-hf-id", mintHfId(el, assigned));
516
+ }
517
+ return root.getAttribute("data-hf-id") ?? "";
518
+ }
519
+ /**
520
+ * Insert an HTML fragment (single-root) as a child of `parent` at `index`.
521
+ * Mints ids against the LIVE document's existing id set so new ids can never
522
+ * collide with elements already in the composition. Returns the minted root id
523
+ * via result.meta.newId — mirrors the `animationId` pattern in addGsapTween.
524
+ *
525
+ * Inverse = patchRemove of the new element's path; mirrors handleRemoveElement's
526
+ * inverse = patchAdd. Forward/inverse are thus symmetric with that handler.
527
+ */
528
+ /**
529
+ * Parse an HTML fragment in the target document and return its single root
530
+ * element, or null when it is empty, multi-root, or contains a <script>.
531
+ * The dispatch path skips validateOp, so these guards are re-enforced here:
532
+ * never insert raw <script>, never silently drop extra roots.
533
+ */
534
+ function parseInsertableFragment(document, html) {
535
+ // Same temp-div approach as apply-patches.ts to avoid cross-document issues.
536
+ const tmp = document.createElement("div");
537
+ tmp.innerHTML = html;
538
+ if (tmp.querySelector("script"))
539
+ return null;
540
+ const node = tmp.firstElementChild;
541
+ if (!node || node.nextElementSibling)
542
+ return null;
543
+ return node;
544
+ }
545
+ function handleAddElement(parsed, parent, index, html) {
546
+ // Resolve parent element (null → document body). Narrow rather than assert:
547
+ // _dispatch does not run validateOp, so a bad parent id must not crash here.
548
+ const parentEl = parent === null
549
+ ? (parsed.document.body ?? null)
550
+ : resolveScoped(parsed.document, parent);
551
+ if (!parentEl)
552
+ return EMPTY;
553
+ const node = parseInsertableFragment(parsed.document, html);
554
+ if (!node)
555
+ return EMPTY;
556
+ // Mint ids against the LIVE doc's existing id set (the #1 landmine — a fresh
557
+ // ensureHfIds(fragment) is blind to existing doc ids and can collide).
558
+ // Order: mint → capture outerHTML → insert → build patch (id needed for path).
559
+ const assigned = collectDocumentHfIds(parsed.document);
560
+ const newId = mintFragmentIds(node, assigned);
561
+ const stampedHtml = node.outerHTML;
562
+ // Insert at `index` — append if index >= childCount (RFC-6902 insert semantics).
563
+ const ref = Array.from(parentEl.children)[index] ?? null;
564
+ parentEl.insertBefore(node, ref);
565
+ // parentId for the inverse/replay patch: preserve the caller's id verbatim
566
+ // (scoped "hf-host/hf-leaf" path or composition id), not the bare data-hf-id —
567
+ // apply-patches resolves it via findById→resolveScoped, so dropping the host
568
+ // prefix would re-insert under the wrong (canonical) parent on redo/replay.
569
+ const parentId = parent;
570
+ const path = elementPath(newId);
571
+ return {
572
+ forward: [patchAdd(path, { html: stampedHtml, parentId, siblingIndex: index })],
573
+ inverse: [patchRemove(path)],
574
+ meta: { newId },
575
+ };
576
+ }
577
+ function handleReorderElements(parsed, entries) {
578
+ const result = { forward: [], inverse: [] };
579
+ // Last write wins per target — a duplicated target collapses to one zIndex
580
+ // patch instead of emitting redundant same-path patches in one dispatch.
581
+ const lastByTarget = new Map();
582
+ for (const { target, zIndex } of entries)
583
+ lastByTarget.set(target, zIndex);
584
+ for (const [target, zIndex] of lastByTarget) {
585
+ const sub = handleSetStyle(parsed, [target], { zIndex: String(zIndex) });
586
+ result.forward.push(...sub.forward);
587
+ result.inverse.push(...sub.inverse);
588
+ }
589
+ return result;
590
+ }
591
+ // fallow-ignore-next-line complexity
592
+ function handleSetCompositionMetadata(parsed, op) {
593
+ const result = { forward: [], inverse: [] };
594
+ const root = findRoot(parsed.document);
595
+ if (!root)
596
+ return result;
597
+ // The runtime treats data-width/data-height as a FORCED override of inline
598
+ // style when present (core/runtime/init.ts applyCompositionSizing). So:
599
+ // style is always written; the data-* attribute is updated only when the
600
+ // composition already carries it — otherwise a style-only write would be
601
+ // clobbered on load. Absent attributes stay absent (keeps inverses exact).
602
+ if (op.width !== undefined) {
603
+ const styles = getElementStyles(root);
604
+ const oldAttr = root.getAttribute("data-width");
605
+ const oldWidth = oldAttr ?? styles["width"] ?? null;
606
+ const newVal = `${op.width}px`;
607
+ setElementStyles(root, { width: newVal });
608
+ if (oldAttr !== null)
609
+ root.setAttribute("data-width", String(op.width));
610
+ const path = metaPath("width");
611
+ const p = scalarChange(path, oldWidth !== null ? parseFloat(oldWidth) : null, op.width);
612
+ result.forward.push(p.forward);
613
+ result.inverse.push(p.inverse);
614
+ }
615
+ if (op.height !== undefined) {
616
+ const styles = getElementStyles(root);
617
+ const oldAttr = root.getAttribute("data-height");
618
+ const oldHeight = oldAttr ?? styles["height"] ?? null;
619
+ const newVal = `${op.height}px`;
620
+ setElementStyles(root, { height: newVal });
621
+ if (oldAttr !== null)
622
+ root.setAttribute("data-height", String(op.height));
623
+ const path = metaPath("height");
624
+ const p = scalarChange(path, oldHeight !== null ? parseFloat(oldHeight) : null, op.height);
625
+ result.forward.push(p.forward);
626
+ result.inverse.push(p.inverse);
627
+ }
628
+ if (op.duration !== undefined) {
629
+ const oldDur = root.getAttribute("data-duration");
630
+ const oldVal = oldDur !== null ? parseFloat(oldDur) : null;
631
+ root.setAttribute("data-duration", String(op.duration));
632
+ const path = metaPath("duration");
633
+ const p = scalarChange(path, oldVal, op.duration);
634
+ result.forward.push(p.forward);
635
+ result.inverse.push(p.inverse);
636
+ }
637
+ return result;
638
+ }
639
+ // ─── Variable JSON model helpers ─────────────────────────────────────────────
640
+ // readVariableDefault / writeVariableDefault now live in ./variableModel.ts,
641
+ // shared with the patch-replay path (apply-patches.ts) so the model shape can't
642
+ // diverge between forward mutation and replay.
643
+ /**
644
+ * True when the value is a FontValue or ImageValue object
645
+ * (object-valued; must NOT be written as a CSS custom property).
646
+ */
647
+ function isObjectVariableValue(value) {
648
+ return typeof value === "object" && value !== null && !Array.isArray(value);
649
+ }
650
+ function handleSetVariableValue(parsed, id, value) {
651
+ const root = findRoot(parsed.document);
652
+ if (!root)
653
+ return EMPTY;
654
+ const modelPath = variablePath(id);
655
+ const oldVarDefault = readVariableDefault(parsed.document, id);
656
+ if (isObjectVariableValue(value)) {
657
+ // Object values (font / image): write to JSON model only — objects are not
658
+ // valid CSS custom property values (LOCKED §7).
659
+ writeVariableDefault(parsed.document, id, value);
660
+ const p = valueChange(modelPath, oldVarDefault ?? null, value);
661
+ return { forward: [p.forward], inverse: [p.inverse] };
662
+ }
663
+ // Scalar values: update the JSON model (B1 — drives the runtime) and also
664
+ // keep the CSS custom prop as secondary / compat for compositions that
665
+ // CSS-bind directly to --{id}.
666
+ const cssVar = `--${id}`;
667
+ const rootId = root.getAttribute("data-hf-id");
668
+ const oldStyles = getElementStyles(root);
669
+ const oldCssValue = oldStyles[cssVar] ?? null;
670
+ const newVal = String(value);
671
+ setElementStyles(root, { [cssVar]: newVal });
672
+ writeVariableDefault(parsed.document, id, value);
673
+ // Emit explicit patches for both the JSON model (canonical) and the CSS compat
674
+ // prop. Keeping them separate means apply-patches.ts can handle each path type
675
+ // purely (variable path → model only; style path → CSS only), so inverse patches
676
+ // correctly restore the exact pre-call state without CSS-side-effect ambiguity.
677
+ const modelP = valueChange(modelPath, oldVarDefault ?? null, value);
678
+ const forward = [modelP.forward];
679
+ const inverse = [modelP.inverse];
680
+ if (rootId) {
681
+ const cssPatch = scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
682
+ forward.push(cssPatch.forward);
683
+ inverse.push(cssPatch.inverse);
684
+ }
685
+ return { forward, inverse };
686
+ }
687
+ // ─── GSAP selector helpers ───────────────────────────────────────────────────
688
+ function selectorMatchesId(selector, id) {
689
+ return (selector === `[data-hf-id="${id}"]` ||
690
+ selector === `[data-hf-id='${id}']` ||
691
+ selector === `#${id}`);
692
+ }
693
+ // v1 limitation: selectorMatchesId uses bare-id matching across the whole script, so a
694
+ // selector targeting "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf"
695
+ // and any other element whose scoped or bare id matches "hf-leaf". Acceptable for typical
696
+ // single-comp use; sub-composition authors with leaf-id collisions should use
697
+ // fully-qualified selectors.
698
+ /** Collect all bare data-hf-id values from el and all its descendants. */
699
+ function collectSubtreeHfIds(el) {
700
+ const ids = [];
701
+ const own = el.getAttribute("data-hf-id");
702
+ if (own)
703
+ ids.push(own);
704
+ for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) {
705
+ const id = child.getAttribute("data-hf-id");
706
+ if (id)
707
+ ids.push(id);
708
+ }
709
+ return ids;
710
+ }
711
+ function cascadeRemoveAnimations(script, id) {
712
+ // Re-parse after each removal: animation ids are positional, so removing one
713
+ // tween renumbers the survivors — ids from a single up-front parse go stale and
714
+ // no-op, orphaning later tweens on the removed element. Same fix as
715
+ // stripGsapForId in htmlParser.ts (R3 #3); this is its SDK-side twin.
716
+ let current = script;
717
+ for (;;) {
718
+ const parsedGsap = parseGsapScriptAcornForWrite(current);
719
+ if (!parsedGsap)
720
+ return current;
721
+ const match = parsedGsap.located.find((l) => selectorMatchesId(l.animation.targetSelector, id));
722
+ if (!match)
723
+ return current;
724
+ const next = removeAnimationFromScript(current, match.id);
725
+ if (next === current)
726
+ return current; // guard against a non-removing match
727
+ current = next;
728
+ }
729
+ }
730
+ // ─── addWithKeyframes / replaceWithKeyframes handlers ────────────────────────
731
+ function handleAddWithKeyframes(parsed, op) {
732
+ const script = getGsapScript(parsed.document);
733
+ if (!script)
734
+ throw new Error("No GSAP script block found in the composition.");
735
+ // Dispatch skips validateOp — re-enforce the empty-keyframes guard here so we
736
+ // never emit a degenerate `keyframes: {}` tween.
737
+ if (op.keyframes.length === 0)
738
+ return EMPTY;
739
+ const { script: newScript, id: animationId } = addAnimationWithKeyframesToScript(script, op.targetSelector, op.position, op.duration, op.keyframes, op.ease);
740
+ if (!animationId)
741
+ return EMPTY;
742
+ setGsapScript(parsed.document, newScript);
743
+ return { ...gsapScriptChange(script, newScript), meta: { animationId } };
744
+ }
745
+ function handleReplaceWithKeyframes(parsed, op) {
746
+ const script = getGsapScript(parsed.document);
747
+ if (!script)
748
+ throw new Error("No GSAP script block found in the composition.");
749
+ if (op.keyframes.length === 0)
750
+ return EMPTY;
751
+ // #11: tween IDs are position-derived and re-point after any structural edit,
752
+ // so a stale `animationId` can resolve to a DIFFERENT tween. Require the
753
+ // located animation to still target the selector the caller expects; if it is
754
+ // absent or now points at another element, bail rather than silently replace
755
+ // the wrong tween. (validateOp's gsapAnimationMissing only catches absent ids.)
756
+ const located = locateGsapAnimation(parsed, op.animationId);
757
+ if (!located || located.animation.targetSelector !== op.targetSelector)
758
+ return EMPTY;
759
+ // Step 1: remove the existing tween. Position-derived IDs renumber, so the
760
+ // inverse patch restores the full GSAP script rather than trying to re-insert
761
+ // by ID (handled by the coarse gsapScriptChange patch pair).
762
+ const afterRemove = removeAnimationFromScript(script, op.animationId);
763
+ // Defense in depth: if the id resolved to nothing the script is unchanged —
764
+ // bail rather than degrade the replace into a plain add (duplicate tween).
765
+ if (afterRemove === script)
766
+ return EMPTY;
767
+ // Step 2: insert the replacement keyframed tween.
768
+ const { script: newScript, id: animationId } = addAnimationWithKeyframesToScript(afterRemove, op.targetSelector, op.position, op.duration, op.keyframes, op.ease);
769
+ if (!animationId)
770
+ return EMPTY;
771
+ setGsapScript(parsed.document, newScript);
772
+ return { ...gsapScriptChange(script, newScript), meta: { animationId } };
773
+ }
774
+ // ─── setClassStyle handler ────────────────────────────────────────────────────
775
+ function handleSetClassStyle(parsed, selector, styles) {
776
+ const oldCss = getStyleSheet(parsed.document);
777
+ const newCss = upsertCssRule(oldCss, selector, styles);
778
+ if (newCss === oldCss)
779
+ return EMPTY;
780
+ setStyleSheet(parsed.document, newCss);
781
+ const path = styleSheetPath();
782
+ return {
783
+ forward: [
784
+ oldCss === "" ? { op: "add", path, value: newCss } : { op: "replace", path, value: newCss },
785
+ ],
786
+ inverse: [oldCss === "" ? { op: "remove", path } : { op: "replace", path, value: oldCss }],
787
+ };
788
+ }
789
+ // ─── GSAP script patch helpers ───────────────────────────────────────────────
790
+ function gsapScriptChange(oldScript, newScript) {
791
+ const path = gsapScriptPath();
792
+ return {
793
+ forward: [{ op: "replace", path, value: newScript }],
794
+ inverse: [{ op: "replace", path, value: oldScript }],
795
+ };
796
+ }
797
+ // ─── Phase 3b handlers ───────────────────────────────────────────────────────
798
+ // Build the GSAP target selector for an add op. The SDK's whole element↔tween
799
+ // attribution is data-hf-id based (selectorMatchesId, cascadeRemoveAnimations,
800
+ // buildAnimationIdMap), so ALWAYS emit the canonical [data-hf-id="…"] form.
801
+ //
802
+ // Resolve the target first: a normal element resolves to itself (hf-id ==
803
+ // target). A sub-composition ROOT addressed by its composition id resolves —
804
+ // via resolveScoped's comp-id fallback — to the host element, whose own
805
+ // data-hf-id we then emit. The fidelity resolver unifies this with the server
806
+ // writer's [data-composition-id="…"] form because both querySelector to the
807
+ // same host node.
808
+ function gsapTargetSelector(document, bareTarget) {
809
+ const el = resolveScoped(document, bareTarget);
810
+ if (!el)
811
+ return `[data-hf-id="${escapeHfId(bareTarget)}"]`;
812
+ const hfId = el.getAttribute("data-hf-id");
813
+ if (hfId)
814
+ return `[data-hf-id="${escapeHfId(hfId)}"]`;
815
+ // Resolved a sub-comp root that carries data-composition-id but no own
816
+ // data-hf-id (rare/defensive) — address it by its composition id.
817
+ const compId = el.getAttribute("data-composition-id");
818
+ if (compId)
819
+ return `[data-composition-id="${escapeHfId(compId)}"]`;
820
+ return `[data-hf-id="${escapeHfId(bareTarget)}"]`;
821
+ }
822
+ // fallow-ignore-next-line complexity
823
+ function handleAddGsapTween(parsed, target, tween) {
824
+ const script = getGsapScript(parsed.document);
825
+ if (!script)
826
+ throw new Error("No GSAP script block found in the composition.");
827
+ const extras = {};
828
+ if (tween.repeat !== undefined)
829
+ extras.repeat = tween.repeat;
830
+ if (tween.yoyo !== undefined)
831
+ extras.yoyo = tween.yoyo;
832
+ if (tween.stagger !== undefined)
833
+ extras.stagger = tween.stagger;
834
+ // A fromTo's destination may arrive as either `toProperties` or `properties`
835
+ // (the Studio add path sets `properties`). Fall back the same way for every
836
+ // method — the old fromTo-only branch read `toProperties` alone and wrote an
837
+ // empty to-vars object, so fromTo animations added via cutover animated to {}.
838
+ const toProps = (tween.toProperties ?? tween.properties ?? {});
839
+ // Scoped ids like "hf-host/hf-leaf" must use the bare leaf id in the GSAP
840
+ // selector — only the leaf part is written as data-hf-id on the DOM element.
841
+ const bareTarget = target.includes("/") ? (target.split("/").at(-1) ?? target) : target;
842
+ const animation = {
843
+ targetSelector: gsapTargetSelector(parsed.document, bareTarget),
844
+ method: tween.method,
845
+ position: tween.position ?? 0,
846
+ ...(tween.duration !== undefined ? { duration: tween.duration } : {}),
847
+ ...(tween.ease ? { ease: tween.ease } : {}),
848
+ properties: toProps,
849
+ ...(tween.fromProperties
850
+ ? { fromProperties: tween.fromProperties }
851
+ : {}),
852
+ ...(Object.keys(extras).length > 0 ? { extras } : {}),
853
+ };
854
+ const { script: newScript, id: animationId } = addAnimationToScript(script, animation);
855
+ if (!animationId)
856
+ return EMPTY;
857
+ setGsapScript(parsed.document, newScript);
858
+ return { ...gsapScriptChange(script, newScript), meta: { animationId } };
859
+ }
860
+ // fallow-ignore-next-line complexity
861
+ function handleSetGsapTween(parsed, animationId, properties) {
862
+ const script = getGsapScript(parsed.document);
863
+ if (!script)
864
+ throw new Error("No GSAP script block found in the composition.");
865
+ const updates = {};
866
+ if (properties.duration !== undefined)
867
+ updates.duration = properties.duration;
868
+ if (properties.ease !== undefined)
869
+ updates.ease = properties.ease;
870
+ if (properties.position !== undefined)
871
+ updates.position = properties.position;
872
+ const toProps = properties.toProperties ?? properties.properties;
873
+ if (toProps)
874
+ updates.properties = toProps;
875
+ if (properties.fromProperties)
876
+ updates.fromProperties = properties.fromProperties;
877
+ const extras = {};
878
+ if (properties.repeat !== undefined)
879
+ extras.repeat = properties.repeat;
880
+ if (properties.yoyo !== undefined)
881
+ extras.yoyo = properties.yoyo;
882
+ if (properties.stagger !== undefined)
883
+ extras.stagger = properties.stagger;
884
+ if (Object.keys(extras).length > 0)
885
+ updates.extras = extras;
886
+ const newScript = updateAnimationInScript(script, animationId, updates);
887
+ if (newScript === script)
888
+ return EMPTY;
889
+ setGsapScript(parsed.document, newScript);
890
+ return gsapScriptChange(script, newScript);
891
+ }
892
+ function handleRemoveGsapProperty(parsed, animationId, property, from) {
893
+ const script = getGsapScript(parsed.document);
894
+ if (!script)
895
+ return EMPTY;
896
+ const newScript = removePropertyFromAnimation(script, animationId, property, from ?? false);
897
+ if (newScript === script)
898
+ return EMPTY;
899
+ setGsapScript(parsed.document, newScript);
900
+ return gsapScriptChange(script, newScript);
901
+ }
902
+ function handleRemoveGsapTween(parsed, animationId) {
903
+ const script = getGsapScript(parsed.document);
904
+ if (!script)
905
+ throw new Error("No GSAP script block found in the composition.");
906
+ const newScript = removeAnimationFromScript(script, animationId);
907
+ if (newScript === script)
908
+ return EMPTY;
909
+ setGsapScript(parsed.document, newScript);
910
+ return gsapScriptChange(script, newScript);
911
+ }
912
+ function handleRemoveAllKeyframes(parsed, animationId) {
913
+ const script = getGsapScript(parsed.document);
914
+ if (!script)
915
+ return EMPTY;
916
+ const newScript = removeAllKeyframesFromScript(script, animationId);
917
+ if (newScript === script)
918
+ return EMPTY;
919
+ setGsapScript(parsed.document, newScript);
920
+ return gsapScriptChange(script, newScript);
921
+ }
922
+ function handleConvertToKeyframes(parsed, animationId, resolvedFromValues) {
923
+ const script = getGsapScript(parsed.document);
924
+ if (!script)
925
+ return EMPTY;
926
+ const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues);
927
+ if (newScript === script)
928
+ return EMPTY;
929
+ setGsapScript(parsed.document, newScript);
930
+ return gsapScriptChange(script, newScript);
931
+ }
932
+ function handleMaterializeKeyframes(parsed, animationId, keyframes, easeEach, resolvedSelector) {
933
+ const script = getGsapScript(parsed.document);
934
+ if (!script)
935
+ return EMPTY;
936
+ const newScript = materializeKeyframesFromScript(script, animationId, keyframes, easeEach, resolvedSelector);
937
+ if (newScript === script)
938
+ return EMPTY;
939
+ setGsapScript(parsed.document, newScript);
940
+ return gsapScriptChange(script, newScript);
941
+ }
942
+ function handleSplitIntoPropertyGroups(parsed, animationId) {
943
+ const script = getGsapScript(parsed.document);
944
+ if (!script)
945
+ return EMPTY;
946
+ const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
947
+ if (newScript === script)
948
+ return EMPTY;
949
+ setGsapScript(parsed.document, newScript);
950
+ return gsapScriptChange(script, newScript);
951
+ }
952
+ function handleSplitAnimations(parsed, op) {
953
+ const script = getGsapScript(parsed.document);
954
+ if (!script)
955
+ return EMPTY;
956
+ const { script: newScript } = splitAnimationsInScript(script, {
957
+ originalId: op.originalId,
958
+ newId: op.newId,
959
+ splitTime: op.splitTime,
960
+ elementStart: op.elementStart,
961
+ elementDuration: op.elementDuration,
962
+ });
963
+ if (newScript === script)
964
+ return EMPTY;
965
+ setGsapScript(parsed.document, newScript);
966
+ return gsapScriptChange(script, newScript);
967
+ }
968
+ function handleArcPathScript(parsed, oldScript, newScript) {
969
+ if (newScript === oldScript)
970
+ return EMPTY;
971
+ setGsapScript(parsed.document, newScript);
972
+ return gsapScriptChange(oldScript, newScript);
973
+ }
974
+ function handleDeleteAllForSelector(parsed, selector) {
975
+ const script = getGsapScript(parsed.document);
976
+ if (!script)
977
+ return EMPTY;
978
+ const parsedForWrite = parseGsapScriptAcornForWrite(script);
979
+ if (!parsedForWrite)
980
+ return EMPTY;
981
+ // Compare quote-insensitively: [data-hf-id='x'] and [data-hf-id="x"] are the
982
+ // same selector. A strict === missed the alternate quote style and matched
983
+ // nothing while can() reported ok.
984
+ const wanted = selector.replace(/'/g, '"');
985
+ const matching = parsedForWrite.located.filter((l) => l.animation.targetSelector.replace(/'/g, '"') === wanted);
986
+ if (matching.length === 0)
987
+ return EMPTY;
988
+ let newScript = script;
989
+ for (const m of [...matching].reverse()) {
990
+ newScript = removeAnimationFromScript(newScript, m.id);
991
+ }
992
+ if (newScript === script)
993
+ return EMPTY;
994
+ setGsapScript(parsed.document, newScript);
995
+ // ponytail: skips stripStudioEditsFromTarget (data-hf-studio-path-offset cleanup) —
996
+ // studio path offset is cosmetic once all animations are gone; session reloads after write
997
+ return gsapScriptChange(script, newScript);
998
+ }
999
+ function resolveKeyframe(parsed, animationId, keyframeIndex) {
1000
+ const script = getGsapScript(parsed.document);
1001
+ if (!script)
1002
+ return null;
1003
+ const parsedForWrite = parseGsapScriptAcornForWrite(script);
1004
+ const located = parsedForWrite?.located.find((l) => l.id === animationId);
1005
+ const kfs = located?.animation.keyframes?.keyframes;
1006
+ const kf = kfs?.[keyframeIndex];
1007
+ if (!kfs || !kf || keyframeIndex < 0)
1008
+ return null;
1009
+ return { script, kf, kfs };
1010
+ }
1011
+ // fallow-ignore-next-line complexity
1012
+ function handleSetGsapKeyframe(parsed, animationId, keyframeIndex, position, value, ease) {
1013
+ const resolved = resolveKeyframe(parsed, animationId, keyframeIndex);
1014
+ if (!resolved)
1015
+ return EMPTY;
1016
+ const { script, kf: existingKf } = resolved;
1017
+ const currentPct = existingKf.percentage;
1018
+ const targetPct = position ?? currentPct;
1019
+ const props = value
1020
+ ? value
1021
+ : { ...existingKf.properties };
1022
+ const resolvedEase = ease ?? existingKf.ease;
1023
+ let newScript = script;
1024
+ if (targetPct !== currentPct) {
1025
+ newScript = removeKeyframeFromScript(newScript, animationId, currentPct);
1026
+ // Thread the same backfill defaults the add path uses so a move (remove +
1027
+ // re-add at a new percentage) seeds new props into sibling keyframes the same
1028
+ // way, keeping both entry points behaviorally identical.
1029
+ newScript = addKeyframeToScript(newScript, animationId, targetPct, props, resolvedEase, deriveKeyframeBackfillDefaults(props));
1030
+ }
1031
+ else {
1032
+ newScript = updateKeyframeInScript(newScript, animationId, currentPct, props, resolvedEase);
1033
+ }
1034
+ if (newScript === script)
1035
+ return EMPTY;
1036
+ setGsapScript(parsed.document, newScript);
1037
+ return gsapScriptChange(script, newScript);
1038
+ }
1039
+ function handleAddGsapKeyframe(parsed, animationId, percentage, value) {
1040
+ const script = getGsapScript(parsed.document);
1041
+ if (!script)
1042
+ throw new Error("No GSAP script block found in the composition.");
1043
+ const props = value;
1044
+ const newScript = addKeyframeToScript(script, animationId, percentage, props, undefined, deriveKeyframeBackfillDefaults(props));
1045
+ if (newScript === script)
1046
+ return EMPTY;
1047
+ setGsapScript(parsed.document, newScript);
1048
+ return gsapScriptChange(script, newScript);
1049
+ }
1050
+ function handleRemoveGsapKeyframeByPercentage(parsed, animationId, percentage) {
1051
+ const script = getGsapScript(parsed.document);
1052
+ if (!script)
1053
+ return EMPTY;
1054
+ const parsedForWrite = parseGsapScriptAcornForWrite(script);
1055
+ const located = parsedForWrite?.located.find((l) => l.id === animationId);
1056
+ const kfs = located?.animation.keyframes?.keyframes;
1057
+ if (!kfs)
1058
+ return EMPTY;
1059
+ // No-op on ambiguity: duplicate-percentage keyframes can't be disambiguated.
1060
+ const TOLERANCE = 0.001;
1061
+ const matches = kfs.filter((k) => Math.abs(k.percentage - percentage) <= TOLERANCE);
1062
+ const sole = matches[0];
1063
+ if (matches.length !== 1 || !sole)
1064
+ return EMPTY;
1065
+ const pct = sole.percentage;
1066
+ const newScript = removeKeyframeFromScript(script, animationId, pct);
1067
+ if (newScript === script)
1068
+ return EMPTY;
1069
+ setGsapScript(parsed.document, newScript);
1070
+ return gsapScriptChange(script, newScript);
1071
+ }
1072
+ function handleAddLabel(parsed, name, position) {
1073
+ const script = getGsapScript(parsed.document);
1074
+ if (!script)
1075
+ return EMPTY;
1076
+ const newScript = addLabelToScript(script, name, position);
1077
+ if (newScript === script)
1078
+ return EMPTY;
1079
+ setGsapScript(parsed.document, newScript);
1080
+ return gsapScriptChange(script, newScript);
1081
+ }
1082
+ function handleRemoveLabel(parsed, name) {
1083
+ const script = getGsapScript(parsed.document);
1084
+ if (!script)
1085
+ return EMPTY;
1086
+ const newScript = removeLabelFromScript(script, name);
1087
+ if (newScript === script)
1088
+ return EMPTY;
1089
+ setGsapScript(parsed.document, newScript);
1090
+ return gsapScriptChange(script, newScript);
1091
+ }
1092
+ // ─── Validation (can(op)) ────────────────────────────────────────────────────
1093
+ const CAN_OK = { ok: true };
1094
+ function canErr(code, message, hint) {
1095
+ return hint ? { ok: false, code, message, hint } : { ok: false, code, message };
1096
+ }
1097
+ /** E_NO_GSAP_SCRIPT CanResult when the composition has no GSAP script, else null. */
1098
+ function gsapScriptMissing(parsed) {
1099
+ return getGsapScript(parsed.document) === null
1100
+ ? canErr("E_NO_GSAP_SCRIPT", "No GSAP script block found in the composition.", "This composition does not use GSAP animations.")
1101
+ : null;
1102
+ }
1103
+ /** The located GSAP animation for `animationId`, or undefined. */
1104
+ function locateGsapAnimation(parsed, animationId) {
1105
+ const script = getGsapScript(parsed.document);
1106
+ if (!script)
1107
+ return undefined;
1108
+ return parseGsapScriptAcornForWrite(script)?.located.find((l) => l.id === animationId);
1109
+ }
1110
+ /**
1111
+ * E_TARGET_NOT_FOUND CanResult when no GSAP animation resolves to `animationId`,
1112
+ * else null. Without this, can() returned ok for stale/positional ids that then
1113
+ * no-op'd at apply — the caller believed the edit would land.
1114
+ */
1115
+ function gsapAnimationMissing(parsed, animationId) {
1116
+ if (getGsapScript(parsed.document) === null)
1117
+ return null; // reported by gsapScriptMissing
1118
+ return locateGsapAnimation(parsed, animationId)
1119
+ ? null
1120
+ : canErr("E_TARGET_NOT_FOUND", `No GSAP animation found with id "${animationId}".`, "Animation ids are positional and shift after edits — re-read them from comp before dispatching.");
1121
+ }
1122
+ /** Validate updateArcSegment: the tween must have an enabled arc with that segment. */
1123
+ function validateArcSegment(parsed, op) {
1124
+ const arc = locateGsapAnimation(parsed, op.animationId)?.animation.arcPath;
1125
+ if (!arc?.enabled)
1126
+ return canErr("E_ARC_NOT_ENABLED", `Animation "${op.animationId}" has no enabled arc path.`, "Call setArcPath({ enabled: true }) before updating a segment.");
1127
+ if (op.segmentIndex < 0 || op.segmentIndex >= arc.segments.length)
1128
+ return canErr("E_INVALID_ARGS", `Segment index ${op.segmentIndex} is out of range (0..${arc.segments.length - 1}).`);
1129
+ return CAN_OK;
1130
+ }
1131
+ /** Dry-run validation — returns CanResult for the given op against current document state. */
1132
+ // fallow-ignore-next-line complexity
1133
+ export function validateOp(parsed, op) {
1134
+ switch (op.type) {
1135
+ case "setStyle":
1136
+ case "setText":
1137
+ case "setAttribute":
1138
+ case "setTiming":
1139
+ case "setHold":
1140
+ case "moveElement":
1141
+ case "removeElement": {
1142
+ const ids = targets(op.target);
1143
+ if (ids.length === 0)
1144
+ return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
1145
+ const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
1146
+ if (missing.length > 0)
1147
+ return canErr("E_TARGET_NOT_FOUND", `Element(s) not found: ${missing.join(", ")}.`, "Verify the id against comp.getElements() or comp.find().");
1148
+ return CAN_OK;
1149
+ }
1150
+ case "addElement": {
1151
+ if (op.parent !== null && resolveScoped(parsed.document, op.parent) === null)
1152
+ return canErr("E_TARGET_NOT_FOUND", `Parent element not found: "${op.parent}".`, "Verify the parent id against comp.getElements() or comp.find().");
1153
+ if (op.index < 0)
1154
+ return canErr("E_INVALID_ARGS", `index must be >= 0 (got ${op.index}).`);
1155
+ if (!op.html || op.html.trim().length === 0)
1156
+ return canErr("E_INVALID_HTML", "html must not be empty.");
1157
+ // Parse to check for <script> and zero-element fragments.
1158
+ // Use the same temp-div pattern as apply-patches.ts for consistency.
1159
+ const tmp = parsed.document.createElement("div");
1160
+ tmp.innerHTML = op.html;
1161
+ if (tmp.firstElementChild === null)
1162
+ return canErr("E_INVALID_HTML", "html parses to zero element nodes.");
1163
+ if (tmp.querySelector("script") !== null)
1164
+ return canErr("E_INVALID_HTML", "<script> elements are not permitted in addElement html.", "GSAP is managed by the composition's single script block; add tweens via addGsapTween.");
1165
+ return CAN_OK;
1166
+ }
1167
+ case "reorderElements": {
1168
+ if (op.entries.length === 0)
1169
+ return CAN_OK;
1170
+ const missing = op.entries
1171
+ .map((e) => e.target)
1172
+ .filter((id) => resolveScoped(parsed.document, id) === null);
1173
+ if (missing.length > 0)
1174
+ return canErr("E_TARGET_NOT_FOUND", `Element(s) not found: ${missing.join(", ")}.`, "Verify the id against comp.getElements() or comp.find().");
1175
+ return CAN_OK;
1176
+ }
1177
+ case "setVariableValue":
1178
+ if (findRoot(parsed.document) === null)
1179
+ return canErr("E_NO_ROOT", "Composition root element not found.");
1180
+ return CAN_OK;
1181
+ case "setCompositionMetadata":
1182
+ case "setClassStyle":
1183
+ return CAN_OK;
1184
+ case "addGsapTween":
1185
+ case "addLabel": {
1186
+ if (op.type === "addGsapTween" && resolveScoped(parsed.document, op.target) === null)
1187
+ return canErr("E_TARGET_NOT_FOUND", `Element not found: ${op.target}.`, "Verify the id against comp.getElements() or comp.find().");
1188
+ const script = getGsapScript(parsed.document);
1189
+ if (!script)
1190
+ return canErr("E_NO_GSAP_SCRIPT", "No GSAP script block found in the composition.", "This composition does not use GSAP animations.");
1191
+ const p = parseGsapScriptAcornForWrite(script);
1192
+ if (!p || !p.hasTimeline)
1193
+ return canErr("E_NO_GSAP_TIMELINE", "No gsap.timeline() declaration found in the GSAP script.", "addGsapTween / addLabel require a timeline variable (e.g. var tl = gsap.timeline(...)).");
1194
+ return CAN_OK;
1195
+ }
1196
+ case "setGsapTween":
1197
+ case "setGsapKeyframe":
1198
+ case "addGsapKeyframe":
1199
+ case "removeGsapKeyframe":
1200
+ case "removeGsapProperty":
1201
+ case "removeGsapTween":
1202
+ case "removeAllKeyframes":
1203
+ case "convertToKeyframes":
1204
+ case "splitIntoPropertyGroups":
1205
+ case "setArcPath":
1206
+ case "removeArcPath":
1207
+ return gsapScriptMissing(parsed) ?? gsapAnimationMissing(parsed, op.animationId) ?? CAN_OK;
1208
+ case "updateArcSegment":
1209
+ return (gsapScriptMissing(parsed) ??
1210
+ gsapAnimationMissing(parsed, op.animationId) ??
1211
+ validateArcSegment(parsed, op));
1212
+ case "splitAnimations":
1213
+ case "deleteAllForSelector":
1214
+ case "removeLabel":
1215
+ return gsapScriptMissing(parsed) ?? CAN_OK;
1216
+ case "addWithKeyframes":
1217
+ return (gsapScriptMissing(parsed) ??
1218
+ (op.keyframes.length === 0
1219
+ ? canErr("E_INVALID_ARGS", "addWithKeyframes requires at least one keyframe.", "An empty keyframe list would create an animation with no keyframes.")
1220
+ : CAN_OK));
1221
+ case "replaceWithKeyframes":
1222
+ return (gsapScriptMissing(parsed) ??
1223
+ gsapAnimationMissing(parsed, op.animationId) ??
1224
+ (op.keyframes.length === 0
1225
+ ? canErr("E_INVALID_ARGS", "replaceWithKeyframes requires at least one keyframe.", "An empty keyframe list would create an animation with no keyframes.")
1226
+ : CAN_OK));
1227
+ case "unrollDynamicAnimations":
1228
+ return (gsapScriptMissing(parsed) ??
1229
+ gsapAnimationMissing(parsed, op.animationId) ??
1230
+ (op.elements.length === 0
1231
+ ? canErr("E_INVALID_ARGS", "unrollDynamicAnimations requires at least one element.", "An empty element list would delete the animation; pass the resolved element list.")
1232
+ : CAN_OK));
1233
+ case "materializeKeyframes":
1234
+ return (gsapScriptMissing(parsed) ??
1235
+ gsapAnimationMissing(parsed, op.animationId) ??
1236
+ (op.keyframes.length === 0
1237
+ ? canErr("E_INVALID_ARGS", "materializeKeyframes requires at least one keyframe.", "An empty keyframe list would empty the animation; pass the resolved keyframes.")
1238
+ : CAN_OK));
1239
+ default:
1240
+ return canErr("E_UNKNOWN_OP", `Unknown op type: "${op.type}".`);
1241
+ }
1242
+ }
1243
+ //# sourceMappingURL=mutate.js.map