Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
readGsapBorderRadiusForPanel,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
Expand Down Expand Up @@ -159,7 +158,66 @@ export const PropertyPanel = memo(function PropertyPanel({
? manualSize.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);

const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
onAddKeyframe(gsapAnimId, pct, axis, parsed);
return;
}
if (hasGsapAnimation) {
showToast?.("Cannot edit position — animation callbacks not available");
return;
}
const current = readStudioPathOffset(element.element);
void Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => undefined);
};

// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
showToast?.("Cannot edit size — animation callbacks not available");
return;
}
const current = readStudioBoxSize(element.element);
const width =
current.width > 0
? current.width
: (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
const height =
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
void Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => undefined);
};

const manualRotation = readStudioRotation(element.element);
const commitManualRotation = (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
};

const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
Expand All @@ -169,21 +227,6 @@ export const PropertyPanel = memo(function PropertyPanel({
const gsapKeyframes = gsapKfAnim?.keyframes?.keyframes ?? null;
const gsapAnimId = gsapKfAnim?.id ?? gsapAnimations?.[0]?.id ?? null;
const hasGsapAnimation = !!(gsapAnimId || gsapAnimations.length > 0);
const { commitManualOffset, commitManualSize, commitManualRotation } =
createTransformCommitHandlers({
element,
styles,
hasGsapAnimation,
gsapAnimId,
gsapKeyframes,
currentPct,
onCommitAnimatedProperty,
onAddKeyframe,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
showToast,
});
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);

Expand Down
33 changes: 33 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,38 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
return data.content;
}

/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Used when dragging or left-edge-resizing a clip so animations stay in sync.
* Operates on the raw HTML string and returns the modified HTML, or the
* original if no GSAP script or no matching animations are found.
*/
export function shiftGsapPositionsInHtml(html: string, elementId: string, delta: number): string {
if (delta === 0 || !elementId) return html;

const scriptMatch = html.match(
/(<script[^>]*>)([\s\S]*?)(gsap\.timeline[\s\S]*?)(window\.__timelines[\s\S]*?<\/script>)/,
);
if (!scriptMatch) return html;

const selector = `#${elementId}`;
const roundTo3 = (n: number) => Math.round(n * 1000) / 1000;

const fullScript = scriptMatch[0];
const shifted = fullScript.replace(
/\b(tl\.(?:to|from|fromTo|set))\(\s*"(#[\w-]+)"([\s\S]*?),\s*([0-9.]+)\s*\)/g,
(match, method, target, middle, posStr) => {
if (target !== selector) return match;
const pos = parseFloat(posStr);
if (!Number.isFinite(pos)) return match;
const newPos = roundTo3(Math.max(0, pos + delta));
return `${method}("${target}"${middle}, ${newPos})`;
},
);

if (shifted === fullScript) return html;
return html.replace(fullScript, shifted);
}

// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
12 changes: 11 additions & 1 deletion packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
readFileContent,
applyPatchByTarget,
formatTimelineAttributeNumber,
shiftGsapPositionsInHtml,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";

Expand Down Expand Up @@ -122,17 +123,22 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-track-index", String(updates.track)],
]);
const delta = updates.start - element.start;
return enqueueEdit(element, "Move timeline clip", (original, target) => {
let patched = applyPatchByTarget(original, target, {
type: "attribute",
property: "start",
value: formatTimelineAttributeNumber(updates.start),
});
return applyPatchByTarget(patched, target, {
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: "track-index",
value: String(updates.track),
});
if (delta !== 0 && element.domId) {
patched = shiftGsapPositionsInHtml(patched, element.domId, delta);
}
return patched;
});
},
[previewIframeRef, enqueueEdit],
Expand All @@ -147,6 +153,7 @@ export function useTimelineEditing({
["data-start", formatTimelineAttributeNumber(updates.start)],
["data-duration", formatTimelineAttributeNumber(updates.duration)],
]);
const startDelta = updates.start - element.start;
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
const pbs = resolveResizePlaybackStart(original, target, element, updates);
let patched = applyPatchByTarget(original, target, {
Expand All @@ -166,6 +173,9 @@ export function useTimelineEditing({
value: formatTimelineAttributeNumber(pbs.value),
});
}
if (startDelta !== 0 && element.domId) {
patched = shiftGsapPositionsInHtml(patched, element.domId, startDelta);
}
return patched;
});
},
Expand Down
Loading