fix(studio): harden keyframe editing semantics

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:13:46 +02:00
parent 9fc0011703
commit d57039882f
41 changed files with 1294 additions and 435 deletions
@@ -2,8 +2,10 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../player/store/playerStore";
import { makeSelection } from "../hooks/domSelectionTestHarness";
import { TimelineToolbar } from "./TimelineToolbar";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -13,12 +15,14 @@ afterEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true });
});
function renderToolbar() {
function renderToolbar(
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<TimelineToolbar />);
root.render(<TimelineToolbar domEditSession={domEditSession} />);
});
return { host, root };
}
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
act(() => root.unmount());
});
});
describe("TimelineToolbar — motion path endpoints", () => {
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
usePlayerStore.setState({ currentTime: 10 });
const animation: GsapAnimation = {
id: "#el-to-0-position",
targetSelector: "#el",
method: "to",
position: 0,
duration: 10,
properties: {},
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
arcPath: {
enabled: true,
autoRotate: false,
segments: [{ curviness: 1 }],
},
};
const element = document.createElement("div");
element.id = "el";
const session = {
domEditSelection: makeSelection("Element", element),
selectedGsapAnimations: [animation],
handleGsapAddAnimation: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
const { host, root } = renderToolbar(session);
const button = host.querySelector<HTMLButtonElement>(
'button[aria-label="Motion path endpoint"]',
);
expect(button?.disabled).toBe(true);
act(() => root.unmount());
});
});
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
interface KeyframeToggleState {
state: "active" | "inactive" | "none";
isMotionPath: boolean;
pathEndpoint: boolean;
willExtend: boolean;
}
const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
state: "none",
isMotionPath: false,
pathEndpoint: false,
willExtend: false,
};
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
if (!animation?.keyframes) return false;
const keyframes = animation.keyframes.keyframes;
return (
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= 1 ||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= 1
);
}
function resolveKeyframeToggleState(
session: DomEditSessionSlice | undefined,
currentTime: number,
): KeyframeToggleState {
if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
const arcAnimation = session.selectedGsapAnimations.find(
(animation) => animation.arcPath && animation.keyframes,
);
const animation =
arcAnimation ??
session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
const isMotionPath = Boolean(arcAnimation);
if (!isPlayheadWithinTween(animation, currentTime)) {
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
}
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
const active = animation.keyframes.keyframes.some(
(keyframe) => Math.abs(keyframe.percentage - percentage) <= 1,
);
return {
state: pathEndpoint ? "none" : active ? "active" : "inactive",
isMotionPath,
pathEndpoint,
willExtend: false,
};
}
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
const sessionRef = useRef(session);
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
);
if (!session) return { state: "none" as const, onToggle: undefined };
const toggleState = resolveKeyframeToggleState(session, currentTime);
const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
let state: "active" | "inactive" | "none" = "none";
// Outside the tween, clicking extends the animation to the playhead rather than
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
let willExtend = false;
if (kfAnim?.keyframes && sel) {
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
state = "inactive";
willExtend = true;
} else {
// Tween-relative percentage (not the clip range) so the button state matches
// where the keyframe would actually land.
const pct = computeElementPercentage(currentTime, sel, kfAnim);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
}
}
return { state, willExtend, onToggle: sel ? onToggle : undefined };
return {
...toggleState,
onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
};
}
// fallow-ignore-next-line complexity
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const {
state: keyframeState,
isMotionPath: keyframeIsMotionPath,
pathEndpoint: keyframePathEndpoint,
willExtend: keyframeWillExtend,
onToggle: onToggleKeyframe,
} = useKeyframeToggle(domEditSession);
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
// toolbar layout never shifts.
<Tooltip
label={
!onToggleKeyframe
? "Select an animated element to add keyframes"
: keyframeState === "active"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
keyframePathEndpoint
? "Motion path endpoints cannot be removed"
: !onToggleKeyframe
? "Select an animated element to add keyframes"
: keyframeIsMotionPath
? keyframeWillExtend
? "Add keyframe at playhead, extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
? "Extend motion path to playhead (K)"
: keyframeState === "active"
? "Remove waypoint from motion path (K)"
: "Add waypoint to motion path (K)"
: keyframeState === "active"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
? keyframeWillExtend
? "Add keyframe at playhead, extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
}
>
<button
@@ -196,9 +241,17 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
disabled={!onToggleKeyframe}
onClick={onToggleKeyframe}
aria-label={
keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
keyframePathEndpoint
? "Motion path endpoint"
: keyframeIsMotionPath
? keyframeState === "active"
? "Remove motion path waypoint"
: keyframeWillExtend
? "Extend motion path to playhead"
: "Add motion path waypoint"
: keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
}
className={
!onToggleKeyframe
@@ -88,6 +88,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// The keyframe % selected by clicking its node — highlighted, and the next drag
// modifies it rather than adding a keyframe.
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const timelineElement = usePlayerStore((state) => {
if (!selection) return undefined;
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
return state.elements.find(
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
);
});
// Set-destination mode is armed from the preview toolbar (replaces the old
// double-click-on-canvas UX). See createMode effects below.
const armed = usePlayerStore((s) => s.motionPathArmed);
@@ -418,12 +425,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// Right-click a keyframe node → the timeline's keyframe context menu (delete
// this keyframe / delete all), so motion-path keyframes are removable in place.
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
if (ref.type !== "keyframe" || !animId || !elementId) return;
if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
e.preventDefault();
e.stopPropagation();
setKfMenu({
x: e.clientX,
y: e.clientY,
element: timelineElement,
elementId,
percentage: ref.pct,
tweenPercentage: ref.pct,
@@ -14,6 +14,22 @@ const KEYFRAMES: RetimeKeyframe[] = [
const WINDOW = { tweenStart: 2, tweenDuration: 4 };
const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
function expectLeftResize(
keyframes: RetimeKeyframe[],
draggedTweenPct: number,
pctRemap: Array<{ from: number; to: number }>,
): void {
const result = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes,
draggedTweenPct,
});
expect(result.kind).toBe("resize");
expect(result.position).toBeCloseTo(0.5, 5);
expect(result.duration).toBeCloseTo(5.5, 5);
expect(result.pctRemap).toEqual(pctRemap);
}
describe("resolveKeyframeRetime — move (within the tween window)", () => {
it("re-keys an interior keyframe to the tween-% of the drop", () => {
const r = resolveKeyframeRetime({
@@ -108,16 +124,8 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
});
it("extends the FIRST keyframe before the start, shifting position earlier", () => {
const r = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes: KEYFRAMES,
draggedTweenPct: 0,
});
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expect(r.pctRemap).toEqual([
expectLeftResize(KEYFRAMES, 0, [
{ from: 0, to: 0 },
{ from: 50, to: 63.636 },
{ from: 100, to: 100 },
@@ -142,15 +150,7 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
});
it("resizes left before the start", () => {
const r = resolveKeyframeRetime({
...LEFT_BOUNDARY_DROP,
keyframes: lone,
draggedTweenPct: 100,
});
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5);
expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
expectLeftResize(lone, 100, [{ from: 100, to: 0 }]);
});
});
@@ -299,22 +299,17 @@ describe("FlatTextFieldEditor controls", () => {
describe("FlatTextSection — multi-field", () => {
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
});
const { host, root } = renderInto(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
expect(host.textContent).toContain("Headline");
expect(host.textContent).toContain("Subhead");
// Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
@@ -408,6 +403,27 @@ describe("FlatTextSection — multi-field", () => {
act(() => root.unmount());
});
it("does not steal canvas focus when a multi-field element is selected", () => {
const focusOwner = document.createElement("button");
document.body.append(focusOwner);
focusOwner.focus();
const { root } = renderInto(
<FlatTextSection
element={makeMultiFieldElement()}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
expect(document.activeElement).toBe(focusOwner);
act(() => root.unmount());
});
it("auto-focuses the Content textarea when a new text field is added", async () => {
let addResolved = false;
@@ -257,6 +257,7 @@ export function FlatTextSection({
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
);
const [autoFocusFieldKey, setAutoFocusFieldKey] = useState<string | null>(null);
useEffect(() => {
const nextFields = element.textFields;
@@ -266,6 +267,16 @@ export function FlatTextSection({
});
}, [element.id, element.selector, element.textFields]);
useEffect(() => {
setAutoFocusFieldKey(null);
}, [element.id, element.selector]);
useEffect(() => {
if (autoFocusFieldKey && autoFocusFieldKey === activeFieldKey) {
setAutoFocusFieldKey(null);
}
}, [activeFieldKey, autoFocusFieldKey]);
if (!isTextEditableSelection(element)) return null;
const textFields = element.textFields;
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
@@ -278,10 +289,15 @@ export function FlatTextSection({
fields={textFields}
activeFieldKey={activeField.key}
styles={styles}
onSelect={setActiveFieldKey}
onSelect={(fieldKey) => {
setAutoFocusFieldKey(null);
setActiveFieldKey(fieldKey);
}}
onAdd={() =>
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveFieldKey(nextKey);
if (!nextKey) return;
setAutoFocusFieldKey(nextKey);
setActiveFieldKey(nextKey);
})
}
onRemove={onRemoveTextField}
@@ -295,7 +311,7 @@ export function FlatTextSection({
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
autoFocus
autoFocus={autoFocusFieldKey === activeField.key}
/>
</div>
);
@@ -316,7 +332,11 @@ export function FlatTextSection({
type="button"
onClick={() => {
track("button", "Add text field");
void onAddTextField(activeField.key);
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (!nextKey) return;
setAutoFocusFieldKey(nextKey);
setActiveFieldKey(nextKey);
});
}}
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
@@ -14,8 +14,8 @@ const mocks = vi.hoisted(() => ({
actions: {
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
handleGsapUpdateMeta: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
@@ -116,6 +116,27 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
return { callbacks, unmount: () => act(() => root.unmount()) };
}
function arrangeClickedCircle(): {
circle: TimelineElement;
selection: { id: string; selector: string; sourceFile: string };
} {
const elementKey = "scenes/main.html#circle";
const circle: TimelineElement = {
...element,
id: "circle",
key: elementKey,
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 };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
@@ -163,10 +184,10 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});
it("safely no-ops a boundary drag while the tween is still flat", () => {
it("settles false for a boundary drag while the tween is still flat", async () => {
const view = renderCallbacks();
act(() => {
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
@@ -176,8 +197,8 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
animationId: flatAnimation.id,
},
25,
);
});
),
).resolves.toBe(false);
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
@@ -251,53 +272,33 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount();
});
// The diamond context menu opens on whatever diamond was clicked, which need
// not belong to the selected element, and it passes no explicit target — so
// the resolve falls back to the cache. Reading the SELECTED element's cache
// there resolves against the wrong element's keyframes.
it("resolves a cache fallback against the clicked element, not the selected one", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
sourceFile: "scenes/main.html",
};
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherKeyframedAnimation]]]),
keyframeCache: new Map([
// Decoy at the same clip-% under the selected element's key.
[
"box",
{
format: "percentage",
keyframes: [{ percentage: 100, tweenPercentage: 100, properties: { x: 420 } }],
},
],
[
"scenes/main.html#circle",
{
format: "percentage",
keyframes: [
{
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: otherKeyframedAnimation.id,
properties: { x: 420 },
},
],
},
],
]),
});
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
const { circle, selection } = arrangeClickedCircle();
const view = renderCallbacks();
await act(async () => {
view.callbacks.onMoveKeyframeToPlayhead?.("scenes/main.html#circle", { percentage: 100 });
view.callbacks.onDeleteAllKeyframes?.(circle);
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
selection,
);
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();
await act(async () => {
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
percentage: 100,
propertyGroup: "position",
tweenPercentage: 100,
animationId: otherKeyframedAnimation.id,
});
await Promise.resolve();
});
@@ -306,7 +307,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
circleSelection,
selection,
otherKeyframedAnimation,
);
view.unmount();
@@ -418,8 +419,8 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onMoveKeyframe?.(
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 50,
@@ -428,8 +429,8 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
animationId: authored.id,
},
75,
);
});
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
@@ -482,4 +483,42 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
);
view.unmount();
});
it("uses the clip timing basis when retiming a duration-less tween", async () => {
const durationless = {
...authoredInteriorAnimation(),
position: 3.2,
resolvedStart: 3.2,
duration: undefined,
};
const wideElement = { ...element, start: 10.94, duration: 16.26 };
mocks.animations = [durationless];
usePlayerStore.setState({
elements: [wideElement],
gsapAnimations: new Map([["box", [durationless]]]),
});
const view = renderCallbacks();
await expect(
view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 19.1,
propertyGroup: "position",
tweenPercentage: 50,
animationId: durationless.id,
},
40,
),
).resolves.toBe(true);
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
durationless.id,
50,
expect.any(Number),
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
});
@@ -15,7 +15,10 @@ import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
import type { DomEditSelection } from "../editor/domEditingTypes";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import { splitTimelineElementKey } from "../../player/lib/timelineElementHelpers";
import {
getTimelineElementIdentity,
splitTimelineElementKey,
} from "../../player/lib/timelineElementHelpers";
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
export interface TimelineEditCallbackDeps {
@@ -139,27 +142,28 @@ export function useTimelineEditCallbacks({
// anim in the keyframe's property group, falling back to the first keyframed one.
const resolveKeyframeTarget = useCallback(
(
elementKey: string,
target: TimelineKeyframeTarget,
animations: GsapAnimation[] = selectedGsapAnimations,
elementKey?: string,
): { animId: string; tweenPct: number } | null => {
const carriesIdentity =
target.propertyGroup !== undefined ||
target.tweenPercentage !== undefined ||
target.animationId !== undefined;
// The clicked element's own cache when the caller knows it: the diamond
// context menu can open on an element that is not the selected one, and
// reading the selection's cache there resolves against the wrong element.
const cached = usePlayerStore
.getState()
.keyframeCache.get(elementKey ?? domEditSelection?.id ?? "");
// The clicked element's own cache: the diamond context menu can open on an
// element that is not the selected one, and reading the selection's cache
// there resolves against the wrong element.
const keyframeCache = usePlayerStore.getState().keyframeCache;
const cached =
keyframeCache.get(elementKey) ??
keyframeCache.get(splitTimelineElementKey(elementKey).domId);
return resolveTimelineKeyframeTarget(
target.percentage,
carriesIdentity ? [target] : (cached?.keyframes ?? []),
animations,
);
},
[domEditSelection?.id, selectedGsapAnimations],
[selectedGsapAnimations],
);
const removeKeyframeTarget = useCallback(
@@ -190,17 +194,20 @@ export function useTimelineEditCallbacks({
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
onRazorSplitAll: handleRazorSplitAll,
onDeleteAllKeyframes: () => {
onDeleteAllKeyframes: (element) => {
// Hold the element where it is (collapse keyframes to a static set) rather
// than deleting the whole animation — deleting strands a stale GSAP base
// that the next drag adds to, flinging the element off-screen.
const anim = selectedGsapAnimations.find((a) => a.keyframes);
const elementKey = getTimelineElementIdentity(element);
const anim = resolveElementAnimations(elementKey).find((animation) => animation.keyframes);
if (!anim) return;
handleGsapRemoveAllKeyframes(anim.id);
void buildDomSelectionForTimelineElement(element).then((selection) => {
if (selection) handleGsapRemoveAllKeyframes(anim.id, selection);
});
},
onDeleteKeyframe: (elId, keyframe) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(keyframe, animations, elId);
const target = resolveKeyframeTarget(elId, keyframe, animations);
if (!target) return;
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
if (!element) {
@@ -211,7 +218,8 @@ export function useTimelineEditCallbacks({
// non-selected element (especially one in a different source file) commits
// against the right element instead of the current domEditSelection.
void buildDomSelectionForTimelineElement(element).then((selection) => {
removeKeyframeTarget(target.animId, target.tweenPct, animations, selection);
if (selection)
removeKeyframeTarget(target.animId, target.tweenPct, animations, selection);
});
},
// Retime the keyframe to the playhead, preserving its value + ease. The
@@ -219,15 +227,14 @@ export function useTimelineEditCallbacks({
// its selection commits it, and its animation computes the playhead
// percentage. Mixing frames here retimed against the selected element's
// tween and wrote the result into the clicked element's file.
onMoveKeyframeToPlayhead: (elId, keyframe) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(keyframe, animations, elId);
onMoveKeyframeToPlayhead: (element, keyframe) => {
const elementKey = getTimelineElementIdentity(element);
const animations = resolveElementAnimations(elementKey);
const target = resolveKeyframeTarget(elementKey, keyframe, animations);
const animation = target
? animations.find((candidate) => candidate.id === target.animId)
: undefined;
if (!target || !animation) return;
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
if (!element) return;
void buildDomSelectionForTimelineElement(element).then((selection) => {
if (selection) {
handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation);
@@ -244,7 +251,7 @@ export function useTimelineEditCallbacks({
// fallow-ignore-next-line complexity
onMoveKeyframe: async (elId, keyframe, toClipPct) => {
const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(keyframe, animations, elId);
const target = resolveKeyframeTarget(elId, keyframe, animations);
if (!target) return false;
// The dragged diamond's OWN element, not the selected one: a drag on a
// non-selected clip has to read that clip's animations and commit
@@ -254,12 +261,11 @@ export function useTimelineEditCallbacks({
if (!sel) return false;
const anim = animations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return false;
if (!anim || tweenStart === null) return Promise.resolve(false);
// Synthesized flat endpoints are clip boundaries, not authored keyframes.
// Boundary-to-clip resize wiring is intentionally deferred; ignore the
// drag rather than dispatching a free keyframe move that cannot be written.
if (!anim.keyframes) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
if (!anim.keyframes) return Promise.resolve(false);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
const { elStart, elDuration } = resolveClipTimingBasis(
@@ -268,6 +274,7 @@ export function useTimelineEditCallbacks({
elements,
domClipChildren,
);
const tweenDuration = resolveTweenDuration(anim, elDuration);
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
const decision = resolveKeyframeRetime({
keyframes: anim.keyframes?.keyframes ?? [],
@@ -277,35 +284,22 @@ export function useTimelineEditCallbacks({
dropAbsTime,
});
if (decision.kind === "move" && decision.toTweenPct != null) {
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
} else if (
decision.kind === "resize" &&
decision.pctRemap &&
decision.position != null &&
decision.duration != null
) {
if (anim.keyframes) {
handleGsapResizeKeyframedTween(
target.animId,
decision.position,
decision.duration,
decision.pctRemap,
sel,
);
} else {
// resize-keyframed-tween requires an authored `keyframes` AST node
// and intentionally no-ops for a flat tween. Update its real tween
// window through the metadata writer (and SDK cutover path) instead.
handleGsapUpdateMeta(
target.animId,
{ position: decision.position, duration: decision.duration },
sel,
);
}
} else {
return false;
return handleGsapResizeKeyframedTween(
target.animId,
decision.position,
decision.duration,
decision.pctRemap,
sel,
);
}
return true;
return Promise.resolve(false);
},
onChangeKeyframeEase: (elId: string, _pct: number, ease: string) => {
// The edited element's own animations + selection, not the selection's: