Skip to content

Commit 8470b88

Browse files
committed
fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks
Review follow-ups on the expanded keyframe lanes. Writer: - `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it dispatched update-meta, so a rejected write left the diamond parked at its drop position. `observeGsapMutation` now resolves to whether the mutation landed and the boundary branch returns it, matching the other branches. - "Delete All Keyframes" cleared only the first keyframed tween on the layer, so a layer with position AND opacity keyframes kept half of them. It now walks every keyframed tween, serially, through the clicked element's selection. - The post-convert lookup in `commitFlatViaKeyframes` matched by target selector, which picks an arbitrary tween when a target carries several. Match by id first. Interaction and a11y: - A rejected retime whose commit settled after a newer drag reverted the selection to its own source keyframe, undoing a retime the user could see. The revert now only runs while it is still the lane's latest gesture. - Diamonds key on the authored identity instead of index plus rendered clip-%, so a neighbour's retime no longer remounts the button mid-drag. - The disclosure caret gets `aria-controls` on an always-mounted lanes container, and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum. - `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane rows instead of hardcoding LABEL_COL_W over the canvas. Test locks: - The timeline callbacks harness resolves a DISTINCT selection per element, so the clicked-element writes are actually pinned; three assertions that passed either way now name the clicked element's selection. - New: null-selection aborts every mutation, delete-all covers both tweens, a rejected boundary retime reports `false`, and a stale revert leaves selection. - The playhead-percentage assertion checks 25, not `expect.any(Number)` (which also accepts NaN); ease segments assert their label ORDER, not just that the three curves differ; the collapsed-diamond callback asserts the whole target. - Dropped a duplicate `selection override` describe left by a rebase.
1 parent c20c536 commit 8470b88

10 files changed

Lines changed: 325 additions & 74 deletions

packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ const mocks = vi.hoisted(() => ({
1616
handleGsapMoveKeyframeToPlayhead: vi.fn(),
1717
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
1818
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
19-
handleGsapUpdateMeta: vi.fn(),
19+
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
2020
handleGsapAddKeyframe: vi.fn(),
2121
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
2222
handleGsapConvertToKeyframes: vi.fn(),
23-
handleGsapRemoveAllKeyframes: vi.fn(),
23+
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
2424
handleGsapDeleteAnimation: vi.fn(),
2525
buildDomSelectionForTimelineElement: vi.fn(),
2626
},
@@ -116,6 +116,19 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
116116
return { callbacks, unmount: () => act(() => root.unmount()) };
117117
}
118118

119+
// One selection PER element, so a callback that resolves the selection for the
120+
// wrong element gets a visibly different object. A single mockResolvedValue
121+
// hands every element the same selection, which passes just as happily when the
122+
// write is committed through whatever happens to be selected.
123+
function selectionForElement(el: TimelineElement): {
124+
id: string;
125+
selector: string;
126+
sourceFile: string;
127+
} {
128+
if (el.id === "box") return mocks.selection;
129+
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
130+
}
131+
119132
function arrangeClickedCircle(): {
120133
circle: TimelineElement;
121134
selection: { id: string; selector: string; sourceFile: string };
@@ -128,19 +141,19 @@ function arrangeClickedCircle(): {
128141
domId: "circle",
129142
sourceFile: "scenes/main.html",
130143
};
131-
const selection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
132144
usePlayerStore.setState({
133145
elements: [element, circle],
134146
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
135147
});
136-
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(selection);
137-
return { circle, selection };
148+
return { circle, selection: selectionForElement(circle) };
138149
}
139150

140151
beforeEach(() => {
141152
vi.clearAllMocks();
142153
mocks.animations = [flatAnimation];
143-
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
154+
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
155+
Promise.resolve(selectionForElement(el)),
156+
);
144157
usePlayerStore.setState({
145158
currentTime: 0.5,
146159
elements: [element],
@@ -212,6 +225,27 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
212225
view.unmount();
213226
});
214227

228+
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
229+
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
230+
const view = renderCallbacks();
231+
232+
// The diamond snaps back on `false`. Answering `true` the moment update-meta
233+
// was dispatched left a rejected boundary drag rendered at its drop position.
234+
await expect(
235+
view.callbacks.onMoveKeyframe?.(
236+
"box",
237+
{
238+
percentage: 0,
239+
propertyGroup: "position",
240+
tweenPercentage: 0,
241+
animationId: flatAnimation.id,
242+
},
243+
25,
244+
),
245+
).resolves.toBe(false);
246+
view.unmount();
247+
});
248+
215249
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
216250
const circle: TimelineElement = {
217251
...element,
@@ -242,7 +276,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
242276
otherFlatAnimation.id,
243277
0,
244278
undefined,
245-
mocks.selection,
279+
selectionForElement(circle),
246280
);
247281
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
248282
view.unmount();
@@ -276,7 +310,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
276310
otherKeyframedAnimation.id,
277311
100,
278312
undefined,
279-
mocks.selection,
313+
selectionForElement(circle),
280314
);
281315
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
282316
view.unmount();
@@ -298,6 +332,65 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
298332
view.unmount();
299333
});
300334

