Skip to content
Merged
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
66 changes: 42 additions & 24 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { useDomEditContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { fetchParsedAnimations } from "../hooks/useGsapTweenCache";
import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove";
import type { GestureRecordingState } from "./editor/GestureRecordControl";

export interface StudioPreviewAreaProps {
Expand Down Expand Up @@ -128,6 +130,7 @@ export function StudioPreviewArea({
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapDeleteAllForElement,
buildDomSelectionForTimelineElement,
} = useDomEditContext();

const [snapPrefs, setSnapPrefs] = useState(() => {
Expand Down Expand Up @@ -169,31 +172,43 @@ export function StudioPreviewArea({
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
}
},
onMoveKeyframe: (_el: TimelineElement, oldPct: number, newPct: number) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
// fallow-ignore-next-line complexity
onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => {
// Resolve the dragged element's selection + parsed animations on demand
// (both awaited and cached) rather than relying on the async DOM-edit
// session being loaded for this element — that coupling made the commit
// intermittently no-op (revert) when dragging before the session caught up.
if (!projectId) return;
const sourceFile = _el.sourceFile || activeCompPath || "index.html";
const [selection, parsed] = await Promise.all([
buildDomSelectionForTimelineElement(_el),
fetchParsedAnimations(projectId, sourceFile),
]);
if (!selection || !parsed) return;

const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id);
const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
const group = cachedKf?.propertyGroup;
const anim =
(group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
const kf = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - tweenOldPct) < 0.2);
if (!kf) return;
const tweenStart = anim.resolvedStart ?? 0;
const tweenDur = anim.duration ?? 1;
const newAbsTime = _el.start + (newPct / 100) * _el.duration;
const tweenNewPct =
tweenDur > 0
? Math.max(
0,
Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
)
: 0;
handleGsapRemoveKeyframe(anim.id, tweenOldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
const origAbsTime = _el.start + (oldPct / 100) * _el.duration;
const anim = pickKeyframeTween(
parsed.animations,
_el,
origAbsTime,
cachedKf?.propertyGroup,
);
if (!anim) return;

const plan = computeKeyframeMovePlan(
anim,
cachedKf?.tweenPercentage ?? oldPct,
_el,
newPct,
);
if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection);
for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection);
for (const add of plan.adds) {
for (const [prop, val] of Object.entries(add.properties)) {
handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection);
}
}
},
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
Expand Down Expand Up @@ -231,6 +246,9 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
buildDomSelectionForTimelineElement,
projectId,
activeCompPath,
],
);

Expand Down
101 changes: 101 additions & 0 deletions packages/studio/src/components/editor/keyframeMove.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove";

const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({
id,
targetSelector: target,
position,
duration,
resolvedStart: position,
propertyGroup: group,
});

const el = { start: 0, duration: 10, domId: "box", selector: "#box" };

describe("pickKeyframeTween", () => {
it("matches by the element's selector", () => {
const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)];
expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b");
});

it("prefers the dragged keyframe's property group", () => {
const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")];
expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis");
});

it("among same-group tweens picks the one whose window contains the original time", () => {
const fadeIn = flat("in", "#box", 1, 1, "visual");
const fadeOut = flat("out", "#box", 8, 1, "visual");
expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out");
expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in");
});

it("returns undefined when there are no tweens", () => {
expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined();
});

it("returns undefined rather than editing another element on a selector mismatch", () => {
const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)];
expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined();
});
});

describe("computeKeyframeMovePlan — flat tween", () => {
const anim = flat("t", "#box", 2, 4); // window [2, 6]

it("start point trims the front, keeping the end fixed", () => {
// newPct 30% → abs 3 → start moves to 3, duration shrinks to 3.
const plan = computeKeyframeMovePlan(anim, 0, el, 30);
expect(plan.meta).toEqual({ position: 3, duration: 3 });
expect(plan.removes).toEqual([]);
});

it("end point resizes, keeping the start", () => {
// tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged.
const plan = computeKeyframeMovePlan(anim, 100, el, 80);
expect(plan.meta).toEqual({ position: 2, duration: 6 });
});
});

describe("computeKeyframeMovePlan — keyframe-array tween", () => {
const anim = {
id: "k",
targetSelector: "#box",
position: 0,
duration: 10,
resolvedStart: 0,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 50 } },
{ percentage: 100, properties: { x: 100 } },
],
},
};

it("moves an intermediate keyframe without touching the tween or others", () => {
// mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween.
const plan = computeKeyframeMovePlan(anim, 50, el, 70);
expect(plan.meta).toBeUndefined();
expect(plan.removes).toEqual([50]);
expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]);
});

it("start move remaps intermediates to preserve their absolute times", () => {
// start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe
// was at abs 5 → now (5-2)/8 = 37.5%.
const plan = computeKeyframeMovePlan(anim, 0, el, 20);
expect(plan.meta).toEqual({ position: 2, duration: 8 });
expect(plan.removes).toContain(50);
const mid = plan.adds.find((a) => a.properties.x === 50);
expect(mid?.pct).toBeCloseTo(37.5, 1);
});

