@hyperframes/studio 0.6.73 → 0.6.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-Dc2HfqON.js +0 -140
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the Studio drag system and GSAP animations running in the
|
|
3
|
+
* preview iframe.
|
|
4
|
+
*
|
|
5
|
+
* The preview iframe exposes `window.gsap` with a `getProperty(element, prop)`
|
|
6
|
+
* method that returns the ACTUAL interpolated value at the current seek time.
|
|
7
|
+
* This module reads those runtime values so that drag commits can write correct
|
|
8
|
+
* absolute positions back into the GSAP script, regardless of tween type,
|
|
9
|
+
* easing, or seek position.
|
|
10
|
+
*/
|
|
11
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
|
+
import { clearStudioPathOffset } from "../components/editor/manualEdits";
|
|
14
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
|
+
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
16
|
+
|
|
17
|
+
// ── Runtime reads ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface IframeGsap {
|
|
20
|
+
getProperty: (el: Element, prop: string) => number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// fallow-ignore-next-line complexity
|
|
24
|
+
function readGsapPositionFromIframe(
|
|
25
|
+
iframe: HTMLIFrameElement | null,
|
|
26
|
+
elementSelector: string,
|
|
27
|
+
): { x: number; y: number } | null {
|
|
28
|
+
if (!iframe?.contentWindow) return null;
|
|
29
|
+
|
|
30
|
+
let gsap: IframeGsap | undefined;
|
|
31
|
+
try {
|
|
32
|
+
gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!gsap?.getProperty) return null;
|
|
37
|
+
|
|
38
|
+
let doc: Document | null = null;
|
|
39
|
+
try {
|
|
40
|
+
doc = iframe.contentDocument;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!doc) return null;
|
|
45
|
+
|
|
46
|
+
const element = doc.querySelector(elementSelector);
|
|
47
|
+
if (!element) return null;
|
|
48
|
+
|
|
49
|
+
const x = Number(gsap.getProperty(element, "x")) || 0;
|
|
50
|
+
const y = Number(gsap.getProperty(element, "y")) || 0;
|
|
51
|
+
return { x, y };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Animation matching ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
// fallow-ignore-next-line complexity
|
|
57
|
+
function findGsapPositionAnimation(animations: GsapAnimation[]): GsapAnimation | null {
|
|
58
|
+
// Prefer animations that already have x/y
|
|
59
|
+
for (const anim of animations) {
|
|
60
|
+
if (anim.keyframes) {
|
|
61
|
+
const hasPos = anim.keyframes.keyframes.some(
|
|
62
|
+
(kf) => "x" in kf.properties || "y" in kf.properties,
|
|
63
|
+
);
|
|
64
|
+
if (hasPos) return anim;
|
|
65
|
+
}
|
|
66
|
+
const props = anim.properties;
|
|
67
|
+
const fromProps = anim.fromProperties;
|
|
68
|
+
if (anim.method === "fromTo") {
|
|
69
|
+
if ("x" in props || "y" in props || (fromProps && ("x" in fromProps || "y" in fromProps))) {
|
|
70
|
+
return anim;
|
|
71
|
+
}
|
|
72
|
+
} else if ("x" in props || "y" in props) {
|
|
73
|
+
return anim;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Fall back to any keyframed animation — drag will add x/y to it
|
|
77
|
+
for (const anim of animations) {
|
|
78
|
+
if (anim.keyframes) return anim;
|
|
79
|
+
}
|
|
80
|
+
// Fall back to any animation — will be converted to keyframes
|
|
81
|
+
return animations[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Selector resolution ────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function selectorForSelection(selection: DomEditSelection): string | null {
|
|
87
|
+
if (selection.id) return `#${selection.id}`;
|
|
88
|
+
if (selection.selector) return selection.selector;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Percentage computation ─────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function computeCurrentPercentage(selection: DomEditSelection): number {
|
|
95
|
+
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
|
96
|
+
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
|
97
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
98
|
+
return elDuration > 0
|
|
99
|
+
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
100
|
+
: 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Dynamic keyframe materialization ──────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async function materializeIfDynamic(
|
|
106
|
+
anim: GsapAnimation,
|
|
107
|
+
iframe: HTMLIFrameElement | null,
|
|
108
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
109
|
+
selection: DomEditSelection,
|
|
110
|
+
): Promise<string | void> {
|
|
111
|
+
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
|
112
|
+
|
|
113
|
+
if (anim.hasUnresolvedSelector) {
|
|
114
|
+
// Unroll: read ALL elements' keyframes from runtime and replace the loop
|
|
115
|
+
const allScanned = scanAllRuntimeKeyframes(iframe);
|
|
116
|
+
if (allScanned.size === 0) return;
|
|
117
|
+
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
|
118
|
+
selector: `#${id}`,
|
|
119
|
+
keyframes: data.keyframes,
|
|
120
|
+
easeEach: data.easeEach,
|
|
121
|
+
}));
|
|
122
|
+
await commitMutation(
|
|
123
|
+
selection,
|
|
124
|
+
{
|
|
125
|
+
type: "materialize-keyframes",
|
|
126
|
+
animationId: anim.id,
|
|
127
|
+
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
|
|
128
|
+
allElements,
|
|
129
|
+
},
|
|
130
|
+
{ label: "Unroll dynamic animations", skipReload: true },
|
|
131
|
+
);
|
|
132
|
+
return `${anim.targetSelector}-to-0`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
|
136
|
+
if (!runtime || runtime.keyframes.length === 0) return;
|
|
137
|
+
await commitMutation(
|
|
138
|
+
selection,
|
|
139
|
+
{
|
|
140
|
+
type: "materialize-keyframes",
|
|
141
|
+
animationId: anim.id,
|
|
142
|
+
keyframes: runtime.keyframes,
|
|
143
|
+
easeEach: runtime.easeEach,
|
|
144
|
+
},
|
|
145
|
+
{ label: "Materialize dynamic keyframes", skipReload: true },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── High-level intercept ───────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface GsapDragCommitCallbacks {
|
|
152
|
+
commitMutation: (
|
|
153
|
+
selection: DomEditSelection,
|
|
154
|
+
mutation: Record<string, unknown>,
|
|
155
|
+
options: {
|
|
156
|
+
label: string;
|
|
157
|
+
coalesceKey?: string;
|
|
158
|
+
softReload?: boolean;
|
|
159
|
+
skipReload?: boolean;
|
|
160
|
+
beforeReload?: () => void;
|
|
161
|
+
},
|
|
162
|
+
) => Promise<void>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Attempt to handle a drag commit via the GSAP script mutation path.
|
|
167
|
+
*
|
|
168
|
+
* Returns a Promise that resolves to true if the drag was handled via GSAP
|
|
169
|
+
* (caller should skip the CSS path), or false if no GSAP position animation
|
|
170
|
+
* exists. The promise resolves only AFTER the mutation has been persisted and
|
|
171
|
+
* the preview soft-reloaded — the CSS offset stays visible until then so the
|
|
172
|
+
* element doesn't snap back during the async gap.
|
|
173
|
+
*/
|
|
174
|
+
// fallow-ignore-next-line complexity
|
|
175
|
+
export async function tryGsapDragIntercept(
|
|
176
|
+
selection: DomEditSelection,
|
|
177
|
+
offset: { x: number; y: number },
|
|
178
|
+
animations: GsapAnimation[],
|
|
179
|
+
iframe: HTMLIFrameElement | null,
|
|
180
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
181
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
let posAnim = findGsapPositionAnimation(animations);
|
|
184
|
+
if (!posAnim && fetchFallbackAnimations) {
|
|
185
|
+
const fresh = await fetchFallbackAnimations();
|
|
186
|
+
posAnim = findGsapPositionAnimation(fresh);
|
|
187
|
+
}
|
|
188
|
+
if (!posAnim) return false;
|
|
189
|
+
|
|
190
|
+
const selector = selectorForSelection(selection);
|
|
191
|
+
if (!selector) return false;
|
|
192
|
+
|
|
193
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector);
|
|
194
|
+
if (!gsapPos) return false;
|
|
195
|
+
|
|
196
|
+
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
|
|
197
|
+
commitMutation,
|
|
198
|
+
});
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Commit helpers ─────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Compute the new GSAP position values from runtime-read positions + drag
|
|
206
|
+
* offset, then commit the mutation to the GSAP script.
|
|
207
|
+
*
|
|
208
|
+
* `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not
|
|
209
|
+
* from the DOM transform matrix. The strip in `applyStudioPathOffset` does
|
|
210
|
+
* not affect the cached values, so the formula is simply:
|
|
211
|
+
* newValue = cachedGsapValue + dragOffset
|
|
212
|
+
*
|
|
213
|
+
* For flat tweens (to/set), the mutation would change the tween endpoint,
|
|
214
|
+
* which is invisible at t=0. Instead, we convert to keyframes first so the
|
|
215
|
+
* position is set at the exact seek percentage via a keyframe.
|
|
216
|
+
*/
|
|
217
|
+
// fallow-ignore-next-line complexity
|
|
218
|
+
async function commitGsapPositionFromDrag(
|
|
219
|
+
selection: DomEditSelection,
|
|
220
|
+
anim: GsapAnimation,
|
|
221
|
+
studioOffset: { x: number; y: number },
|
|
222
|
+
gsapPos: { x: number; y: number },
|
|
223
|
+
iframe: HTMLIFrameElement | null,
|
|
224
|
+
selector: string,
|
|
225
|
+
callbacks: GsapDragCommitCallbacks,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
// CSS composition: translate → rotate → transform. The studioOffset is in
|
|
228
|
+
// pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate
|
|
229
|
+
// space (CSS transform). Counter-rotate the offset to match GSAP's frame.
|
|
230
|
+
const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
|
|
231
|
+
const rotDeg = Number.parseFloat(rotStyle) || 0;
|
|
232
|
+
const rad = (-rotDeg * Math.PI) / 180;
|
|
233
|
+
const cos = Math.cos(rad);
|
|
234
|
+
const sin = Math.sin(rad);
|
|
235
|
+
const adjX = studioOffset.x * cos - studioOffset.y * sin;
|
|
236
|
+
const adjY = studioOffset.x * sin + studioOffset.y * cos;
|
|
237
|
+
const newX = Math.round(gsapPos.x + adjX);
|
|
238
|
+
const newY = Math.round(gsapPos.y + adjY);
|
|
239
|
+
const clearOffset = () => clearStudioPathOffset(selection.element);
|
|
240
|
+
|
|
241
|
+
if (anim.keyframes) {
|
|
242
|
+
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
243
|
+
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
|
244
|
+
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
|
245
|
+
await commitKeyframedPosition(
|
|
246
|
+
selection,
|
|
247
|
+
effectiveAnim,
|
|
248
|
+
{ ...runtimeProps, x: newX, y: newY },
|
|
249
|
+
callbacks,
|
|
250
|
+
clearOffset,
|
|
251
|
+
);
|
|
252
|
+
} else if (anim.method === "from") {
|
|
253
|
+
await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
|
254
|
+
} else if (anim.method === "fromTo") {
|
|
255
|
+
await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
|
256
|
+
} else {
|
|
257
|
+
// Flat to()/set() — convert to keyframes first so the drag position
|
|
258
|
+
// is captured at the current seek time, not just the tween endpoint.
|
|
259
|
+
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
|
260
|
+
await commitFlatViaKeyframes(
|
|
261
|
+
selection,
|
|
262
|
+
anim,
|
|
263
|
+
{ ...runtimeProps, x: newX, y: newY },
|
|
264
|
+
callbacks,
|
|
265
|
+
clearOffset,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// fallow-ignore-next-line complexity
|
|
271
|
+
async function commitKeyframedPosition(
|
|
272
|
+
selection: DomEditSelection,
|
|
273
|
+
anim: GsapAnimation,
|
|
274
|
+
properties: Record<string, number>,
|
|
275
|
+
callbacks: GsapDragCommitCallbacks,
|
|
276
|
+
beforeReload: () => void,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
const pct = computeCurrentPercentage(selection);
|
|
279
|
+
|
|
280
|
+
await callbacks.commitMutation(
|
|
281
|
+
selection,
|
|
282
|
+
{
|
|
283
|
+
type: "add-keyframe",
|
|
284
|
+
animationId: anim.id,
|
|
285
|
+
percentage: pct,
|
|
286
|
+
properties,
|
|
287
|
+
},
|
|
288
|
+
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* For flat to()/set() tweens, convert to keyframes first so we can place the
|
|
294
|
+
* drag position at the current percentage. Without conversion, the mutation
|
|
295
|
+
* only changes the tween endpoint, which is invisible at t=0.
|
|
296
|
+
*/
|
|
297
|
+
// fallow-ignore-next-line complexity
|
|
298
|
+
async function commitFlatViaKeyframes(
|
|
299
|
+
selection: DomEditSelection,
|
|
300
|
+
anim: GsapAnimation,
|
|
301
|
+
properties: Record<string, number>,
|
|
302
|
+
callbacks: GsapDragCommitCallbacks,
|
|
303
|
+
beforeReload: () => void,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
await callbacks.commitMutation(
|
|
306
|
+
selection,
|
|
307
|
+
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
308
|
+
{ label: "Convert to keyframes for drag", skipReload: true },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const pct = computeCurrentPercentage(selection);
|
|
312
|
+
|
|
313
|
+
await callbacks.commitMutation(
|
|
314
|
+
selection,
|
|
315
|
+
{
|
|
316
|
+
type: "add-keyframe",
|
|
317
|
+
animationId: anim.id,
|
|
318
|
+
percentage: pct,
|
|
319
|
+
properties,
|
|
320
|
+
},
|
|
321
|
+
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function commitFromPosition(
|
|
326
|
+
selection: DomEditSelection,
|
|
327
|
+
anim: GsapAnimation,
|
|
328
|
+
delta: { x: number; y: number },
|
|
329
|
+
callbacks: GsapDragCommitCallbacks,
|
|
330
|
+
beforeReload: () => void,
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
|
|
333
|
+
const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
|
|
334
|
+
|
|
335
|
+
await callbacks.commitMutation(
|
|
336
|
+
selection,
|
|
337
|
+
{ type: "update-property", animationId: anim.id, property: "x", value: fromX },
|
|
338
|
+
{ label: "Move layer (GSAP from x)", skipReload: true },
|
|
339
|
+
);
|
|
340
|
+
await callbacks.commitMutation(
|
|
341
|
+
selection,
|
|
342
|
+
{ type: "update-property", animationId: anim.id, property: "y", value: fromY },
|
|
343
|
+
{ label: "Move layer (GSAP from y)", softReload: true, beforeReload },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// fallow-ignore-next-line complexity
|
|
348
|
+
async function commitFromToPosition(
|
|
349
|
+
selection: DomEditSelection,
|
|
350
|
+
anim: GsapAnimation,
|
|
351
|
+
delta: { x: number; y: number },
|
|
352
|
+
callbacks: GsapDragCommitCallbacks,
|
|
353
|
+
beforeReload: () => void,
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
if (anim.fromProperties) {
|
|
356
|
+
const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x);
|
|
357
|
+
const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y);
|
|
358
|
+
await callbacks.commitMutation(
|
|
359
|
+
selection,
|
|
360
|
+
{ type: "update-from-property", animationId: anim.id, property: "x", value: fromX },
|
|
361
|
+
{ label: "Move (GSAP from x)", skipReload: true },
|
|
362
|
+
);
|
|
363
|
+
await callbacks.commitMutation(
|
|
364
|
+
selection,
|
|
365
|
+
{ type: "update-from-property", animationId: anim.id, property: "y", value: fromY },
|
|
366
|
+
{ label: "Move (GSAP from y)", skipReload: true },
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
|
|
371
|
+
const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
|
|
372
|
+
await callbacks.commitMutation(
|
|
373
|
+
selection,
|
|
374
|
+
{ type: "update-property", animationId: anim.id, property: "x", value: toX },
|
|
375
|
+
{ label: "Move (GSAP to x)", skipReload: true },
|
|
376
|
+
);
|
|
377
|
+
await callbacks.commitMutation(
|
|
378
|
+
selection,
|
|
379
|
+
{ type: "update-property", animationId: anim.id, property: "y", value: toY },
|
|
380
|
+
{ label: "Move (GSAP to y)", softReload: true, beforeReload },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Runtime property reader ───────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
export function readGsapProperty(
|
|
387
|
+
iframe: HTMLIFrameElement | null,
|
|
388
|
+
selector: string | null,
|
|
389
|
+
prop: string,
|
|
390
|
+
): number | null {
|
|
391
|
+
if (!iframe?.contentWindow || !selector) return null;
|
|
392
|
+
try {
|
|
393
|
+
const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
|
|
394
|
+
if (!gsap?.getProperty) return null;
|
|
395
|
+
const el = iframe.contentDocument?.querySelector(selector);
|
|
396
|
+
if (!el) return null;
|
|
397
|
+
const val = Number(gsap.getProperty(el, prop));
|
|
398
|
+
return Number.isFinite(val) ? Math.round(val) : null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function readAllAnimatedProperties(
|
|
405
|
+
iframe: HTMLIFrameElement | null,
|
|
406
|
+
selector: string,
|
|
407
|
+
anim: GsapAnimation,
|
|
408
|
+
): Record<string, number> {
|
|
409
|
+
const result: Record<string, number> = {};
|
|
410
|
+
if (!iframe?.contentWindow) return result;
|
|
411
|
+
let gsap: IframeGsap | undefined;
|
|
412
|
+
try {
|
|
413
|
+
gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
|
|
414
|
+
} catch {
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
if (!gsap?.getProperty) return result;
|
|
418
|
+
let doc: Document | null = null;
|
|
419
|
+
try {
|
|
420
|
+
doc = iframe.contentDocument;
|
|
421
|
+
} catch {
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
const el = doc?.querySelector(selector);
|
|
425
|
+
if (!el) return result;
|
|
426
|
+
|
|
427
|
+
const propKeys = new Set<string>();
|
|
428
|
+
if (anim.keyframes) {
|
|
429
|
+
for (const kf of anim.keyframes.keyframes) {
|
|
430
|
+
for (const p of Object.keys(kf.properties)) {
|
|
431
|
+
if (typeof kf.properties[p] === "number") propKeys.add(p);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const prop of propKeys) {
|
|
439
|
+
const val = Number(gsap.getProperty(el, prop));
|
|
440
|
+
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
export async function tryGsapResizeIntercept(
|
|
448
|
+
selection: DomEditSelection,
|
|
449
|
+
size: { width: number; height: number },
|
|
450
|
+
animations: GsapAnimation[],
|
|
451
|
+
iframe: HTMLIFrameElement | null,
|
|
452
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
453
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
454
|
+
): Promise<boolean> {
|
|
455
|
+
let anim = animations.find(
|
|
456
|
+
(a) => "width" in a.properties || "height" in a.properties || a.keyframes,
|
|
457
|
+
);
|
|
458
|
+
if (!anim && fetchFallbackAnimations) {
|
|
459
|
+
const fresh = await fetchFallbackAnimations();
|
|
460
|
+
anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes);
|
|
461
|
+
}
|
|
462
|
+
if (!anim) return false;
|
|
463
|
+
|
|
464
|
+
const pct = computeCurrentPercentage(selection);
|
|
465
|
+
|
|
466
|
+
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
467
|
+
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
468
|
+
if (newId) anim = { ...anim, id: newId };
|
|
469
|
+
} else if (!anim.keyframes) {
|
|
470
|
+
await commitMutation(
|
|
471
|
+
selection,
|
|
472
|
+
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
473
|
+
{ label: "Convert to keyframes for resize", skipReload: true },
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const selector = selectorForSelection(selection);
|
|
478
|
+
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
479
|
+
|
|
480
|
+
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
|
481
|
+
if (!("width" in runtimeProps)) {
|
|
482
|
+
const cssW = readGsapProperty(iframe, selector, "width");
|
|
483
|
+
backfillDefaults.width = cssW ?? Math.round(size.width);
|
|
484
|
+
}
|
|
485
|
+
if (!("height" in runtimeProps)) {
|
|
486
|
+
const cssH = readGsapProperty(iframe, selector, "height");
|
|
487
|
+
backfillDefaults.height = cssH ?? Math.round(size.height);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const properties = {
|
|
491
|
+
...runtimeProps,
|
|
492
|
+
width: Math.round(size.width),
|
|
493
|
+
height: Math.round(size.height),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
await commitMutation(
|
|
497
|
+
selection,
|
|
498
|
+
{
|
|
499
|
+
type: "add-keyframe",
|
|
500
|
+
animationId: anim.id,
|
|
501
|
+
percentage: pct,
|
|
502
|
+
properties,
|
|
503
|
+
backfillDefaults,
|
|
504
|
+
},
|
|
505
|
+
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
506
|
+
);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── Rotation intercept ────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
export async function tryGsapRotationIntercept(
|
|
513
|
+
selection: DomEditSelection,
|
|
514
|
+
angle: number,
|
|
515
|
+
animations: GsapAnimation[],
|
|
516
|
+
iframe: HTMLIFrameElement | null,
|
|
517
|
+
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
518
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
519
|
+
): Promise<boolean> {
|
|
520
|
+
let anim = animations.find((a) => "rotation" in a.properties || a.keyframes);
|
|
521
|
+
if (!anim && fetchFallbackAnimations) {
|
|
522
|
+
const fresh = await fetchFallbackAnimations();
|
|
523
|
+
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes);
|
|
524
|
+
}
|
|
525
|
+
if (!anim) return false;
|
|
526
|
+
|
|
527
|
+
const selector = selectorForSelection(selection);
|
|
528
|
+
if (!selector) return false;
|
|
529
|
+
|
|
530
|
+
let gsapRotation = 0;
|
|
531
|
+
if (iframe?.contentWindow) {
|
|
532
|
+
try {
|
|
533
|
+
const gsap = (
|
|
534
|
+
iframe.contentWindow as unknown as {
|
|
535
|
+
gsap?: { getProperty: (el: Element, prop: string) => number };
|
|
536
|
+
}
|
|
537
|
+
).gsap;
|
|
538
|
+
const doc = iframe.contentDocument;
|
|
539
|
+
const el = doc?.querySelector(selector);
|
|
540
|
+
if (gsap?.getProperty && el) {
|
|
541
|
+
gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0;
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
/* cross-origin guard */
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const pct = computeCurrentPercentage(selection);
|
|
549
|
+
const newRotation = Math.round(gsapRotation + angle);
|
|
550
|
+
|
|
551
|
+
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
552
|
+
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
553
|
+
if (newId) anim = { ...anim, id: newId };
|
|
554
|
+
} else if (!anim.keyframes) {
|
|
555
|
+
await commitMutation(
|
|
556
|
+
selection,
|
|
557
|
+
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
558
|
+
{ label: "Convert to keyframes for rotation", skipReload: true },
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
|
563
|
+
|
|
564
|
+
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
|
565
|
+
if (!("rotation" in runtimeProps)) {
|
|
566
|
+
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const properties = { ...runtimeProps, rotation: newRotation };
|
|
570
|
+
|
|
571
|
+
await commitMutation(
|
|
572
|
+
selection,
|
|
573
|
+
{
|
|
574
|
+
type: "add-keyframe",
|
|
575
|
+
animationId: anim.id,
|
|
576
|
+
percentage: pct,
|
|
577
|
+
properties,
|
|
578
|
+
backfillDefaults,
|
|
579
|
+
},
|
|
580
|
+
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
|
581
|
+
);
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|