Skip to content
Open
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ export function StudioApp() {
designPanelActive,
inspectorPanelActive,
inspectorButtonActive,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
} = useInspectorState(
panelLayout.rightPanelTab,
Expand Down Expand Up @@ -552,6 +553,7 @@ export function StudioApp() {
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
recordingState={gestureState}
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
Expand Down Expand Up @@ -90,6 +91,7 @@ export function EditorShell({
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowMotionPath,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
Expand Down Expand Up @@ -149,6 +151,7 @@ export function EditorShell({
onDeleteElement={handleTimelineElementDelete}
previewOverlay={
<PreviewOverlays
shouldShowMotionPath={shouldShowMotionPath}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
isGestureRecording={isGestureRecording}
Expand Down
6 changes: 5 additions & 1 deletion packages/studio/src/components/editor/KeyframeEaseList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ export function KeyframeEaseList({
? "Custom"
: (EASE_LABELS[segEase] ?? segEase);
return (
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
<div
key={`${i}-${kf.percentage}`}
data-ease-segment-pct={kf.percentage}
className="rounded-md bg-neutral-900/50"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
Expand Down
12 changes: 10 additions & 2 deletions packages/studio/src/components/editor/KeyframeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ interface KeyframeNavigationProps {
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
/** The tween that authored this keyframe, when the cache knows it. */
animationId?: string;
}> | null;
/** Current playhead percentage within the element's lifetime (0-100) */
currentPercentage: number;
onSeek: (percentage: number) => void;
onAddKeyframe: (percentage: number) => void;
onRemoveKeyframe: (percentage: number) => void;
/** `animationId` is the clicked keyframe's OWN tween; see handleDiamondClick. */
onRemoveKeyframe: (percentage: number, animationId?: string) => void;
onConvertToKeyframes: () => void;
}

Expand Down Expand Up @@ -152,7 +155,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
if (diamondState === "ghost") {
onConvertToKeyframes();
} else if (diamondState === "active" && atCurrent) {
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
// Report the keyframe's OWN tween. A merged gutter row shows the keyframes
// of every tween in the property group, so the caller's "the group's
// animation" guess names the wrong tween whenever the clicked keyframe
// belongs to a sibling — and the writer then finds no keyframe at that
// percentage and silently does nothing.
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage, atCurrent.animationId);
} else {
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom

import { act } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
import { KeyframeNavigation } from "./KeyframeNavigation";

beforeAll(installReactActEnvironment);

/**
* Regression: a merged gutter row shows the keyframes of EVERY tween in the
* property group. The panel guesses "the group's animation" for the write, so a
* click on a keyframe authored by a sibling tween named the wrong animation and
* the writer silently found nothing to remove. The diamond now reports the
* clicked keyframe's own animationId.
*/
const MERGED_ROW = [
{ percentage: 0, tweenPercentage: 0, properties: { x: 0 }, animationId: "delta-to-500-position" },
{
percentage: 50,
tweenPercentage: 25,
properties: { x: 120 },
animationId: "delta-to-4000-position",
},
];

function clickDiamond(currentPercentage: number, onRemoveKeyframe: (...args: never[]) => void) {
const root = mountReactHarness(
<KeyframeNavigation
property="x"
keyframes={MERGED_ROW}
currentPercentage={currentPercentage}
onSeek={vi.fn()}
onAddKeyframe={vi.fn()}
onRemoveKeyframe={onRemoveKeyframe as never}
onConvertToKeyframes={vi.fn()}
/>,
);
const diamond = document.querySelector<HTMLElement>('[title="Remove x keyframe"]');
expect(diamond).not.toBeNull();
act(() => {
diamond?.click();
});
act(() => root.unmount());
}

describe("KeyframeNavigation diamond", () => {
it("reports the clicked keyframe's own animation on a merged row", () => {
const onRemoveKeyframe = vi.fn();
clickDiamond(50, onRemoveKeyframe);
expect(onRemoveKeyframe).toHaveBeenCalledWith(25, "delta-to-4000-position");
});

it("still reports the first tween's keyframe as its own", () => {
const onRemoveKeyframe = vi.fn();
clickDiamond(0, onRemoveKeyframe);
expect(onRemoveKeyframe).toHaveBeenCalledWith(0, "delta-to-500-position");
});
});
8 changes: 6 additions & 2 deletions packages/studio/src/components/editor/MotionPathOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
<KeyframeDiamondContextMenu
state={kfMenu}
onClose={() => setKfMenu(null)}
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
onDelete={(_elId, keyframe) =>
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
}
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
onMoveToPlayhead={(_element, keyframe) =>
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
}
/>
)}
</>
Expand Down
20 changes: 15 additions & 5 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
/>
)}
Expand All @@ -433,7 +435,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
/>
)}
Expand All @@ -458,7 +462,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
/>
)}
Expand All @@ -483,7 +489,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
/>
)}
Expand All @@ -507,7 +515,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
onRemoveKeyframe={(pct, animationId) =>
onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ function KeyframeGutter({
track("button", `Add ${property} keyframe`);
void onCommitAnimatedProperty(element, property, displayValue);
}}
onRemoveKeyframe={(pct) => {
onRemoveKeyframe={(pct, animationId) => {
if (!onRemoveKeyframe) return;
track("button", `Remove ${property} keyframe`);
onRemoveKeyframe(animIdForProp(property), pct);
onRemoveKeyframe(animationId ?? animIdForProp(property), pct);
}}
onConvertToKeyframes={() => {
if (!onConvertToKeyframes) return;
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/nle/PreviewOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
import type { ReactNode } from "react";

export interface PreviewOverlaysProps {
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
Expand Down Expand Up @@ -132,6 +133,7 @@ export function resolveZIndexEntries(

// fallow-ignore-next-line complexity
export function PreviewOverlays({
shouldShowMotionPath,
shouldShowSelectedDomBounds,
blockPreview,
isGestureRecording,
Expand Down Expand Up @@ -274,7 +276,7 @@ export function PreviewOverlays({
{STUDIO_KEYFRAMES_ENABLED && (
<MotionPathOverlay
iframeRef={previewIframeRef}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
selection={shouldShowMotionPath ? domEditSelection : null}
compositionSize={compositionDimensions}
isPlaying={isPlaying}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});

it("settles false for a boundary drag while the tween is still flat", async () => {
it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
const view = renderCallbacks();

await expect(
Expand All @@ -198,14 +198,21 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
},
25,
),
).resolves.toBe(false);
).resolves.toBe(true);

// The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
flatAnimation.id,
{ position: 0.25, duration: 0.75 },
mocks.selection,
);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
// resize-keyframed-tween would convert the flat tween to keyframes form.
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});

it("deletes a non-selected element flat boundary through the clicked element's selection", async () => {
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
Expand All @@ -229,12 +236,15 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
await Promise.resolve();
});

// Persisted through the CLICKED element's own selection, not the current one.
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
// Persisted through the CLICKED element's own selection, not the current one,
// and as a remove-keyframe the writer can refuse — never a whole-tween delete.
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});

Expand Down Expand Up @@ -311,7 +321,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});

it("keeps selected-element flat boundary deletion on the animation delete path", () => {
it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
const view = renderCallbacks();

act(() => {
Expand All @@ -323,12 +333,12 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
});
});

expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 0);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});

it("routes the flat lane-header remove toggle through the guarded delete path", async () => {
it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
const view = renderCallbacks();

await act(async () => {
Expand All @@ -341,19 +351,20 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
});
});

expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
flatAnimation.id,
100,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});

// The lane-header toggle fires on whichever element owns the lane, which need
// not be the selected one. Looking the flat tween up in the selected element's
// animations misses, and the miss silently takes the remove-one-keyframe
// branch, which strands the flat tween instead of deleting it.
it("removes a non-selected element's flat tween through that element's own animations", async () => {
// not be the selected one. It must still commit through that element's own
// selection, and it must never escalate a flat tween to a whole-tween delete.
it("removes a non-selected element's flat tween through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
Expand All @@ -376,11 +387,13 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
});
});

expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherFlatAnimation.id,
0,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});

Expand Down
Loading
Loading