@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.
- package/LICENSE +190 -0
- package/dist/adapters/fs.d.ts +9 -0
- package/dist/adapters/fs.d.ts.map +1 -0
- package/dist/adapters/fs.js +122 -0
- package/dist/adapters/fs.js.map +1 -0
- package/dist/adapters/headless.d.ts +3 -0
- package/dist/adapters/headless.d.ts.map +1 -0
- package/dist/adapters/headless.js +17 -0
- package/dist/adapters/headless.js.map +1 -0
- package/dist/adapters/iframe.d.ts +102 -0
- package/dist/adapters/iframe.d.ts.map +1 -0
- package/dist/adapters/iframe.js +569 -0
- package/dist/adapters/iframe.js.map +1 -0
- package/dist/adapters/memory.d.ts +5 -0
- package/dist/adapters/memory.d.ts.map +1 -0
- package/dist/adapters/memory.js +54 -0
- package/dist/adapters/memory.js.map +1 -0
- package/dist/adapters/types.d.ts +65 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/document.d.ts +25 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +238 -0
- package/dist/document.js.map +1 -0
- package/dist/engine/apply-patches.d.ts +20 -0
- package/dist/engine/apply-patches.d.ts.map +1 -0
- package/dist/engine/apply-patches.js +284 -0
- package/dist/engine/apply-patches.js.map +1 -0
- package/dist/engine/cssWriter.d.ts +18 -0
- package/dist/engine/cssWriter.d.ts.map +1 -0
- package/dist/engine/cssWriter.js +139 -0
- package/dist/engine/cssWriter.js.map +1 -0
- package/dist/engine/keyframeBackfill.d.ts +17 -0
- package/dist/engine/keyframeBackfill.d.ts.map +1 -0
- package/dist/engine/keyframeBackfill.js +43 -0
- package/dist/engine/keyframeBackfill.js.map +1 -0
- package/dist/engine/model.d.ts +55 -0
- package/dist/engine/model.d.ts.map +1 -0
- package/dist/engine/model.js +256 -0
- package/dist/engine/model.js.map +1 -0
- package/dist/engine/mutate.d.ts +26 -0
- package/dist/engine/mutate.d.ts.map +1 -0
- package/dist/engine/mutate.js +1243 -0
- package/dist/engine/mutate.js.map +1 -0
- package/dist/engine/patches.d.ts +71 -0
- package/dist/engine/patches.d.ts.map +1 -0
- package/dist/engine/patches.js +197 -0
- package/dist/engine/patches.js.map +1 -0
- package/dist/engine/serialize.d.ts +15 -0
- package/dist/engine/serialize.d.ts.map +1 -0
- package/dist/engine/serialize.js +20 -0
- package/dist/engine/serialize.js.map +1 -0
- package/dist/engine/variableModel.d.ts +29 -0
- package/dist/engine/variableModel.d.ts.map +1 -0
- package/dist/engine/variableModel.js +81 -0
- package/dist/engine/variableModel.js.map +1 -0
- package/dist/history.d.ts +39 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +116 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/persist-queue.d.ts +24 -0
- package/dist/persist-queue.d.ts.map +1 -0
- package/dist/persist-queue.js +62 -0
- package/dist/persist-queue.js.map +1 -0
- package/dist/session.d.ts +38 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +514 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +521 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- 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
|