335+
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
336+
const opacityAnimation: GsapAnimation = {
337+
...otherKeyframedAnimation,
338+
id: "circle-to-0-visual",
339+
propertyGroup: "visual",
340+
};
341+
const { circle } = arrangeClickedCircle();
342+
usePlayerStore.setState({
343+
gsapAnimations: new Map([
344+
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
345+
]),
346+
});
347+
const view = renderCallbacks();
348+
349+
await act(async () => {
350+
view.callbacks.onDeleteAllKeyframes?.(circle);
351+
await Promise.resolve();
352+
await Promise.resolve();
353+
await Promise.resolve();
354+
});
355+
356+
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
357+
otherKeyframedAnimation.id,
358+
opacityAnimation.id,
359+
]);
360+
view.unmount();
361+
});
362+
363+
it("aborts every mutation when the clicked element resolves no selection", async () => {
364+
const { circle } = arrangeClickedCircle();
365+
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
366+
const view = renderCallbacks();
367+
368+
await act(async () => {
369+
view.callbacks.onDeleteAllKeyframes?.(circle);
370+
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
371+
percentage: 100,
372+
propertyGroup: "position",
373+
tweenPercentage: 100,
374+
animationId: otherKeyframedAnimation.id,
375+
});
376+
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
377+
percentage: 100,
378+
propertyGroup: "position",
379+
tweenPercentage: 100,
380+
animationId: otherKeyframedAnimation.id,
381+
});
382+
await Promise.resolve();
383+
await Promise.resolve();
384+
});
385+
386+
// No selection for the clicked element means there is nothing safe to write
387+
// to: falling back to the current selection would edit a different file.
388+
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
389+
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
390+
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
391+
view.unmount();
392+
});
393+
301394
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
302395
const { circle, selection } = arrangeClickedCircle();
303396
const view = renderCallbacks();
@@ -398,7 +491,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
398491
otherFlatAnimation.id,
399492
0,
400493
undefined,
401-
mocks.selection,
494+
selectionForElement(circle),
402495
);
403496
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
404497
view.unmount();