it("is a no-op when the dragged keyframe can't be located (stale cache)", () => {
// tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween.
const plan = computeKeyframeMovePlan(anim, 33, el, 70);
expect(plan.meta).toBeUndefined();
expect(plan.removes).toEqual([]);
expect(plan.adds).toEqual([]);
});
});
151 changes: 151 additions & 0 deletions packages/studio/src/components/editor/keyframeMove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Pure helpers for committing a keyframe-diamond drag: pick the tween the
* dragged keyframe belongs to, and compute the GSAP mutations (tween
* position/duration and/or keyframe add/remove) for the move. Kept free of
* React/store so the timeline drag handler stays a thin orchestrator.
*/

interface TweenLike {
id: string;
targetSelector: string;
position: number | string;
duration?: number;
resolvedStart?: number;
propertyGroup?: string;
keyframes?: { keyframes: { percentage: number; properties: Record<string, number | string> }[] };
}

interface ElementWindow {
start: number;
duration: number;
domId?: string;
selector?: string;
}

export interface KeyframeMovePlan {
/** Tween timing change (start/end point drags). */
meta?: { position: number; duration: number };
/** Keyframe percentages to remove, then re-add (intermediate move / remap). */
removes: number[];
adds: { pct: number; properties: Record<string, number | string> }[];
}

const round3 = (n: number) => Math.round(n * 1000) / 1000;
const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100));
const MIN_DUR = 0.05;

function tweenWindow(a: TweenLike): { start: number; dur: number } {
return {
start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0),
dur: a.duration ?? 0,
};
}

type Kf = { percentage: number; properties: Record<string, number | string> };

/**
* Remap every keyframe except `keepIdx` from the old tween window to the new one
* so their absolute times stay fixed after a start/end resize. Returns the
* remove/add ops (empty for flat tweens, which have no intermediates).
*/
function remapKeyframes(
kfs: Kf[],
keepIdx: number,
oldStart: number,
oldDur: number,
newStart: number,
newDur: number,
): Pick<KeyframeMovePlan, "removes" | "adds"> {
const removes: number[] = [];
const adds: KeyframeMovePlan["adds"] = [];
if (newDur <= 0) return { removes, adds };
for (let i = 0; i < kfs.length; i++) {
if (i === keepIdx) continue;
const k = kfs[i]!;
const absT = oldStart + (k.percentage / 100) * oldDur;
const remapped = clampPct(((absT - newStart) / newDur) * 100);
if (Math.abs(remapped - k.percentage) < 0.05) continue;
removes.push(k.percentage);
adds.push({ pct: remapped, properties: k.properties });
}
return { removes, adds };
}

/**
* Pick the tween the dragged keyframe belongs to: restrict to the element's
* selector and (if known) the keyframe's property group, then choose the one
* whose time window contains — or is nearest — the keyframe's original time.
* An element can have several tweens in one group (e.g. fade-in + fade-out).
*/
export function pickKeyframeTween<T extends TweenLike>(
anims: T[],
el: ElementWindow,
origAbsTime: number,
group: string | undefined,
): T | undefined {
const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean);
const forEl = anims.filter((a) => selectors.includes(a.targetSelector));
// Only ever pick among THIS element's tweens. Don't fall back to all
// animations — a selector mismatch (e.g. a class/compound-selector tween)
// would otherwise edit a different element's keyframes. No match → no-op.
if (forEl.length === 0) return undefined;
const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : [];
const candidates = groupPool.length > 0 ? groupPool : forEl;
const dist = (a: T): number => {
const { start, dur } = tweenWindow(a);
if (origAbsTime >= start && origAbsTime <= start + dur) return 0;
return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur)));
};
return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!);
}

/**
* Compute the mutations for moving a keyframe to `newPct` (clip-relative):
* - start point → trim front (position moves, end fixed),
* - end point → resize (duration changes, start fixed),
* - intermediate → move only that keyframe; start/end moves remap the other
* keyframes so their absolute times stay put.
*/
// fallow-ignore-next-line complexity
export function computeKeyframeMovePlan(
anim: TweenLike,
tweenOldPct: number,
el: ElementWindow,
newPct: number,
): KeyframeMovePlan {
const newAbsTime = el.start + (newPct / 100) * el.duration;
const tweenStart = tweenWindow(anim).start;
const tweenDur = anim.duration ?? el.duration;
const kfs = anim.keyframes
? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage)
: null;
const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1;

// Keyframe-array tween but the dragged keyframe couldn't be located (stale
// cache / precision drift): no-op rather than falling through to an end-point
// resize that would silently rescale the whole tween and re-time every key.
if (kfs && idx === -1) return { removes: [], adds: [] };

if (kfs && idx > 0 && idx < kfs.length - 1) {
const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0;
return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] };
}

const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50;
let newStart = tweenStart;
let newDur = tweenDur;
if (isStartPoint) {
const end = tweenStart + tweenDur;
newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR));
newDur = end - newStart;
} else {
newDur = Math.max(MIN_DUR, newAbsTime - tweenStart);
}

const windowChanged = newStart !== tweenStart || newDur !== tweenDur;
const remap =
kfs && windowChanged
? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur)
: { removes: [], adds: [] };
return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap };
}
Loading
Loading