@hyperframes/studio 0.6.122-alpha.0 → 0.7.0
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-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
5
|
+
import { roundTo3 } from "../utils/rounding";
|
|
6
|
+
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
7
|
+
import {
|
|
8
|
+
type GsapDragCommitCallbacks,
|
|
9
|
+
computeCurrentPercentage,
|
|
10
|
+
parkPlayheadOnKeyframe,
|
|
11
|
+
materializeIfDynamic,
|
|
12
|
+
} from "./gsapDragCommit";
|
|
13
|
+
|
|
14
|
+
async function extendTweenAndAddKeyframe(
|
|
15
|
+
selection: DomEditSelection,
|
|
16
|
+
anim: GsapAnimation,
|
|
17
|
+
properties: Record<string, number>,
|
|
18
|
+
targetTime: number,
|
|
19
|
+
tweenStart: number,
|
|
20
|
+
tweenDuration: number,
|
|
21
|
+
callbacks: GsapDragCommitCallbacks,
|
|
22
|
+
beforeReload?: () => void,
|
|
23
|
+
backfillDefaults?: Record<string, number>,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const tweenEnd = tweenStart + tweenDuration;
|
|
26
|
+
const newStart = Math.min(targetTime, tweenStart);
|
|
27
|
+
const newEnd = Math.max(targetTime, tweenEnd);
|
|
28
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
29
|
+
const existingKfs = anim.keyframes?.keyframes ?? [];
|
|
30
|
+
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
31
|
+
[];
|
|
32
|
+
for (const kf of existingKfs) {
|
|
33
|
+
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
|
|
34
|
+
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
35
|
+
const props: Record<string, number | string> = { ...kf.properties };
|
|
36
|
+
for (const k of Object.keys(properties)) {
|
|
37
|
+
if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k];
|
|
38
|
+
}
|
|
39
|
+
remappedKfs.push({ percentage: newPct, properties: props });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
|
43
|
+
remappedKfs.push({ percentage: targetPct, properties });
|
|
44
|
+
|
|
45
|
+
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
|
46
|
+
|
|
47
|
+
await callbacks.commitMutation(
|
|
48
|
+
selection,
|
|
49
|
+
{
|
|
50
|
+
type: "replace-with-keyframes",
|
|
51
|
+
animationId: anim.id,
|
|
52
|
+
targetSelector: anim.targetSelector,
|
|
53
|
+
position: roundTo3(newStart),
|
|
54
|
+
duration: roundTo3(newDuration),
|
|
55
|
+
keyframes: remappedKfs,
|
|
56
|
+
},
|
|
57
|
+
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// fallow-ignore-next-line complexity
|
|
62
|
+
async function commitKeyframedPosition(
|
|
63
|
+
selection: DomEditSelection,
|
|
64
|
+
anim: GsapAnimation,
|
|
65
|
+
properties: Record<string, number>,
|
|
66
|
+
callbacks: GsapDragCommitCallbacks,
|
|
67
|
+
beforeReload?: () => void,
|
|
68
|
+
backfillDefaults?: Record<string, number>,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
71
|
+
const computedPct = computeCurrentPercentage(selection, anim);
|
|
72
|
+
const pct = activeKeyframePct ?? computedPct;
|
|
73
|
+
await callbacks.commitMutation(
|
|
74
|
+
selection,
|
|
75
|
+
{
|
|
76
|
+
type: "add-keyframe",
|
|
77
|
+
animationId: anim.id,
|
|
78
|
+
percentage: pct,
|
|
79
|
+
properties,
|
|
80
|
+
...(backfillDefaults ? { backfillDefaults } : {}),
|
|
81
|
+
},
|
|
82
|
+
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
|
83
|
+
);
|
|
84
|
+
if (activeKeyframePct != null) {
|
|
85
|
+
setActiveKeyframePct(null);
|
|
86
|
+
parkPlayheadOnKeyframe(anim, pct);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface DragRuntimeGsap {
|
|
91
|
+
getProperty: (target: Element, key: string) => unknown;
|
|
92
|
+
set: (target: Element, vars: Record<string, unknown>) => void;
|
|
93
|
+
}
|
|
94
|
+
interface DragRuntimeTimeline {
|
|
95
|
+
seek: (time: number) => void;
|
|
96
|
+
}
|
|
97
|
+
interface DragRuntime {
|
|
98
|
+
gsapLib: DragRuntimeGsap;
|
|
99
|
+
el: Element;
|
|
100
|
+
mainTl: DragRuntimeTimeline;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveDragRuntime(
|
|
104
|
+
iframe: HTMLIFrameElement | null | undefined,
|
|
105
|
+
selector: string | undefined,
|
|
106
|
+
): DragRuntime | null {
|
|
107
|
+
if (!iframe || !selector) return null;
|
|
108
|
+
const win = iframe.contentWindow as
|
|
109
|
+
| (Window & {
|
|
110
|
+
gsap?: Partial<DragRuntimeGsap>;
|
|
111
|
+
__timelines?: Record<string, Partial<DragRuntimeTimeline>>;
|
|
112
|
+
})
|
|
113
|
+
| null;
|
|
114
|
+
const gsap = win?.gsap;
|
|
115
|
+
if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null;
|
|
116
|
+
let el: Element | null = null;
|
|
117
|
+
try {
|
|
118
|
+
el = iframe.contentDocument?.querySelector(selector) ?? null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (!el) return null;
|
|
123
|
+
const timelines = win?.__timelines;
|
|
124
|
+
const mainTl = timelines ? Object.values(timelines)[0] : undefined;
|
|
125
|
+
if (typeof mainTl?.seek !== "function") return null;
|
|
126
|
+
return {
|
|
127
|
+
gsapLib: gsap as DragRuntimeGsap,
|
|
128
|
+
el,
|
|
129
|
+
mainTl: mainTl as DragRuntimeTimeline,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// fallow-ignore-next-line complexity
|
|
134
|
+
async function commitFlatViaKeyframes(
|
|
135
|
+
selection: DomEditSelection,
|
|
136
|
+
anim: GsapAnimation,
|
|
137
|
+
properties: Record<string, number>,
|
|
138
|
+
callbacks: GsapDragCommitCallbacks,
|
|
139
|
+
beforeReload?: () => void,
|
|
140
|
+
iframe?: HTMLIFrameElement | null,
|
|
141
|
+
selector?: string,
|
|
142
|
+
backfillDefaults?: Record<string, number>,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
145
|
+
const ts = resolveTweenStart(anim);
|
|
146
|
+
const td = resolveTweenDuration(anim);
|
|
147
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
148
|
+
const outsideRange =
|
|
149
|
+
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
150
|
+
|
|
151
|
+
const resolvedFromValues: Record<string, number | string> = {};
|
|
152
|
+
const runtime = resolveDragRuntime(iframe, selector);
|
|
153
|
+
if (runtime && ts !== null) {
|
|
154
|
+
const { gsapLib, el, mainTl } = runtime;
|
|
155
|
+
const draggedValues: Record<string, number> = {};
|
|
156
|
+
for (const key of Object.keys(properties)) {
|
|
157
|
+
const v = Number(gsapLib.getProperty(el, key));
|
|
158
|
+
if (Number.isFinite(v)) draggedValues[key] = v;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
gsapLib.set(el, { clearProps: Object.keys(properties).join(",") });
|
|
162
|
+
mainTl.seek(ts);
|
|
163
|
+
for (const key of Object.keys(properties)) {
|
|
164
|
+
const v = Number(gsapLib.getProperty(el, key));
|
|
165
|
+
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
|
|
166
|
+
}
|
|
167
|
+
mainTl.seek(ct);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn("[gsap-drag] start-value read failed; using identity from values", err);
|
|
170
|
+
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
|
|
171
|
+
} finally {
|
|
172
|
+
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (outsideRange && ts !== null) {
|
|
177
|
+
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
178
|
+
await callbacks.commitMutation(
|
|
179
|
+
selection,
|
|
180
|
+
{
|
|
181
|
+
type: "convert-to-keyframes",
|
|
182
|
+
animationId: anim.id,
|
|
183
|
+
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
|
|
184
|
+
},
|
|
185
|
+
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
186
|
+
);
|
|
187
|
+
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
|
188
|
+
const converted =
|
|
189
|
+
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
|
|
190
|
+
const convertedStart = resolveTweenStart(converted) ?? ts;
|
|
191
|
+
const convertedDur = resolveTweenDuration(converted) || td;
|
|
192
|
+
await extendTweenAndAddKeyframe(
|
|
193
|
+
selection,
|
|
194
|
+
converted,
|
|
195
|
+
properties,
|
|
196
|
+
ct,
|
|
197
|
+
convertedStart,
|
|
198
|
+
convertedDur,
|
|
199
|
+
callbacks,
|
|
200
|
+
beforeReload,
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
206
|
+
await callbacks.commitMutation(
|
|
207
|
+
selection,
|
|
208
|
+
{
|
|
209
|
+
type: "convert-to-keyframes",
|
|
210
|
+
animationId: anim.id,
|
|
211
|
+
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
|
|
212
|
+
},
|
|
213
|
+
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
|
214
|
+
);
|
|
215
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
216
|
+
const editedSelected = activeKeyframePct != null;
|
|
217
|
+
if (editedSelected) setActiveKeyframePct(null);
|
|
218
|
+
|
|
219
|
+
await callbacks.commitMutation(
|
|
220
|
+
selection,
|
|
221
|
+
{
|
|
222
|
+
type: "add-keyframe",
|
|
223
|
+
animationId: anim.id,
|
|
224
|
+
percentage: pct,
|
|
225
|
+
properties,
|
|
226
|
+
...(backfillDefaults ? { backfillDefaults } : {}),
|
|
227
|
+
},
|
|
228
|
+
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
|
|
229
|
+
);
|
|
230
|
+
if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// fallow-ignore-next-line complexity
|
|
234
|
+
export async function commitGsapPositionFromDrag(
|
|
235
|
+
selection: DomEditSelection,
|
|
236
|
+
anim: GsapAnimation,
|
|
237
|
+
studioOffset: { x: number; y: number },
|
|
238
|
+
gsapPos: { x: number; y: number },
|
|
239
|
+
iframe: HTMLIFrameElement | null,
|
|
240
|
+
selector: string,
|
|
241
|
+
callbacks: GsapDragCommitCallbacks,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
const el = selection.element;
|
|
244
|
+
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
245
|
+
el,
|
|
246
|
+
studioOffset,
|
|
247
|
+
gsapPos,
|
|
248
|
+
);
|
|
249
|
+
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
250
|
+
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
251
|
+
const restoreOffset = () => {
|
|
252
|
+
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
253
|
+
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
254
|
+
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
255
|
+
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
|
|
259
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
260
|
+
if (anim.keyframes) {
|
|
261
|
+
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
262
|
+
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
|
263
|
+
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
264
|
+
|
|
265
|
+
const ts = resolveTweenStart(effectiveAnim);
|
|
266
|
+
const td = resolveTweenDuration(effectiveAnim);
|
|
267
|
+
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
268
|
+
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
269
|
+
if (outsideRange && !hasSelectedKeyframe) {
|
|
270
|
+
await extendTweenAndAddKeyframe(
|
|
271
|
+
selection,
|
|
272
|
+
effectiveAnim,
|
|
273
|
+
dragProps,
|
|
274
|
+
ct,
|
|
275
|
+
ts,
|
|
276
|
+
td,
|
|
277
|
+
callbacks,
|
|
278
|
+
restoreOffset,
|
|
279
|
+
backfillDefaults,
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
await commitKeyframedPosition(
|
|
283
|
+
selection,
|
|
284
|
+
effectiveAnim,
|
|
285
|
+
dragProps,
|
|
286
|
+
callbacks,
|
|
287
|
+
restoreOffset,
|
|
288
|
+
backfillDefaults,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
} else if (anim.method === "from" || anim.method === "fromTo") {
|
|
292
|
+
const ct = usePlayerStore.getState().currentTime;
|
|
293
|
+
const ts = resolveTweenStart(anim);
|
|
294
|
+
const td = resolveTweenDuration(anim);
|
|
295
|
+
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
|
296
|
+
const outsideRange =
|
|
297
|
+
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
|
298
|
+
const dragProps: Record<string, number> = { x: newX, y: newY };
|
|
299
|
+
|
|
300
|
+
if (outsideRange && ts !== null) {
|
|
301
|
+
await callbacks.commitMutation(
|
|
302
|
+
selection,
|
|
303
|
+
{ type: "split-into-property-groups", animationId: anim.id },
|
|
304
|
+
{ label: "Split from() for drag", skipReload: true },
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
|
308
|
+
const existingPosAnim = allAnims.find(
|
|
309
|
+
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (existingPosAnim?.keyframes) {
|
|
313
|
+
const posTs = resolveTweenStart(existingPosAnim);
|
|
314
|
+
const posTd = resolveTweenDuration(existingPosAnim);
|
|
315
|
+
if (posTs !== null) {
|
|
316
|
+
await extendTweenAndAddKeyframe(
|
|
317
|
+
selection,
|
|
318
|
+
existingPosAnim,
|
|
319
|
+
{ x: newX, y: newY },
|
|
320
|
+
ct,
|
|
321
|
+
posTs,
|
|
322
|
+
posTd,
|
|
323
|
+
callbacks,
|
|
324
|
+
restoreOffset,
|
|
325
|
+
backfillDefaults,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const newStart = Math.min(ct, ts);
|
|
332
|
+
const newEnd = Math.max(ct, ts + td);
|
|
333
|
+
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
334
|
+
const dragBefore = ct < ts;
|
|
335
|
+
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
|
|
336
|
+
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
|
|
337
|
+
|
|
338
|
+
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
339
|
+
[];
|
|
340
|
+
if (dragBefore) {
|
|
341
|
+
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
|
|
342
|
+
if (origStartPct > 0.5 && origStartPct < 99.5) {
|
|
343
|
+
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
|
|
344
|
+
}
|
|
345
|
+
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
|
|
346
|
+
} else {
|
|
347
|
+
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
|
|
348
|
+
if (origEndPct > 0.5 && origEndPct < 99.5) {
|
|
349
|
+
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
|
|
350
|
+
}
|
|
351
|
+
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
|
|
352
|
+
}
|
|
353
|
+
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
354
|
+
|
|
355
|
+
const baseKf = {
|
|
356
|
+
targetSelector: anim.targetSelector,
|
|
357
|
+
position: roundTo3(newStart),
|
|
358
|
+
duration: roundTo3(newDuration),
|
|
359
|
+
keyframes,
|
|
360
|
+
};
|
|
361
|
+
await callbacks.commitMutation(
|
|
362
|
+
selection,
|
|
363
|
+
existingPosAnim
|
|
364
|
+
? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
|
|
365
|
+
: { type: "add-with-keyframes", ...baseKf },
|
|
366
|
+
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
370
|
+
await callbacks.commitMutation(
|
|
371
|
+
selection,
|
|
372
|
+
{
|
|
373
|
+
type: "convert-to-keyframes",
|
|
374
|
+
animationId: anim.id,
|
|
375
|
+
},
|
|
376
|
+
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
|
|
377
|
+
);
|
|
378
|
+
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
379
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
380
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
381
|
+
await callbacks.commitMutation(
|
|
382
|
+
selection,
|
|
383
|
+
{
|
|
384
|
+
type: "add-keyframe",
|
|
385
|
+
animationId: anim.id,
|
|
386
|
+
percentage: pct,
|
|
387
|
+
properties: dragProps,
|
|
388
|
+
...(backfillDefaults ? { backfillDefaults } : {}),
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
label: `Move layer (keyframe ${pct}%)`,
|
|
392
|
+
softReload: true,
|
|
393
|
+
beforeReload: restoreOffset,
|
|
394
|
+
coalesceKey,
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
await commitFlatViaKeyframes(
|
|
400
|
+
selection,
|
|
401
|
+
anim,
|
|
402
|
+
{ x: newX, y: newY },
|
|
403
|
+
callbacks,
|
|
404
|
+
restoreOffset,
|
|
405
|
+
iframe,
|
|
406
|
+
selector,
|
|
407
|
+
backfillDefaults,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression: `selectedGsapAnimations` (and the fetch fallback) is an async
|
|
8
|
+
* server-parse that LAGS a delete-all. A drag in that window would resolve a
|
|
9
|
+
* phantom position tween from the stale cache and re-commit it — resurrecting the
|
|
10
|
+
* just-deleted animation. tryGsapDragIntercept must trust the LIVE runtime: when
|
|
11
|
+
* the runtime has no keyframed/tweened position motion, the element is STATIC
|
|
12
|
+
* (single-source model), so the drag commits a position-hold `tl.set("#el",{x,y})`
|
|
13
|
+
* rather than re-committing the phantom tween. The stale `to` parse is ignored.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A preview iframe whose runtime timeline holds `children`, resolves the element,
|
|
17
|
+
// and exposes a gsap stub — so the drag can reach the commit path (the guard, not
|
|
18
|
+
// a missing gsap, must be what stops it).
|
|
19
|
+
function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
|
|
20
|
+
const timeline = { getChildren: () => children, duration: () => 14.6 };
|
|
21
|
+
const el = { id: elId };
|
|
22
|
+
return {
|
|
23
|
+
contentWindow: {
|
|
24
|
+
__timelines: { "index.html": timeline },
|
|
25
|
+
gsap: { getProperty: () => 0 },
|
|
26
|
+
},
|
|
27
|
+
contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) },
|
|
28
|
+
} as unknown as HTMLIFrameElement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A selection whose element answers the reads commitGsapPositionFromDrag makes —
|
|
32
|
+
// so without the guard the drag would reach commitMutation (resurrecting the tween).
|
|
33
|
+
const fakeElement = {
|
|
34
|
+
id: "puck-b",
|
|
35
|
+
style: { getPropertyValue: () => "" },
|
|
36
|
+
getAttribute: () => null,
|
|
37
|
+
getBoundingClientRect: () => ({ top: 100, left: 100, width: 50, height: 50 }),
|
|
38
|
+
} as unknown as HTMLElement;
|
|
39
|
+
|
|
40
|
+
const selection = {
|
|
41
|
+
id: "puck-b",
|
|
42
|
+
selector: "#puck-b",
|
|
43
|
+
element: fakeElement,
|
|
44
|
+
} as unknown as DomEditSelection;
|
|
45
|
+
|
|
46
|
+
// A stale parse-cache entry: a position tween the server still reports post-delete.
|
|
47
|
+
const stalePositionAnim = {
|
|
48
|
+
id: "#puck-b-to-1000-position",
|
|
49
|
+
targetSelector: "#puck-b",
|
|
50
|
+
propertyGroup: "position",
|
|
51
|
+
method: "to",
|
|
52
|
+
properties: { x: -180, y: -60 },
|
|
53
|
+
position: 1,
|
|
54
|
+
resolvedStart: 1,
|
|
55
|
+
duration: 2,
|
|
56
|
+
} as unknown as GsapAnimation;
|
|
57
|
+
|
|
58
|
+
afterEach(() => vi.restoreAllMocks());
|
|
59
|
+
|
|
60
|
+
describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => {
|
|
61
|
+
it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => {
|
|
62
|
+
const commitMutation = vi.fn();
|
|
63
|
+
// Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the
|
|
64
|
+
// element is treated as STATIC. The stale `to` parse must NOT be re-committed.
|
|
65
|
+
const iframe = fakeIframe("puck-b", []);
|
|
66
|
+
|
|
67
|
+
const handled = await tryGsapDragIntercept(
|
|
68
|
+
selection,
|
|
69
|
+
{ x: -50, y: 30 },
|
|
70
|
+
[stalePositionAnim],
|
|
71
|
+
iframe,
|
|
72
|
+
commitMutation,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(handled).toBe(true);
|
|
76
|
+
// No existing `set` for the selector → one `add` mutation with `method:"set"`.
|
|
77
|
+
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
78
|
+
const [, mutation] = commitMutation.mock.calls[0];
|
|
79
|
+
expect(mutation).toMatchObject({
|
|
80
|
+
type: "add",
|
|
81
|
+
method: "set",
|
|
82
|
+
targetSelector: "#puck-b",
|
|
83
|
+
position: 0,
|
|
84
|
+
});
|
|
85
|
+
// Drag delta (-50, 30) off a zero base → the committed set holds that position.
|
|
86
|
+
expect(mutation.properties).toEqual({ x: -50, y: 30 });
|
|
87
|
+
// It must NOT resurrect the stale tween via a tween/keyframe mutation.
|
|
88
|
+
expect(mutation.type).not.toBe("update-property");
|
|
89
|
+
expect(mutation.type).not.toBe("add-keyframe");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("forwards instantPatch on BOTH coalesced commits when updating an existing static set", async () => {
|
|
93
|
+
const commitMutation = vi.fn();
|
|
94
|
+
const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
|
|
95
|
+
// An existing position-hold `set` for the selector → update-in-place (not add).
|
|
96
|
+
const existingSet = {
|
|
97
|
+
id: "#puck-b-set",
|
|
98
|
+
targetSelector: "#puck-b",
|
|
99
|
+
method: "set",
|
|
100
|
+
// Tagged as a position group so resolveGroupTween returns it directly
|
|
101
|
+
// (no split commit), exercising the in-place update path cleanly.
|
|
102
|
+
propertyGroup: "position",
|
|
103
|
+
properties: { x: 0, y: 0 },
|
|
104
|
+
} as unknown as GsapAnimation;
|
|
105
|
+
|
|
106
|
+
const handled = await tryGsapDragIntercept(
|
|
107
|
+
selection,
|
|
108
|
+
{ x: -50, y: 30 },
|
|
109
|
+
[existingSet],
|
|
110
|
+
iframe,
|
|
111
|
+
commitMutation,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(handled).toBe(true);
|
|
115
|
+
// The coalesced update-property pair both carry an instantPatch so a partial
|
|
116
|
+
// (second-POST) failure still leaves the preview patched for what persisted:
|
|
117
|
+
// the x commit patches {x}, the final y commit patches the full {x,y}.
|
|
118
|
+
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-property");
|
|
119
|
+
expect(updates).toHaveLength(2);
|
|
120
|
+
expect(updates[0][2].instantPatch).toEqual({
|
|
121
|
+
selector: "#puck-b",
|
|
122
|
+
change: { kind: "set", props: { x: -50 } },
|
|
123
|
+
});
|
|
124
|
+
expect(updates[1][2].instantPatch).toEqual({
|
|
125
|
+
selector: "#puck-b",
|
|
126
|
+
change: { kind: "set", props: { x: -50, y: 30 } },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not trip the stale-parse guard when the runtime still has the tween", async () => {
|
|
131
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
132
|
+
const liveTween = {
|
|
133
|
+
targets: () => [{ id: "puck-b" }],
|
|
134
|
+
vars: { x: -120, y: 40, duration: 1 },
|
|
135
|
+
duration: () => 1,
|
|
136
|
+
startTime: () => 1,
|
|
137
|
+
};
|
|
138
|
+
// No fake gsap → it returns false later (at the gsapPos read), but the point
|
|
139
|
+
// is the stale-parse guard must NOT be the reason.
|
|
140
|
+
const iframe = fakeIframe("puck-b", [liveTween]);
|
|
141
|
+
|
|
142
|
+
await tryGsapDragIntercept(selection, { x: -50, y: 30 }, [stalePositionAnim], iframe, vi.fn());
|
|
143
|
+
|
|
144
|
+
const staleLogged = logSpy.mock.calls.some((c) => String(c[1] ?? "").includes("stale parse"));
|
|
145
|
+
expect(staleLogged).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|