packages/studio/src/components/nle/useTimelineEditCallbacks.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,18 @@ export function useTimelineEditCallbacks({
194194
// than deleting the whole animation — deleting strands a stale GSAP base
195195
// that the next drag adds to, flinging the element off-screen.
196196
const elementKey = getTimelineElementIdentity(element);
197-
const anim = resolveElementAnimations(elementKey).find((animation) => animation.keyframes);
198-
if (!anim) return;
199-
void buildDomSelectionForTimelineElement(element).then((selection) => {
200-
if (selection) handleGsapRemoveAllKeyframes(anim.id, selection);
197+
// Every keyframed tween on the layer, not just the first: a layer with
198+
// position AND opacity keyframes left the second one keyframed, so
199+
// "Delete All Keyframes" visibly did half the job.
200+
const anims = resolveElementAnimations(elementKey).filter(
201+
(animation) => animation.keyframes,
202+
);
203+
if (anims.length === 0) return;
204+
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
205+
if (!selection) return;
206+
// Serial: each removal rewrites the same source file, so dispatching
207+
// them together would have the later writes read a pre-edit document.
208+
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
201209
});
202210
},
203211
onDeleteKeyframe: (elId, keyframe) => {
@@ -287,12 +295,14 @@ export function useTimelineEditCallbacks({
287295
// keyframes form as a side effect of a pure position/duration change, so
288296
// dispatch update-meta and leave the tween as the author wrote it.
289297
if (decision.pctRemap.length === 0) {
290-
handleGsapUpdateMeta(
298+
// Report the write's real settlement, like every other branch here:
299+
// answering `true` while the meta update is still in flight tells the
300+
// diamond the retime landed, so a rejected write never snaps back.
301+
return handleGsapUpdateMeta(
291302
target.animId,
292303
{ position: decision.position, duration: decision.duration },
293304
sel,
294305
);
295-
return true;
296306
}
297307
return handleGsapResizeKeyframedTween(
298308
target.animId,

packages/studio/src/hooks/gsapDragPositionCommit.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,13 @@ async function commitFlatViaKeyframes(
210210
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
211211
);
212212
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
213+
// By id first: a target with several tweens (two `to`s on the same selector)
214+
// matches the selector lookup on whichever one happens to be first, and the
215+
// extend-and-add below would then rewrite a tween the drag never touched.
213216
const converted =
214-
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
217+
fresh.find((a) => a.id === anim.id && a.keyframes) ??
218+
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
219+
anim;
215220
const convertedStart = resolveTweenStart(converted) ?? ts;
216221
const convertedDur = resolveTweenDuration(converted) || td;
217222
await extendTweenAndAddKeyframe(

packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
44
import { describe, expect, it, vi } from "vitest";
55
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
66
import type { DomEditSelection } from "../components/editor/domEditingTypes";
7+
import { usePlayerStore } from "../player/store/playerStore";
78
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
89

910
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -80,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => {
8081
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
8182
);
8283

83-
act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
84+
// Braces, not a bare arrow: the handler returns its settlement promise now,
85+
// and returning a thenable from act() turns it into an un-awaited async act.
86+
act(() => {
87+
void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
88+
});
8489
await flushRejection();
8590

8691
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
@@ -135,12 +140,24 @@ describe("useGsapSelectionHandlers selection override", () => {
135140
it("computes the playhead percentage from the passed animation, not the selection's", () => {
136141
const moveKeyframe = vi.fn();
137142
const selection = makeSelection();
138-
const animation = { id: "anim-1", keyframes: { keyframes: [] } } as unknown as GsapAnimation;
143+
// The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
144+
// the animation the handler falls back to the selection's own element window
145+
// (0s→1s here), which reads the same playhead as 100%. Asserting the exact
146+
// 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
147+
// missing window would produce.
148+
const animation = {
149+
id: "anim-1",
150+
position: 2,
151+
resolvedStart: 2,
152+
duration: 4,
153+
keyframes: { keyframes: [] },
154+
} as unknown as GsapAnimation;
155+
usePlayerStore.setState({ currentTime: 3 });
139156
const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
140157

141158
rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
142159

143-
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, expect.any(Number));
160+
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
144161
rendered.unmount();
145162
});
146163
});
@@ -163,20 +180,3 @@ describe("useGsapSelectionHandlers retime settlement", () => {
163180
withSelection.unmount();
164181
});
165182
});
166-
167-
describe("useGsapSelectionHandlers selection override", () => {
168-
it("aborts on an explicit null override instead of writing to the current selection", () => {
169-
const removeKeyframe = vi.fn();
170-
const rendered = renderHandlers(makeParams({ removeKeyframe }));
171-
172-
// Explicit null: the caller resolved a selection for its own element and
173-
// found none, so the write must not land on the selected element.
174-
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
175-
expect(removeKeyframe).not.toHaveBeenCalled();
176-
177-
// Omitted override: falls back to the current selection as before.
178-
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
179-
expect(removeKeyframe).toHaveBeenCalledOnce();
180-
rendered.unmount();
181-
});
182-
});

packages/studio/src/hooks/useGsapSelectionHandlers.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,24 @@ export function useGsapSelectionHandlers({
150150
[showToast],
151151
);
152152

153+
// Resolves to whether the mutation landed. Callers that only fire-and-forget
154+
// can ignore it (the rejection is always handled here), but a caller that
155+
// reports a commit result to the UI has to await the real settlement instead
156+
// of assuming success the moment it dispatched.
153157
const observeGsapMutation = useCallback(
154-
(mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
155-
void mutation.catch((error) => {
156-
trackGsapHandlerFailure(error, selection, mutationType, label);
157-
});
158-
},
158+
(
159+
mutation: Promise<void>,
160+
selection: DomEditSelection,
161+
mutationType: string,
162+
label: string,
163+
): Promise<boolean> =>
164+
mutation.then(
165+
() => true,
166+
(error: unknown) => {
167+
trackGsapHandlerFailure(error, selection, mutationType, label);
168+
return false;
169+
},
170+
),
159171
[trackGsapHandlerFailure],
160172
);
161173

@@ -174,8 +186,8 @@ export function useGsapSelectionHandlers({
174186
selectionOverride?: DomEditSelection | null,
175187
) => {
176188
const sel = resolveWriteSelection(selectionOverride);
177-
if (!sel) return;
178-
observeGsapMutation(
189+
if (!sel) return Promise.resolve(false);
190+
return observeGsapMutation(
179191
updateGsapMeta(sel, animId, updates),
180192
sel,
181193
"update-meta",
@@ -419,8 +431,8 @@ export function useGsapSelectionHandlers({
419431
const handleGsapRemoveAllKeyframes = useCallback(
420432
(animId: string, selectionOverride?: DomEditSelection | null) => {
421433
const selection = resolveWriteSelection(selectionOverride);
422-
if (!selection) return;
423-
observeGsapMutation(
434+
if (!selection) return Promise.resolve(false);
435+
return observeGsapMutation(
424436
removeAllKeyframes(selection, animId),
425437
selection,
426438
"remove-all-keyframes",

0 commit comments

Comments
 (0)