mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(studio): harden keyframe editing semantics
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user