mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
This commit is contained in:
@@ -16,11 +16,11 @@ const mocks = vi.hoisted(() => ({
|
||||
handleGsapMoveKeyframeToPlayhead: vi.fn(),
|
||||
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
handleGsapUpdateMeta: vi.fn(),
|
||||
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
|
||||
handleGsapAddKeyframe: vi.fn(),
|
||||
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleGsapConvertToKeyframes: vi.fn(),
|
||||
handleGsapRemoveAllKeyframes: vi.fn(),
|
||||
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
|
||||
handleGsapDeleteAnimation: vi.fn(),
|
||||
buildDomSelectionForTimelineElement: vi.fn(),
|
||||
},
|
||||
@@ -116,6 +116,19 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
|
||||
return { callbacks, unmount: () => act(() => root.unmount()) };
|
||||
}
|
||||
|
||||
// One selection PER element, so a callback that resolves the selection for the
|
||||
// wrong element gets a visibly different object. A single mockResolvedValue
|
||||
// hands every element the same selection, which passes just as happily when the
|
||||
// write is committed through whatever happens to be selected.
|
||||
function selectionForElement(el: TimelineElement): {
|
||||
id: string;
|
||||
selector: string;
|
||||
sourceFile: string;
|
||||
} {
|
||||
if (el.id === "box") return mocks.selection;
|
||||
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
|
||||
}
|
||||
|
||||
function arrangeClickedCircle(): {
|
||||
circle: TimelineElement;
|
||||
selection: { id: string; selector: string; sourceFile: string };
|
||||
@@ -128,19 +141,19 @@ function arrangeClickedCircle(): {
|
||||
domId: "circle",
|
||||
sourceFile: "scenes/main.html",
|
||||
};
|
||||
const selection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
|
||||
});
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(selection);
|
||||
return { circle, selection };
|
||||
return { circle, selection: selectionForElement(circle) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.animations = [flatAnimation];
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
|
||||
Promise.resolve(selectionForElement(el)),
|
||||
);
|
||||
usePlayerStore.setState({
|
||||
currentTime: 0.5,
|
||||
elements: [element],
|
||||
@@ -212,6 +225,27 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
|
||||
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
|
||||
const view = renderCallbacks();
|
||||
|
||||
// The diamond snaps back on `false`. Answering `true` the moment update-meta
|
||||
// was dispatched left a rejected boundary drag rendered at its drop position.
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
},
|
||||
25,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
@@ -242,7 +276,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
@@ -276,7 +310,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
@@ -298,6 +332,65 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
|
||||
const opacityAnimation: GsapAnimation = {
|
||||
...otherKeyframedAnimation,
|
||||
id: "circle-to-0-visual",
|
||||
propertyGroup: "visual",
|
||||
};
|
||||
const { circle } = arrangeClickedCircle();
|
||||
usePlayerStore.setState({
|
||||
gsapAnimations: new Map([
|
||||
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
|
||||
]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
|
||||
otherKeyframedAnimation.id,
|
||||
opacityAnimation.id,
|
||||
]);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
||||
const { circle } = arrangeClickedCircle();
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No selection for the clicked element means there is nothing safe to write
|
||||
// to: falling back to the current selection would edit a different file.
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
|
||||
const { circle, selection } = arrangeClickedCircle();
|
||||
const view = renderCallbacks();
|
||||
@@ -398,7 +491,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
|
||||
@@ -194,10 +194,18 @@ export function useTimelineEditCallbacks({
|
||||
// than deleting the whole animation — deleting strands a stale GSAP base
|
||||
// that the next drag adds to, flinging the element off-screen.
|
||||
const elementKey = getTimelineElementIdentity(element);
|
||||
const anim = resolveElementAnimations(elementKey).find((animation) => animation.keyframes);
|
||||
if (!anim) return;
|
||||
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
||||
if (selection) handleGsapRemoveAllKeyframes(anim.id, selection);
|
||||
// Every keyframed tween on the layer, not just the first: a layer with
|
||||
// position AND opacity keyframes left the second one keyframed, so
|
||||
// "Delete All Keyframes" visibly did half the job.
|
||||
const anims = resolveElementAnimations(elementKey).filter(
|
||||
(animation) => animation.keyframes,
|
||||
);
|
||||
if (anims.length === 0) return;
|
||||
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
||||
if (!selection) return;
|
||||
// Serial: each removal rewrites the same source file, so dispatching
|
||||
// them together would have the later writes read a pre-edit document.
|
||||
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
|
||||
});
|
||||
},
|
||||
onDeleteKeyframe: (elId, keyframe) => {
|
||||
@@ -287,12 +295,14 @@ export function useTimelineEditCallbacks({
|
||||
// keyframes form as a side effect of a pure position/duration change, so
|
||||
// dispatch update-meta and leave the tween as the author wrote it.
|
||||
if (decision.pctRemap.length === 0) {
|
||||
handleGsapUpdateMeta(
|
||||
// Report the write's real settlement, like every other branch here:
|
||||
// answering `true` while the meta update is still in flight tells the
|
||||
// diamond the retime landed, so a rejected write never snaps back.
|
||||
return handleGsapUpdateMeta(
|
||||
target.animId,
|
||||
{ position: decision.position, duration: decision.duration },
|
||||
sel,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return handleGsapResizeKeyframedTween(
|
||||
target.animId,
|
||||
|
||||
@@ -210,8 +210,13 @@ async function commitFlatViaKeyframes(
|
||||
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
||||
// By id first: a target with several tweens (two `to`s on the same selector)
|
||||
// matches the selector lookup on whichever one happens to be first, and the
|
||||
// extend-and-add below would then rewrite a tween the drag never touched.
|
||||
const converted =
|
||||
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
|
||||
fresh.find((a) => a.id === anim.id && a.keyframes) ??
|
||||
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
|
||||
anim;
|
||||
const convertedStart = resolveTweenStart(converted) ?? ts;
|
||||
const convertedDur = resolveTweenDuration(converted) || td;
|
||||
await extendTweenAndAddKeyframe(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -80,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => {
|
||||
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
|
||||
);
|
||||
|
||||
act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
|
||||
// Braces, not a bare arrow: the handler returns its settlement promise now,
|
||||
// and returning a thenable from act() turns it into an un-awaited async act.
|
||||
act(() => {
|
||||
void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
|
||||
});
|
||||
await flushRejection();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
|
||||
@@ -135,12 +140,24 @@ describe("useGsapSelectionHandlers selection override", () => {
|
||||
it("computes the playhead percentage from the passed animation, not the selection's", () => {
|
||||
const moveKeyframe = vi.fn();
|
||||
const selection = makeSelection();
|
||||
const animation = { id: "anim-1", keyframes: { keyframes: [] } } as unknown as GsapAnimation;
|
||||
// The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
|
||||
// the animation the handler falls back to the selection's own element window
|
||||
// (0s→1s here), which reads the same playhead as 100%. Asserting the exact
|
||||
// 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
|
||||
// missing window would produce.
|
||||
const animation = {
|
||||
id: "anim-1",
|
||||
position: 2,
|
||||
resolvedStart: 2,
|
||||
duration: 4,
|
||||
keyframes: { keyframes: [] },
|
||||
} as unknown as GsapAnimation;
|
||||
usePlayerStore.setState({ currentTime: 3 });
|
||||
const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
|
||||
|
||||
rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
|
||||
|
||||
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, expect.any(Number));
|
||||
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
@@ -163,20 +180,3 @@ describe("useGsapSelectionHandlers retime settlement", () => {
|
||||
withSelection.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapSelectionHandlers selection override", () => {
|
||||
it("aborts on an explicit null override instead of writing to the current selection", () => {
|
||||
const removeKeyframe = vi.fn();
|
||||
const rendered = renderHandlers(makeParams({ removeKeyframe }));
|
||||
|
||||
// Explicit null: the caller resolved a selection for its own element and
|
||||
// found none, so the write must not land on the selected element.
|
||||
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
|
||||
expect(removeKeyframe).not.toHaveBeenCalled();
|
||||
|
||||
// Omitted override: falls back to the current selection as before.
|
||||
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
|
||||
expect(removeKeyframe).toHaveBeenCalledOnce();
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,12 +150,24 @@ export function useGsapSelectionHandlers({
|
||||
[showToast],
|
||||
);
|
||||
|
||||
// Resolves to whether the mutation landed. Callers that only fire-and-forget
|
||||
// can ignore it (the rejection is always handled here), but a caller that
|
||||
// reports a commit result to the UI has to await the real settlement instead
|
||||
// of assuming success the moment it dispatched.
|
||||
const observeGsapMutation = useCallback(
|
||||
(mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
|
||||
void mutation.catch((error) => {
|
||||
trackGsapHandlerFailure(error, selection, mutationType, label);
|
||||
});
|
||||
},
|
||||
(
|
||||
mutation: Promise<void>,
|
||||
selection: DomEditSelection,
|
||||
mutationType: string,
|
||||
label: string,
|
||||
): Promise<boolean> =>
|
||||
mutation.then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
trackGsapHandlerFailure(error, selection, mutationType, label);
|
||||
return false;
|
||||
},
|
||||
),
|
||||
[trackGsapHandlerFailure],
|
||||
);
|
||||
|
||||
@@ -174,8 +186,8 @@ export function useGsapSelectionHandlers({
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
observeGsapMutation(
|
||||
if (!sel) return Promise.resolve(false);
|
||||
return observeGsapMutation(
|
||||
updateGsapMeta(sel, animId, updates),
|
||||
sel,
|
||||
"update-meta",
|
||||
@@ -419,8 +431,8 @@ export function useGsapSelectionHandlers({
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const selection = resolveWriteSelection(selectionOverride);
|
||||
if (!selection) return;
|
||||
observeGsapMutation(
|
||||
if (!selection) return Promise.resolve(false);
|
||||
return observeGsapMutation(
|
||||
removeAllKeyframes(selection, animId),
|
||||
selection,
|
||||
"remove-all-keyframes",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CaretRight } from "@phosphor-icons/react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
|
||||
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
|
||||
@@ -10,6 +10,8 @@ export function LayerDisclosureRow({
|
||||
clipCount,
|
||||
isExpanded,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
lanesId,
|
||||
onToggleClipExpanded,
|
||||
children,
|
||||
}: {
|
||||
@@ -17,6 +19,11 @@ export function LayerDisclosureRow({
|
||||
clipCount: number;
|
||||
isExpanded: boolean;
|
||||
gutterBackground: string;
|
||||
/** Same adaptive width the lane rows use: a narrowed header column must not
|
||||
* leave this row hanging over the clips it labels. */
|
||||
columnWidth: number;
|
||||
/** Id of the element holding the lanes this row's caret expands. */
|
||||
lanesId: string;
|
||||
onToggleClipExpanded: () => void;
|
||||
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
||||
children?: React.ReactNode;
|
||||
@@ -26,7 +33,7 @@ export function LayerDisclosureRow({
|
||||
<div
|
||||
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
||||
style={{
|
||||
width: LABEL_COL_W,
|
||||
width: columnWidth,
|
||||
height: TRACK_H,
|
||||
color: "#ffffff",
|
||||
background: gutterBackground,
|
||||
@@ -35,9 +42,12 @@ export function LayerDisclosureRow({
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={lanesId}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
||||
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
||||
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
|
||||
// only the hit box grows.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -310,6 +310,89 @@ describe("TimelineClipDiamonds", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("leaves the selection alone when a stale retime fails after a newer drag", async () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
let failFirstDrag: (() => void) | undefined;
|
||||
const onMoveKeyframe = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
failFirstDrag = () => resolve(false);
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
|
||||
// Two drags back to back; the first one's commit is still in flight.
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
||||
});
|
||||
onClickKeyframe.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failFirstDrag?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The stale failure must not drag the selection back to the first drag's
|
||||
// source: the second retime, which the user can see, owns it now.
|
||||
expect(onClickKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("composes a rapid second retime from the pending position", () => {
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
|
||||
@@ -63,6 +63,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
// the first away, once per mounted lane.
|
||||
pendingRetimeRef.current ??= new Map();
|
||||
const pendingRetimes = pendingRetimeRef.current;
|
||||
// The most recent retime dispatched from this lane, whichever diamond it came
|
||||
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
|
||||
// question, not a per-keyframe one.
|
||||
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
|
||||
useEffect(() => {
|
||||
// Clear a pending entry once the authoritative cache reflects THAT keyframe
|
||||
// at ~its destination. Match by tolerance, not equality: cache writers round
|
||||
@@ -354,6 +358,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
: target;
|
||||
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
||||
pendingRetimes.set(kfKey, pending);
|
||||
latestRetimeRef.current = pending;
|
||||
const clearPending = () => {
|
||||
if (pendingRetimes.get(kfKey) === pending) {
|
||||
pendingRetimes.delete(kfKey);
|
||||
@@ -365,8 +370,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
// position strands the playhead + selection on a keyframe that does
|
||||
// not exist there.
|
||||
const revertRetime = () => {
|
||||
// Only the newest gesture owns the selection. A rejected first drag
|
||||
// whose commit settles after a second one started would otherwise
|
||||
// park the selection back on ITS source keyframe, undoing a retime
|
||||
// the user has already made and moving the playhead with it.
|
||||
const isLatest = latestRetimeRef.current === pending;
|
||||
clearPending();
|
||||
onClickKeyframe?.(fromTarget);
|
||||
if (isLatest) onClickKeyframe?.(fromTarget);
|
||||
};
|
||||
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
||||
if (!committed) revertRetime();
|
||||
@@ -386,7 +396,11 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${i}-${kf.percentage}`}
|
||||
// Key on the authored identity (tween-%), not the rendered clip-% or
|
||||
// the row index: a clip resize, a neighbour's retime, or a re-sort
|
||||
// changes both of those without changing WHICH keyframe this is, and
|
||||
// a key change remounts the button mid-drag (losing pointer capture).
|
||||
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
|
||||
type="button"
|
||||
className="absolute"
|
||||
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
||||
|
||||
@@ -346,6 +346,16 @@ describe("TimelinePropertyLanes", () => {
|
||||
);
|
||||
expect(paths).toHaveLength(3);
|
||||
expect(new Set(paths).size).toBe(3);
|
||||
// Uniqueness alone passes even when the curves are swapped between segments.
|
||||
// Each segment is labelled with the ease it draws, so pin the ORDER: a
|
||||
// segment carries the ease of the keyframe it arrives at.
|
||||
expect(
|
||||
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
|
||||
).toEqual([
|
||||
"Edit none easing",
|
||||
"Edit power2.out easing",
|
||||
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
|
||||
]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -391,6 +401,8 @@ describe("TimelinePropertyLanes", () => {
|
||||
|
||||
it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
|
||||
const COLLAPSED_TARGET = { ...COLLAPSED_IDENTITY, percentage: 50, tweenPercentage: 50 };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -400,8 +412,13 @@ describe("TimelinePropertyLanes", () => {
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
{ percentage: 0, ...COLLAPSED_IDENTITY, tweenPercentage: 0, properties: { x: 0 } },
|
||||
{
|
||||
percentage: 50,
|
||||
...COLLAPSED_IDENTITY,
|
||||
tweenPercentage: 50,
|
||||
properties: { x: 100 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
@@ -410,7 +427,7 @@ describe("TimelinePropertyLanes", () => {
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set(["clip-1:50"])}
|
||||
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", COLLAPSED_TARGET)])}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
/>,
|
||||
);
|
||||
@@ -424,10 +441,10 @@ describe("TimelinePropertyLanes", () => {
|
||||
act(() => {
|
||||
diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(
|
||||
"clip-1",
|
||||
expect.objectContaining({ percentage: 50 }),
|
||||
);
|
||||
// The whole identity, not just the percentage: objectContaining on the one
|
||||
// field passes even when the animation id / property group / tween-% the
|
||||
// diamond-identity refactor added are dropped on the way out.
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", COLLAPSED_TARGET);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,8 @@ function PropertyGroupHeaderRow({
|
||||
aria-pressed={!!navigation.currentKeyframe}
|
||||
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
className="flex h-5 w-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onClick={(event) => {
|
||||
// Same as the disclosure caret and the eye: a control in the label
|
||||
// column owns its click, it does not also hit the track row behind it.
|
||||
@@ -286,6 +287,7 @@ export function TimelineTrackHeader({
|
||||
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
||||
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
||||
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
|
||||
const lanesId = `timeline-lanes-track-${trackNumber}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -319,6 +321,8 @@ export function TimelineTrackHeader({
|
||||
clipCount={clipCount}
|
||||
isExpanded={isExpanded}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||
lanesId={lanesId}
|
||||
onToggleClipExpanded={onToggleClipExpanded}
|
||||
>
|
||||
{/* The eye belongs to the LAYER, so it lives on the always-mounted
|
||||
@@ -333,22 +337,25 @@ export function TimelineTrackHeader({
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</LayerDisclosureRow>
|
||||
{isExpanded &&
|
||||
lanes.map((lane, laneIndex) => (
|
||||
<PropertyGroupHeaderRow
|
||||
key={lane.group}
|
||||
lane={lane}
|
||||
laneIndex={laneIndex}
|
||||
isLastLane={laneIndex === lanes.length - 1}
|
||||
expandedElement={keyframeClip}
|
||||
currentTime={currentTime}
|
||||
clipPercentage={clipPercentage}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
{/* Always mounted so the caret's aria-controls resolves in both states. */}
|
||||
<div id={lanesId}>
|
||||
{isExpanded &&
|
||||
lanes.map((lane, laneIndex) => (
|
||||
<PropertyGroupHeaderRow
|
||||
key={lane.group}
|
||||
lane={lane}
|
||||
laneIndex={laneIndex}
|
||||
isLastLane={laneIndex === lanes.length - 1}
|
||||
expandedElement={keyframeClip}
|
||||
currentTime={currentTime}
|
||||
clipPercentage={clipPercentage}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user