Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,32 @@ export interface KeyframeDiamondContextMenuState {
elementId: string;
percentage: number;
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
currentEase?: string;
}

interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState;
onClose: () => void;
onDelete: (elementId: string, percentage: number) => void;
onDelete: (
elementId: string,
percentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onDeleteAll: (elementId: string) => void;
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties?: (elementId: string, percentage: number) => void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
onMoveToPlayhead?: (
elementId: string,
fromPercentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
}

export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
Expand Down Expand Up @@ -51,7 +65,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
// and returns the tween-% for the mutation. Passing tween-% here would
// miss the lookup on any tween whose window is shorter than the clip.
onMoveToPlayhead(state.elementId, state.percentage);
onMoveToPlayhead(
state.elementId,
state.percentage,
state.propertyGroup,
state.tweenPercentage,
state.animationId,
);
onClose();
}}
>
Expand All @@ -64,7 +84,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDelete(state.elementId, state.percentage);
onDelete(
state.elementId,
state.percentage,
state.propertyGroup,
state.tweenPercentage,
state.animationId,
);
onClose();
}}
>
Expand Down
240 changes: 226 additions & 14 deletions packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -84,12 +84,24 @@ describe("TimelineClipDiamonds", () => {
const root = createRoot(host);
act(() => {
root.render(
<TimelineClipDiamonds
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
{
percentage: 0,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 50,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
],
}}
clipWidthPx={5000}
Expand All @@ -101,6 +113,7 @@ describe("TimelineClipDiamonds", () => {
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
groupAware
/>,
);
});
Expand All @@ -117,7 +130,12 @@ describe("TimelineClipDiamonds", () => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
});

expect(onClickKeyframe).toHaveBeenCalledWith(50);
expect(onClickKeyframe).toHaveBeenCalledWith({
percentage: 50,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
});
expect(onMoveKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});
Expand All @@ -126,20 +144,39 @@ describe("TimelineClipDiamonds", () => {
// keyframe) committed the move but never selected/parked on the result —
// the diamond it was just dragged looked exactly like one nothing happened
// to. Select it at its NEW position too.
it("selects the keyframe at its new position after a real drag-retime", () => {
it("reselects a retimed keyframe with its post-move tween percentage", () => {
const onClickKeyframe = vi.fn();
const onMoveKeyframe = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineClipDiamonds
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
{
percentage: 20,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 40,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
{
percentage: 60,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 200 },
},
],
}}
clipWidthPx={200}
Expand All @@ -151,6 +188,81 @@ describe("TimelineClipDiamonds", () => {
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
groupAware
/>,
);
});
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
expect(diamond).not.toBeNull();

act(() => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
);
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
});

expect(onMoveKeyframe).toHaveBeenCalledWith(
{
percentage: 40,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
},
50,
);
expect(onClickKeyframe).toHaveBeenCalledWith({
percentage: 50,
tweenPercentage: 75,
propertyGroup: "position",
animationId: "anim-1",
});
act(() => root.unmount());
});

it("composes a rapid second retime from the pending position", () => {
const onMoveKeyframe = vi.fn();
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()}
onMoveKeyframe={onMoveKeyframe}
groupAware
/>,
);
});
Expand All @@ -161,13 +273,29 @@ describe("TimelineClipDiamonds", () => {
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
);
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
// a real retime.
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));

// The cache still exposes 50%, but this second +10% drag starts at the
// pending 75% destination and must therefore land at 85%, not 60%.
diamond!.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
);
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
});

expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
expect(onClickKeyframe).toHaveBeenCalledWith(52);
// The second move must identify the FROM keyframe by the pending (already-
// moved) position 75%, not the stale rendered 50%; otherwise the serialized
// mutation can't locate the keyframe the first move relocated.
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
2,
{
percentage: 75,
tweenPercentage: 75,
propertyGroup: "position",
animationId: "anim-1",
},
85,
);
act(() => root.unmount());
});

Expand Down Expand Up @@ -212,4 +340,88 @@ describe("TimelineClipDiamonds", () => {
expect(suppressClickRef.current).toBe(true);
act(() => root.unmount());
});

const renderSegmentLane = (lastAmbiguous: boolean) => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
tweenPercentage: percentage,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: percentage },
...extra,
});
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
groupAware
/>,
);
});
return { host, root };
};

it("hides the inline ease button on an ambiguous merged segment", () => {
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
// keyframe, so its hover/ease-button area is not rendered.
const { host, root } = renderSegmentLane(true);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
act(() => root.unmount());
});

it("keeps the inline ease button on unambiguous merged segments", () => {
const { host, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
act(() => root.unmount());
});

it("hides the inline ease button on a segment with no source animation id", () => {
// A runtime-scanned keyframe has no animationId, so there is no tween to
// target; the segment ending on it must not render a (dead) ease button.
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const kf = (percentage: number, animationId?: string) => ({
percentage,
tweenPercentage: percentage,
propertyGroup: "position",
...(animationId ? { animationId } : {}),
properties: { x: percentage },
});
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
groupAware
/>,
);
});
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
act(() => root.unmount());
});
});
Loading
Loading