Skip to content

Commit bcc0c29

Browse files
fix(studio): harden keyframe drag commit (review follow-ups)
- pickKeyframeTween no longer falls back to ALL animations on a selector mismatch — it only picks among the dragged element's own tweens, so a class/compound-selector mismatch can't edit a different element. No match → no-op. - computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's dragged keyframe can't be located (stale cache / precision drift) instead of falling through to an end-point resize that silently rescaled the whole tween and re-timed every keyframe. - usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision (matching useGsapAnimationsForElement) so beat-snapped keyframes from the file-wide cache also center on the dot and the two caches agree. - The optimistic drag hold only releases once the cache reflects the committed position (a keyframe near the held %), so an unrelated cache rebuild no longer flashes the diamond back to its old spot. - A drag's document listeners are cleaned up on unmount, so an unmount mid-drag (clip delete / comp switch / zoom-out) no longer leaks them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 1cd55e6 commit bcc0c29

4 files changed

Lines changed: 56 additions & 6 deletions

File tree

packages/studio/src/components/editor/keyframeMove.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ describe("pickKeyframeTween", () => {
3333
it("returns undefined when there are no tweens", () => {
3434
expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined();
3535
});
36+
37+
it("returns undefined rather than editing another element on a selector mismatch", () => {
38+
const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)];
39+
expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined();
40+
});
3641
});
3742

3843
describe("computeKeyframeMovePlan — flat tween", () => {
@@ -85,4 +90,12 @@ describe("computeKeyframeMovePlan — keyframe-array tween", () => {
8590
const mid = plan.adds.find((a) => a.properties.x === 50);
8691
expect(mid?.pct).toBeCloseTo(37.5, 1);
8792
});
93+
94+
it("is a no-op when the dragged keyframe can't be located (stale cache)", () => {
95+
// tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween.
96+
const plan = computeKeyframeMovePlan(anim, 33, el, 70);
97+
expect(plan.meta).toBeUndefined();
98+
expect(plan.removes).toEqual([]);
99+
expect(plan.adds).toEqual([]);
100+
});
88101
});

packages/studio/src/components/editor/keyframeMove.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,12 @@ export function pickKeyframeTween<T extends TweenLike>(
8585
): T | undefined {
8686
const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean);
8787
const forEl = anims.filter((a) => selectors.includes(a.targetSelector));
88-
const pool = forEl.length > 0 ? forEl : anims;
89-
const groupPool = group ? pool.filter((a) => a.propertyGroup === group) : [];
90-
const candidates = groupPool.length > 0 ? groupPool : pool;
91-
if (candidates.length === 0) return undefined;
88+
// Only ever pick among THIS element's tweens. Don't fall back to all
89+
// animations — a selector mismatch (e.g. a class/compound-selector tween)
90+
// would otherwise edit a different element's keyframes. No match → no-op.
91+
if (forEl.length === 0) return undefined;
92+
const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : [];
93+
const candidates = groupPool.length > 0 ? groupPool : forEl;
9294
const dist = (a: T): number => {
9395
const { start, dur } = tweenWindow(a);
9496
if (origAbsTime >= start && origAbsTime <= start + dur) return 0;
@@ -119,6 +121,11 @@ export function computeKeyframeMovePlan(
119121
: null;
120122
const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1;
121123

124+
// Keyframe-array tween but the dragged keyframe couldn't be located (stale
125+
// cache / precision drift): no-op rather than falling through to an end-point
126+
// resize that would silently rescale the whole tween and re-time every key.
127+
if (kfs && idx === -1) return { removes: [], adds: [] };
128+
122129
if (kfs && idx > 0 && idx < kfs.length - 1) {
123130
const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0;
124131
return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] };

packages/studio/src/hooks/useGsapTweenCache.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,9 +383,12 @@ export function usePopulateKeyframeCacheForFile(
383383
const elDuration = timelineEl?.duration ?? 1;
384384
const clipKeyframes = kfData.keyframes.map((kf) => {
385385
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
386+
// 0.001% precision (matching useGsapAnimationsForElement above) so a
387+
// beat-snapped keyframe centers exactly on the beat dot and the two
388+
// caches agree on a keyframe's percentage.
386389
const clipPct =
387390
elDuration > 0
388-
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
391+
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
389392
: kf.percentage;
390393
return {
391394
...kf,

packages/studio/src/player/components/TimelineClipDiamonds.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,35 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
6666
// until the cache reflects the change (the file round-trip rewrites
6767
// keyframesData), so it doesn't flash back to the old spot in between.
6868
const pendingRef = useRef(false);
69+
const pendingHeldPctRef = useRef<number | null>(null);
6970
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
71+
// Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
72+
// (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
73+
const dragCleanupRef = useRef<(() => void) | null>(null);
7074

7175
useEffect(() => {
7276
if (!pendingRef.current) return;
77+
// Only release the optimistic hold once the cache actually reflects the
78+
// committed position (a keyframe near the held %). An unrelated cache
79+
// rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
80+
// percentages — releasing then would flash the diamond back to the old spot.
81+
const held = pendingHeldPctRef.current;
82+
if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
83+
return;
84+
}
7385
pendingRef.current = false;
86+
pendingHeldPctRef.current = null;
7487
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
7588
setDrag(null);
7689
}, [keyframesData]);
7790

78-
useEffect(() => () => clearTimeout(pendingTimerRef.current ?? undefined), []);
91+
useEffect(
92+
() => () => {
93+
clearTimeout(pendingTimerRef.current ?? undefined);
94+
dragCleanupRef.current?.();
95+
},
96+
[],
97+
);
7998

8099
if (clipWidthPx < 20) return null;
81100

@@ -119,17 +138,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
119138
const handleUp = () => {
120139
document.removeEventListener("pointermove", handleMove);
121140
document.removeEventListener("pointerup", handleUp);
141+
dragCleanupRef.current = null;
122142
const d = dragRef.current;
123143
dragRef.current = null;
124144
const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
125145
if (willCommit && d) {
126146
// Hold the dropped position optimistically; the effect clears it once the
127147
// cache round-trip lands (fallback timeout in case it never does).
128148
pendingRef.current = true;
149+
pendingHeldPctRef.current = d.pct;
129150
setDrag({ origPct: d.origPct, pct: d.pct });
130151
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
131152
pendingTimerRef.current = setTimeout(() => {
132153
pendingRef.current = false;
154+
pendingHeldPctRef.current = null;
133155
setDrag(null);
134156
}, 2000);
135157
onDragKeyframeRef.current?.(d.origPct, d.pct);
@@ -138,6 +160,11 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
138160
}
139161
};
140162

163+
dragCleanupRef.current = () => {
164+
document.removeEventListener("pointermove", handleMove);
165+
document.removeEventListener("pointerup", handleUp);
166+
};
167+
141168
document.addEventListener("pointermove", handleMove);
142169
document.addEventListener("pointerup", handleUp);
143170
};

0 commit comments

Comments
 (0)