Skip to content

Commit 6d4a4a6

Browse files
feat(studio): shift GSAP animation positions on clip drag and resize
When a clip is moved or left-edge-resized on the timeline, all GSAP animation positions targeting that element now shift by the time delta. Uses the AST-based parser (parseGsapScript + updateAnimationInScript) via a new shift-positions mutation type on the server, keeping positions absolute and the script as the single source of truth. - Add shift-positions GSAP mutation type to the studio API - Wire handleTimelineElementMove and handleTimelineElementResize to call the shift mutation after persisting the data-start change - Add elementStart parameter to globalTimeCompiler.resolveTweenStart for future-proof absolute-to-clip-local conversion Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent e6da47d commit 6d4a4a6

6 files changed

Lines changed: 124 additions & 25 deletions

File tree

packages/core/src/studio-api/routes/files.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,11 @@ type GsapMutationRequest =
466466
| {
467467
type: "delete-all-for-selector";
468468
targetSelector: string;
469+
}
470+
| {
471+
type: "shift-positions";
472+
targetSelector: string;
473+
delta: number;
469474
};
470475

471476
// ── GSAP mutation executor ──────────────────────────────────────────────────
@@ -715,6 +720,19 @@ async function executeGsapMutation(
715720
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
716721
return result.script;
717722
}
723+
case "shift-positions": {
724+
const { targetSelector, delta } = body;
725+
if (!targetSelector || typeof delta !== "number" || delta === 0) return block.scriptText;
726+
const parsed = parseGsapScript(block.scriptText);
727+
let script = block.scriptText;
728+
for (const anim of parsed.animations) {
729+
if (anim.targetSelector !== targetSelector) continue;
730+
if (typeof anim.position !== "number") continue;
731+
const newPos = Math.max(0, Math.round((anim.position + delta) * 1000) / 1000);
732+
script = updateAnimationInScript(script, anim.id, { position: newPos });
733+
}
734+
return script;
735+
}
718736
default:
719737
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
720738
}

packages/studio/src/components/editor/GestureTrailOverlay.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
66
sampleCount?: number;
77
trail?: Array<{ x: number; y: number }>;
88
simplifiedPoints?: Map<number, Record<string, number>>;
9-
canvasRect: { left: number; top: number; width: number; height: number };
9+
canvasRect: { left: number; top: number; width: number; height: number } | null;
1010
compositionSize?: { width: number; height: number };
1111
mode: "recording" | "preview";
1212
accentColor?: string;
@@ -22,6 +22,8 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
2222
mode,
2323
accentColor = "#3CE6AC",
2424
}: GestureTrailOverlayProps) {
25+
if (!canvasRect) return null;
26+
2527
const trailPoints = useMemo(() => {
2628
if (trail && trail.length > 1) {
2729
return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
readGsapBorderRadiusForPanel,
1212
} from "./propertyPanelHelpers";
1313
import { MetricField, Section } from "./propertyPanelPrimitives";
14-
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
1514
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
1615
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
1716
import { TextSection, StyleSections } from "./propertyPanelSections";
@@ -159,7 +158,66 @@ export const PropertyPanel = memo(function PropertyPanel({
159158
? manualSize.height
160159
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
161160

161+
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
162+
const parsed = parsePxMetricValue(nextValue);
163+
if (parsed == null) return;
164+
if (onCommitAnimatedProperty && hasGsapAnimation) {
165+
void onCommitAnimatedProperty(element, axis, parsed);
166+
return;
167+
}
168+
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
169+
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
170+
onAddKeyframe(gsapAnimId, pct, axis, parsed);
171+
return;
172+
}
173+
if (hasGsapAnimation) {
174+
showToast?.("Cannot edit position — animation callbacks not available");
175+
return;
176+
}
177+
const current = readStudioPathOffset(element.element);
178+
void Promise.resolve(
179+
onSetManualOffset(element, {
180+
x: axis === "x" ? parsed : current.x,
181+
y: axis === "y" ? parsed : current.y,
182+
}),
183+
).catch(() => undefined);
184+
};
185+
186+
// fallow-ignore-next-line complexity
187+
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
188+
const parsed = parsePxMetricValue(nextValue);
189+
if (parsed == null || parsed <= 0) return;
190+
if (onCommitAnimatedProperty && hasGsapAnimation) {
191+
void onCommitAnimatedProperty(element, axis, parsed);
192+
return;
193+
}
194+
if (hasGsapAnimation) {
195+
showToast?.("Cannot edit size — animation callbacks not available");
196+
return;
197+
}
198+
const current = readStudioBoxSize(element.element);
199+
const width =
200+
current.width > 0
201+
? current.width
202+
: (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
203+
const height =
204+
current.height > 0
205+
? current.height
206+
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
207+
void Promise.resolve(
208+
onSetManualSize(element, {
209+
width: axis === "width" ? parsed : width,
210+
height: axis === "height" ? parsed : height,
211+
}),
212+
).catch(() => undefined);
213+
};
214+
162215
const manualRotation = readStudioRotation(element.element);
216+
const commitManualRotation = (nextValue: string) => {
217+
const parsed = Number.parseFloat(nextValue);
218+
if (!Number.isFinite(parsed)) return;
219+
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
220+
};
163221

164222
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
165223
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
@@ -169,21 +227,6 @@ export const PropertyPanel = memo(function PropertyPanel({
169227
const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null;
170228
const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null;
171229
const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0);
172-
const { commitManualOffset, commitManualSize, commitManualRotation } =
173-
createTransformCommitHandlers({
174-
element,
175-
styles,
176-
hasGsapAnimation,
177-
gsapAnimId,
178-
gsapKeyframes,
179-
currentPct,
180-
onCommitAnimatedProperty,
181-
onAddKeyframe,
182-
onSetManualOffset,
183-
onSetManualSize,
184-
onSetManualRotation,
185-
showToast,
186-
});
187230
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
188231
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
189232

packages/studio/src/hooks/timelineEditingHelpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,34 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
144144
return data.content;
145145
}
146146

147+
/**
148+
* Shift all GSAP animation positions targeting a given element by a time delta.
149+
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
150+
*/
151+
export async function shiftGsapPositions(
152+
projectId: string,
153+
filePath: string,
154+
elementId: string,
155+
delta: number,
156+
): Promise<void> {
157+
if (delta === 0 || !elementId) return;
158+
const res = await fetch(
159+
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
160+
{
161+
method: "POST",
162+
headers: { "Content-Type": "application/json" },
163+
body: JSON.stringify({
164+
type: "shift-positions",
165+
targetSelector: `#${elementId}`,
166+
delta,
167+
}),
168+
},
169+
);
170+
if (!res.ok) {
171+
const err = await res.json().catch(() => null);
172+
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
173+
}
174+
}
175+
147176
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
148177
export { applyPatchByTarget, formatTimelineAttributeNumber };

packages/studio/src/hooks/useTimelineEditing.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
readFileContent,
2727
applyPatchByTarget,
2828
formatTimelineAttributeNumber,
29+
shiftGsapPositions,
2930
} from "./timelineEditingHelpers";
3031
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
3132

@@ -122,6 +123,8 @@ export function useTimelineEditing({
122123
["data-start", formatTimelineAttributeNumber(updates.start)],
123124
["data-track-index", String(updates.track)],
124125
]);
126+
const delta = updates.start - element.start;
127+
const filePath = element.sourceFile || activeCompPath || "index.html";
125128
return enqueueEdit(element, "Move timeline clip", (original, target) => {
126129
let patched = applyPatchByTarget(original, target, {
127130
type: "attribute",
@@ -133,9 +136,16 @@ export function useTimelineEditing({
133136
property: "track-index",
134137
value: String(updates.track),
135138
});
139+
}).then(() => {
140+
const pid = projectIdRef.current;
141+
if (delta !== 0 && element.domId && pid) {
142+
return shiftGsapPositions(pid, filePath, element.domId, delta)
143+
.then(() => reloadPreview())
144+
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
145+
}
136146
});
137147
},
138-
[previewIframeRef, enqueueEdit],
148+
[previewIframeRef, enqueueEdit, activeCompPath, reloadPreview],
139149
);
140150

141151
const handleTimelineElementResize = useCallback(
@@ -147,9 +157,6 @@ export function useTimelineEditing({
147157
["data-start", formatTimelineAttributeNumber(updates.start)],
148158
["data-duration", formatTimelineAttributeNumber(updates.duration)],
149159
];
150-
// A start-edge trim advances the media-start offset (skips into the
151-
// source). Patch it live too — otherwise the iframe keeps the old offset
152-
// and the clip only repositions instead of trimming the audio.
153160
if (updates.playbackStart != null) {
154161
const liveAttr =
155162
element.playbackStartAttr === "playback-start"

packages/studio/src/utils/globalTimeCompiler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ export function isTimeWithinTween(
2626
return time >= tweenStart && time <= tweenStart + tweenDuration;
2727
}
2828

29-
export function resolveTweenStart(animation: GsapAnimation): number | null {
30-
if (animation.resolvedStart != null) return animation.resolvedStart;
31-
if (typeof animation.position === "number") return animation.position;
29+
export function resolveTweenStart(animation: GsapAnimation, elementStart = 0): number | null {
30+
if (animation.resolvedStart != null) return elementStart + animation.resolvedStart;
31+
if (typeof animation.position === "number") return elementStart + animation.position;
3232
const parsed = Number.parseFloat(animation.position as string);
33-
if (!Number.isNaN(parsed)) return parsed;
33+
if (!Number.isNaN(parsed)) return elementStart + parsed;
3434
return null;
3535
}
3636

0 commit comments

Comments
 (0)