mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
|
||||
import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
|
||||
let deleteKeyframesCommitCounter = 0;
|
||||
@@ -18,18 +18,31 @@ export function deleteSelectedKeyframes(session: {
|
||||
) => void;
|
||||
}): void {
|
||||
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
||||
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
|
||||
if (!animation) return;
|
||||
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
||||
const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
|
||||
if (!selectedElementId) return;
|
||||
const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes);
|
||||
const fallbackAnimation = keyframedAnimations[0];
|
||||
const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation]));
|
||||
const removals = new Map<string, { animationId: string; percentage: number }>();
|
||||
for (const key of selectedKeyframes) {
|
||||
const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key);
|
||||
if (!target) continue;
|
||||
const animation = target.animationId
|
||||
? animationsById.get(target.animationId)
|
||||
: fallbackAnimation;
|
||||
if (!animation) continue;
|
||||
const percentage = target.tweenPercentage ?? target.percentage;
|
||||
removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage });
|
||||
}
|
||||
const targets = [...removals.values()];
|
||||
if (targets.length === 0) return;
|
||||
const coalesceOptions = {
|
||||
coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
|
||||
coalesceMs: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
for (const [index, pct] of percentages.entries()) {
|
||||
session.handleGsapRemoveKeyframe(animation.id, pct, {
|
||||
for (const [index, target] of targets.entries()) {
|
||||
session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
|
||||
...coalesceOptions,
|
||||
...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,21 @@ import {
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
|
||||
export function buildTemporalArcKeyframes(
|
||||
anim: GsapAnimation,
|
||||
percentage: number,
|
||||
properties: Record<string, number>,
|
||||
) {
|
||||
return [
|
||||
...(anim.keyframes?.keyframes ?? []).map((keyframe) => ({
|
||||
percentage: keyframe.percentage,
|
||||
properties: { ...keyframe.properties },
|
||||
...(keyframe.ease ? { ease: keyframe.ease } : {}),
|
||||
})),
|
||||
{ percentage, properties },
|
||||
].sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
async function extendTweenAndAddKeyframe(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
@@ -259,6 +274,47 @@ export async function commitGsapPositionFromDrag(
|
||||
|
||||
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
if (anim.arcPath?.enabled) {
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
const keyframes = anim.keyframes?.keyframes ?? [];
|
||||
const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05);
|
||||
if (pointIndex >= 0) {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: anim.id,
|
||||
pointIndex,
|
||||
x: newX,
|
||||
y: newY,
|
||||
},
|
||||
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
setActiveKeyframePct(null);
|
||||
parkPlayheadOnKeyframe(anim, pct);
|
||||
return;
|
||||
}
|
||||
|
||||
const tweenStart = resolveTweenStart(anim);
|
||||
const tweenDuration = resolveTweenDuration(anim);
|
||||
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
|
||||
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(tweenStart),
|
||||
duration: roundTo3(tweenDuration),
|
||||
keyframes: temporalKeyframes,
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
|
||||
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
||||
expect(types).not.toContain("replace-with-keyframes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryGsapDragIntercept — motion paths", () => {
|
||||
const motionPathAnim = {
|
||||
id: "#puck-b-to-12170-position",
|
||||
targetSelector: "#puck-b",
|
||||
propertyGroup: "position",
|
||||
method: "to",
|
||||
position: 12.17,
|
||||
resolvedStart: 12.17,
|
||||
duration: 16.055,
|
||||
ease: "power1.inOut",
|
||||
properties: {},
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
const liveTween = {
|
||||
targets: () => [{ id: "puck-b" }],
|
||||
vars: { motionPath: { path: [] }, duration: 16.055 },
|
||||
duration: () => 16.055,
|
||||
startTime: () => 12.17,
|
||||
};
|
||||
|
||||
async function dragMotionPath(activeKeyframePct: number | null) {
|
||||
usePlayerStore.setState({
|
||||
autoKeyframeEnabled: true,
|
||||
activeKeyframePct,
|
||||
currentTime: 15.9,
|
||||
});
|
||||
const commitMutation = vi.fn();
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
{ x: -50, y: 30 },
|
||||
[motionPathAnim],
|
||||
fakeIframe("puck-b", [liveTween]),
|
||||
commitMutation,
|
||||
);
|
||||
return { commitMutation, handled };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({ activeKeyframePct: null });
|
||||
});
|
||||
|
||||
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(null);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: motionPathAnim.id,
|
||||
targetSelector: "#puck-b",
|
||||
position: 12.17,
|
||||
duration: 16.055,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 23.233, properties: { x: -50, y: 30 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"add-motion-path-point",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(50);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: motionPathAnim.id,
|
||||
pointIndex: 1,
|
||||
x: -50,
|
||||
y: 30,
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"replace-with-keyframes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface MutationResult {
|
||||
|
||||
export interface CommitMutationOptions {
|
||||
label: string;
|
||||
/** Observe the durable writer result without duplicating the request path. */
|
||||
onResult?: (result: MutationResult) => void;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
softReload?: boolean;
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
idFromSelector,
|
||||
idSelector,
|
||||
isInstantHold,
|
||||
parsePercentageKeyframes,
|
||||
resolveEditableTweenDuration,
|
||||
toClipKeyframes,
|
||||
toClipPercentage,
|
||||
} from "./gsapShared";
|
||||
|
||||
describe("resolveEditableTweenDuration", () => {
|
||||
const selection = { dataAttributes: { duration: "16.26" } } as DomEditSelection;
|
||||
|
||||
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: undefined } as GsapAnimation, selection)).toBe(
|
||||
16.26,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly-authored tween duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: 4 } as GsapAnimation, selection)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInstantHold", () => {
|
||||
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
||||
({ method, duration }) as unknown as GsapAnimation;
|
||||
|
||||
@@ -109,6 +109,22 @@ export function selectorFromSelection(selection: DomEditSelection): string | nul
|
||||
|
||||
// ── Percentage computation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
||||
* duration-less tween across its owning clip, so mutations must use that same
|
||||
* duration instead of silently falling back to GSAP's 0.5s default.
|
||||
*/
|
||||
export function resolveEditableTweenDuration(
|
||||
animation: GsapAnimation,
|
||||
selection: DomEditSelection,
|
||||
): number {
|
||||
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
||||
return resolveTweenDuration(
|
||||
animation,
|
||||
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current playback percentage within an element's animation range.
|
||||
* Uses the animation's resolved timing if available, otherwise falls back to
|
||||
@@ -121,7 +137,7 @@ export function computeElementPercentage(
|
||||
): number {
|
||||
if (animation) {
|
||||
const start = resolveTweenStart(animation);
|
||||
const duration = resolveTweenDuration(animation);
|
||||
const duration = resolveEditableTweenDuration(animation, selection);
|
||||
if (duration <= 0) return 0;
|
||||
if (start !== null) {
|
||||
return absoluteToPercentage(currentTime, start, duration);
|
||||
@@ -129,9 +145,7 @@ export function computeElementPercentage(
|
||||
}
|
||||
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
return absoluteToPercentage(currentTime, elStart, elDuration);
|
||||
}
|
||||
|
||||
// ── Iframe accessors ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
@@ -342,4 +343,51 @@ describe("deleteSelectedKeyframes", () => {
|
||||
expect(options[1]).not.toHaveProperty("softReload");
|
||||
expect(options[2]).not.toHaveProperty("skipReload");
|
||||
});
|
||||
|
||||
it("deletes two expanded lanes through their own animation and tween percentages", () => {
|
||||
usePlayerStore.setState({
|
||||
selectedElementId: "card",
|
||||
selectedKeyframes: new Set([
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 30,
|
||||
tweenPercentage: 20,
|
||||
propertyGroup: "position",
|
||||
animationId: "card-position",
|
||||
}),
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 70,
|
||||
tweenPercentage: 80,
|
||||
propertyGroup: "visual",
|
||||
animationId: "card-visual",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const handleGsapRemoveKeyframe =
|
||||
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
||||
|
||||
deleteSelectedKeyframes({
|
||||
selectedGsapAnimations: [
|
||||
{ id: "card-position", keyframes: {} },
|
||||
{ id: "card-visual", keyframes: {} },
|
||||
],
|
||||
handleGsapRemoveKeyframe,
|
||||
});
|
||||
|
||||
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
|
||||
animationId,
|
||||
percentage,
|
||||
]),
|
||||
).toEqual([
|
||||
["card-position", 20],
|
||||
["card-visual", 80],
|
||||
]);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ skipReload: true }),
|
||||
);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ softReload: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
applyArcKeyframeAtPlayhead,
|
||||
animatedProps,
|
||||
buildExtendedKeyframes,
|
||||
isPlayheadWithinTween,
|
||||
@@ -219,6 +220,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyArcKeyframeAtPlayhead", () => {
|
||||
const arcAnim = anim({
|
||||
id: "#el-to-0-position",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: {
|
||||
format: "object-array",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
function arcFixture(x: number, y: number) {
|
||||
const commitMutation = vi.fn(async () => undefined);
|
||||
const session = { commitMutation } as unknown as EnableKeyframesSession;
|
||||
const sel = {
|
||||
id: "el",
|
||||
selector: "#el",
|
||||
element: { isConnected: true } as HTMLElement,
|
||||
dataAttributes: { duration: "10" },
|
||||
} as DomEditSelection;
|
||||
const iframe = {
|
||||
contentWindow: {
|
||||
gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) },
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
return { commitMutation, iframe, sel, session };
|
||||
}
|
||||
|
||||
it("removes an existing interior stop without redistributing the remaining times", async () => {
|
||||
const fixture = arcFixture(50, 50);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the path endpoints", async () => {
|
||||
const fixture = arcFixture(0, 0);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe);
|
||||
expect(fixture.commitMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 25, properties: { x: 25, y: 25 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the owning clip duration when an arc omits its outer duration", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
const durationlessArc = { ...arcAnim, duration: undefined };
|
||||
|
||||
await applyArcKeyframeAtPlayhead(
|
||||
fixture.session,
|
||||
fixture.sel,
|
||||
durationlessArc,
|
||||
2.5,
|
||||
fixture.iframe,
|
||||
);
|
||||
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "replace-with-keyframes",
|
||||
duration: 10,
|
||||
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
||||
}),
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
|
||||
let enable: (() => Promise<void>) | null = null;
|
||||
function Probe() {
|
||||
|
||||
@@ -12,16 +12,22 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
||||
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
|
||||
import {
|
||||
selectorFromSelection,
|
||||
computeElementPercentage,
|
||||
isInstantHold,
|
||||
resolveEditableTweenDuration,
|
||||
} from "./gsapShared";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
resolveTweenStart,
|
||||
resolveTweenDuration,
|
||||
isTimeWithinTween,
|
||||
} from "../utils/globalTimeCompiler";
|
||||
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
|
||||
|
||||
let enableKeyframesTransactionCounter = 0;
|
||||
|
||||
@@ -89,9 +95,10 @@ export function buildExtendedKeyframes(
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
position: Record<string, number>,
|
||||
sourceDuration = resolveTweenDuration(anim),
|
||||
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
||||
const oldStart = resolveTweenStart(anim) ?? 0;
|
||||
const oldDuration = resolveTweenDuration(anim);
|
||||
const oldDuration = sourceDuration;
|
||||
const newStart = Math.min(oldStart, currentTime);
|
||||
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
||||
const newDuration = roundTo3(newEnd - newStart);
|
||||
@@ -222,6 +229,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
|
||||
return (await tryFetchAnimationsForElement(sel)) ?? [];
|
||||
}
|
||||
|
||||
async function extendKeyframedTweenToPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
const selector = selectorFromSelection(sel);
|
||||
const position = readElementPosition(iframe, sel, anim);
|
||||
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
||||
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: anim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
|
||||
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
|
||||
@@ -237,31 +275,22 @@ async function applyKeyframeAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
if (!isPlayheadWithinTween(kfAnim, t)) {
|
||||
const position = readElementPosition(iframe, sel, kfAnim);
|
||||
const selector = selectorFromSelection(sel);
|
||||
if (selector && Object.keys(position).length > 0 && session.commitMutation) {
|
||||
const extended = buildExtendedKeyframes(kfAnim, t, position);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: kfAnim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: kfAnim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
const duration = resolveEditableTweenDuration(kfAnim, sel);
|
||||
const start = resolveTweenStart(kfAnim);
|
||||
if (start !== null && !isTimeWithinTween(t, start, duration)) {
|
||||
await extendKeyframedTweenToPlayhead(
|
||||
session,
|
||||
sel,
|
||||
kfAnim,
|
||||
t,
|
||||
duration,
|
||||
iframe,
|
||||
commitOverrides,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pct = computeElementPercentage(t, sel, kfAnim);
|
||||
const pct =
|
||||
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
|
||||
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
@@ -332,14 +361,13 @@ export async function promoteSetToKeyframes(
|
||||
}
|
||||
|
||||
/**
|
||||
* An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
|
||||
* it must be edited as waypoints (not x/y keyframes, which would break the curve).
|
||||
* "Add keyframe at playhead" drops a waypoint where the element currently sits on
|
||||
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
||||
* range, extend the duration so the motion reaches the playhead.
|
||||
* Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the
|
||||
* playhead stop. A toolbar command named "Add keyframe at playhead" must preserve
|
||||
* every authored stop's time; inserting a spatial waypoint instead redistributes
|
||||
* the path and can silently compress the animation.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function applyArcWaypointAtPlayhead(
|
||||
export async function applyArcKeyframeAtPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
arcAnim: GsapAnimation,
|
||||
@@ -347,8 +375,11 @@ async function applyArcWaypointAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
): Promise<void> {
|
||||
if (!session.commitMutation) return;
|
||||
if (!isPlayheadWithinTween(arcAnim, t)) {
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const targetSelector = selectorFromSelection(sel);
|
||||
if (!targetSelector) return;
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
||||
if (!isTimeWithinTween(t, start, duration)) {
|
||||
if (t > start) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
@@ -361,30 +392,45 @@ async function applyArcWaypointAtPlayhead(
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nodes = arcAnim.keyframes?.keyframes ?? [];
|
||||
const playheadPercentage = absoluteToPercentage(t, start, duration);
|
||||
const timedNodeIndex = nodes.findIndex(
|
||||
(node) => Math.abs(node.percentage - playheadPercentage) <= 1,
|
||||
);
|
||||
if (timedNodeIndex !== -1) {
|
||||
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: nodes.filter((_, index) => index !== timedNodeIndex),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const live = readElementPosition(iframe, sel, arcAnim);
|
||||
if (typeof live.x !== "number" || typeof live.y !== "number") return;
|
||||
const liveX = live.x;
|
||||
const liveY = live.y;
|
||||
const nodes = (arcAnim.keyframes?.keyframes ?? [])
|
||||
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
|
||||
.filter(
|
||||
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
|
||||
);
|
||||
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
|
||||
// path endpoints).
|
||||
const WAYPOINT_MERGE_PX = 6;
|
||||
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
|
||||
const proj = nearestPointOnPath(liveX, liveY, nodes);
|
||||
if (!proj) return;
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "add-motion-path-point",
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
index: proj.segIndex + 1,
|
||||
x: liveX,
|
||||
y: liveY,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, {
|
||||
x: live.x,
|
||||
y: live.y,
|
||||
}),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add waypoint", softReload: true },
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -420,7 +466,7 @@ export function useEnableKeyframes(
|
||||
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
|
||||
|
||||
if (arcAnim) {
|
||||
await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
} else if (kfAnim) {
|
||||
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
|
||||
} else if (setAnim) {
|
||||
|
||||
@@ -20,7 +20,12 @@ afterEach(() => {
|
||||
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
||||
|
||||
function successfulCommitMutation() {
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({ ok: true }));
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
||||
const options = args[2] as {
|
||||
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
||||
};
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyframeOps(over: {
|
||||
@@ -54,6 +59,18 @@ function renderKeyframeOps(over: {
|
||||
return captured.api;
|
||||
}
|
||||
|
||||
async function moveKeyframeWith(
|
||||
commitMutation: (...args: unknown[]) => Promise<unknown>,
|
||||
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
|
||||
const trackGsapSaveFailure = vi.fn();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
|
||||
});
|
||||
return { committed, trackGsapSaveFailure };
|
||||
}
|
||||
|
||||
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
||||
const commitMutation = successfulCommitMutation();
|
||||
@@ -64,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 100, to: 100 },
|
||||
];
|
||||
let committed = false;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
});
|
||||
|
||||
expect(commitMutation).toHaveBeenCalledTimes(1);
|
||||
@@ -79,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
pctRemap,
|
||||
});
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
expect(committed).toBe(true);
|
||||
});
|
||||
|
||||
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
||||
@@ -89,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
|
||||
// let the rejected commit promise settle inside act
|
||||
await Promise.resolve();
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
||||
@@ -101,6 +121,46 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
expect(selArg).toBe(selection);
|
||||
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
||||
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
||||
expect(committed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
||||
it("returns false when the commit settles without a durable writer result", async () => {
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
||||
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
||||
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
||||
options.onResult?.({ ok: true, changed: false });
|
||||
});
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and tracks a rejected move", async () => {
|
||||
const error = new Error("write failed");
|
||||
const commitMutation = vi.fn().mockRejectedValue(error);
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
|
||||
error,
|
||||
selection,
|
||||
{
|
||||
type: "move-keyframe",
|
||||
animationId: "box-to-0-position",
|
||||
fromPercentage: 50,
|
||||
toPercentage: 75,
|
||||
},
|
||||
"Move keyframe to 75%",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export function useGsapKeyframeOps({
|
||||
);
|
||||
|
||||
const moveKeyframe = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
fromPercentage: number,
|
||||
@@ -247,18 +247,26 @@ export function useGsapKeyframeOps({
|
||||
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
|
||||
// optimistic cache write is needed (mapping the tween-% to clip-% here would
|
||||
// duplicate that math). softReload mirrors remove-keyframe.
|
||||
void commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
|
||||
const resizeKeyframedTween = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
position: number,
|
||||
@@ -275,12 +283,20 @@ export function useGsapKeyframeOps({
|
||||
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
|
||||
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
|
||||
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
|
||||
void commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
|
||||
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
||||
});
|
||||
|
||||
it("publishes the server mutation outcome to callers", async () => {
|
||||
mockFetchResult({ changed: false });
|
||||
const deps = renderCommitHook();
|
||||
let commitResult: MutationResult | undefined;
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
|
||||
{
|
||||
label: "Move keyframe",
|
||||
onResult: (result) => {
|
||||
commitResult = result;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
|
||||
});
|
||||
|
||||
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
mockFetchResult({ changed: false });
|
||||
|
||||
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScript(pid, targetPath, mutation),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScriptBatch(pid, targetPath, mutations),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
|
||||
addKeyframe: vi.fn(),
|
||||
addKeyframeBatch: resolved(),
|
||||
removeKeyframe: vi.fn(),
|
||||
moveKeyframe: vi.fn(),
|
||||
resizeKeyframedTween: vi.fn(),
|
||||
moveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
convertToKeyframes: resolved(),
|
||||
removeAllKeyframes: resolved(),
|
||||
handleDomManualEditsReset: vi.fn(),
|
||||
@@ -144,3 +144,22 @@ describe("useGsapSelectionHandlers selection override", () => {
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapSelectionHandlers retime settlement", () => {
|
||||
it("returns false without a selection and forwards the mutation result with one", async () => {
|
||||
const moveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
|
||||
await expect(
|
||||
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
|
||||
).resolves.toBe(false);
|
||||
expect(moveKeyframe).not.toHaveBeenCalled();
|
||||
withoutSelection.unmount();
|
||||
|
||||
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
|
||||
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(moveKeyframe).toHaveBeenCalledOnce();
|
||||
withSelection.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
@@ -355,7 +355,7 @@ export function useGsapSelectionHandlers({
|
||||
const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
|
||||
const toPercentage = computeCurrentPercentage(sel, anim);
|
||||
trackStudioEvent("keyframe", { action: "move_to_playhead" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
void moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
|
||||
);
|
||||
@@ -368,13 +368,13 @@ export function useGsapSelectionHandlers({
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
|
||||
// percentages are tween-relative (the drag handler converts the drop
|
||||
// position before calling). No optimistic runtime hold — the soft-reload
|
||||
// re-keys the diamond from source.
|
||||
trackStudioEvent("keyframe", { action: "retime" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
return moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[resolveWriteSelection, moveKeyframe],
|
||||
);
|
||||
@@ -388,11 +388,11 @@ export function useGsapSelectionHandlers({
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
|
||||
// in place. Distinct telemetry action so resize is separable from in-window move.
|
||||
trackStudioEvent("keyframe", { action: "retime_resize" });
|
||||
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
},
|
||||
[resolveWriteSelection, resizeKeyframedTween],
|
||||
);
|
||||
@@ -417,11 +417,12 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const selection = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!selection) return;
|
||||
observeGsapMutation(
|
||||
removeAllKeyframes(domEditSelection, animId),
|
||||
domEditSelection,
|
||||
removeAllKeyframes(selection, animId),
|
||||
selection,
|
||||
"remove-all-keyframes",
|
||||
"Remove all keyframes",
|
||||
);
|
||||
|
||||
@@ -10,6 +10,16 @@ interface StudioTestHookDeps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface StudioTestApi {
|
||||
selectByDomId: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__studioTest?: StudioTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only headless-QA shortcut. Selecting an element normally requires a
|
||||
* pixel-precise click inside the preview iframe, which automated verification
|
||||
@@ -33,7 +43,7 @@ export function useStudioTestHooks({
|
||||
isDev = false;
|
||||
}
|
||||
if (!isDev || typeof window === "undefined") return;
|
||||
const api = {
|
||||
const api: StudioTestApi = {
|
||||
selectByDomId: async (id: string): Promise<boolean> => {
|
||||
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
||||
if (!element) return false;
|
||||
@@ -43,11 +53,11 @@ export function useStudioTestHooks({
|
||||
return true;
|
||||
},
|
||||
};
|
||||
(window as unknown as { __studioTest?: typeof api }).__studioTest = api;
|
||||
window.__studioTest = api;
|
||||
return () => {
|
||||
// delete, not `= undefined`: an own key holding undefined keeps
|
||||
// `"__studioTest" in window` true, which defeats feature detection.
|
||||
delete (window as unknown as { __studioTest?: typeof api }).__studioTest;
|
||||
delete window.__studioTest;
|
||||
};
|
||||
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
export interface KeyframeDiamondContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
element: TimelineElement;
|
||||
elementId: string;
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
@@ -23,12 +25,12 @@ interface KeyframeDiamondContextMenuProps {
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
) => void;
|
||||
onDeleteAll: (elementId: string) => void;
|
||||
onDeleteAll: (element: TimelineElement) => 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,
|
||||
element: TimelineElement,
|
||||
fromPercentage: number,
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
@@ -66,7 +68,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
// 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.element,
|
||||
state.percentage,
|
||||
state.propertyGroup,
|
||||
state.tweenPercentage,
|
||||
@@ -101,7 +103,7 @@ 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={() => {
|
||||
onDeleteAll(state.elementId);
|
||||
onDeleteAll(state.element);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -435,6 +435,9 @@ export const Timeline = memo(function Timeline({
|
||||
onDragLeave={() => clearDropPreview()}
|
||||
onDrop={handleAssetDrop}
|
||||
onPointerDown={(e) => {
|
||||
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
||||
// their own clicks — scrubbing here would preventDefault and eat them.
|
||||
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
|
||||
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
const x =
|
||||
@@ -512,15 +515,15 @@ export const Timeline = memo(function Timeline({
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onContextMenuKeyframe={(e, elId, pct) => {
|
||||
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
if (!el) return;
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
setKfContextMenu({
|
||||
x: e.clientX + 4,
|
||||
y: e.clientY + 2,
|
||||
element: el,
|
||||
elementId: elId,
|
||||
percentage: pct,
|
||||
tweenPercentage: kf?.tweenPercentage,
|
||||
|
||||
@@ -45,6 +45,42 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("TimelineClipDiamonds", () => {
|
||||
it("keeps dense keyframe hit regions and visuals from overlapping", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [30, 60, 90].map((percentage) => ({
|
||||
percentage,
|
||||
propertyGroup: "position",
|
||||
properties: { x: percentage },
|
||||
})),
|
||||
}}
|
||||
clipWidthPx={36}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button[title]"));
|
||||
expect(diamonds).toHaveLength(3);
|
||||
for (const diamond of diamonds) {
|
||||
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(10.8);
|
||||
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
|
||||
}
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("treats primary pointerup without drag as a keyframe click", () => {
|
||||
const { host, root, onClickKeyframe } = renderDiamonds();
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
@@ -516,6 +552,18 @@ describe("TimelineClipDiamonds", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the ease button above nearby diamonds without blocking the segment", () => {
|
||||
const { host, root } = renderSegmentLane(false);
|
||||
const segment = host.querySelector<HTMLElement>("[data-keyframe-ease-segment]");
|
||||
const ease = segment?.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
|
||||
expect(segment?.style.pointerEvents).toBe("none");
|
||||
expect(ease?.style.pointerEvents).toBe("auto");
|
||||
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
||||
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.
|
||||
|
||||
@@ -183,10 +183,6 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
cancelPreviewFrame();
|
||||
};
|
||||
}, []);
|
||||
// Index of the segment whose mid-point ease button is revealed on hover, like
|
||||
// Figma. Null = no segment hovered → no button shown (resting state is just
|
||||
// the connector line + diamonds).
|
||||
const [hoveredSegment, setHoveredSegment] = useState<number | null>(null);
|
||||
// The button element can re-render (reposition/unmount) synchronously from
|
||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||
// browser gets to auto-synthesize the "click" event that normally follows
|
||||
@@ -213,7 +209,6 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
const diamondSize = beatsActive
|
||||
? Math.round(clipHeightPx * 0.45)
|
||||
: Math.round(LANE_H * DIAMOND_RATIO);
|
||||
const half = diamondSize / 2;
|
||||
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
||||
const sorted = keyframesData.keyframes
|
||||
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
||||
@@ -221,6 +216,20 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
|
||||
// the whole row to bound the dragged diamond between its immediate siblings.
|
||||
const sortedClipPcts = sorted.map((k) => k.percentage);
|
||||
const sortedCenterXs = sorted.map((keyframe) =>
|
||||
Math.max(0, Math.min(clipWidthPx, (keyframe.percentage / 100) * clipWidthPx)),
|
||||
);
|
||||
const markerMetrics = sortedCenterXs.map((centerX, index) => {
|
||||
const previousGap = index > 0 ? centerX - sortedCenterXs[index - 1]! : Infinity;
|
||||
const nextGap =
|
||||
index < sortedCenterXs.length - 1 ? sortedCenterXs[index + 1]! - centerX : Infinity;
|
||||
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
||||
const hitWidth = Math.min(diamondSize, nearestGap);
|
||||
return {
|
||||
hitWidth,
|
||||
visualSize: hitWidth === diamondSize ? diamondSize : Math.max(2, hitWidth - 2),
|
||||
};
|
||||
});
|
||||
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
@@ -240,11 +249,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
}}
|
||||
>
|
||||
{sorted.map((kf, i) => {
|
||||
const prev = sorted[i - 1];
|
||||
if (!prev) return null;
|
||||
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
||||
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
||||
if (i === 0) return null;
|
||||
const prev = sorted[i - 1]!;
|
||||
const x1 = sortedCenterXs[i - 1]!;
|
||||
const x2 = sortedCenterXs[i]!;
|
||||
if (x2 - x1 < 1) return null;
|
||||
const connectorLeft = x1 + markerMetrics[i - 1]!.visualSize / 2;
|
||||
const connectorWidth =
|
||||
x2 - x1 - markerMetrics[i - 1]!.visualSize / 2 - markerMetrics[i]!.visualSize / 2;
|
||||
// Group-aware target for the ease button: the segment ease is
|
||||
// per-keyframe (each keyframe carries its own animationId/tweenPercentage).
|
||||
// On a merged inline row the button is hidden where the segment is
|
||||
@@ -259,9 +271,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
className="absolute"
|
||||
data-keyframe-connector={groupAware ? "" : undefined}
|
||||
style={{
|
||||
left: x1,
|
||||
left: connectorLeft,
|
||||
top: centerY,
|
||||
width: x2 - x1,
|
||||
width: Math.max(0, connectorWidth),
|
||||
height: 2,
|
||||
transform: "translateY(-1px)",
|
||||
background: baseColor,
|
||||
@@ -271,7 +283,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
/>
|
||||
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
|
||||
<div
|
||||
className="absolute"
|
||||
className="group absolute"
|
||||
data-keyframe-ease-segment=""
|
||||
style={{
|
||||
left: x1,
|
||||
@@ -279,40 +291,43 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
width: x2 - x1,
|
||||
height: 18,
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "auto",
|
||||
// Own a stacking context above the diamond buttons. At fit
|
||||
// zoom the 16px ease control can overlap its neighbouring
|
||||
// diamond; without a z-index here the later diamond wins the
|
||||
// hit test even though the child button has z-index 3.
|
||||
zIndex: 3,
|
||||
// Only the centered control is interactive. The transparent
|
||||
// segment wrapper must not swallow connector/clip gestures.
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSegment(i)}
|
||||
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}
|
||||
>
|
||||
{hoveredSegment === i && (
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-ease-button=""
|
||||
aria-label={`Edit ${ease} easing`}
|
||||
title={`Edit ${ease} easing`}
|
||||
className="absolute flex items-center justify-center rounded"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 3,
|
||||
pointerEvents: "auto",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(255, 255, 255, 0.14)",
|
||||
background: "#171717",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectSegment(target);
|
||||
}}
|
||||
>
|
||||
<MiniCurveSvg ease={ease} active size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-ease-button=""
|
||||
aria-label={`Edit ${ease} easing`}
|
||||
title={`Edit ${ease} easing`}
|
||||
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 3,
|
||||
pointerEvents: "auto",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(255, 255, 255, 0.14)",
|
||||
background: "#171717",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectSegment(target);
|
||||
}}
|
||||
>
|
||||
<MiniCurveSvg ease={ease} active size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -324,12 +339,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
||||
// While dragging this diamond, render it at the live preview clip-%.
|
||||
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half, so the
|
||||
// diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
||||
// Center the marker's non-overlapping hit region ON its keyframe %, so
|
||||
// the diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
||||
// The 0% diamond's left half lands in the reserved left gutter (the
|
||||
// content origin is inset past the label column, Figma-style) so it stays
|
||||
// fully visible instead of being clipped by the sticky label column.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - half;
|
||||
const marker = markerMetrics[i]!;
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
const isHighlighted = isKfSelected || atPlayhead;
|
||||
@@ -480,7 +496,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
left: leftPx,
|
||||
top: centerY,
|
||||
transform: "translateY(-50%)",
|
||||
width: diamondSize,
|
||||
width: marker.hitWidth,
|
||||
height: diamondSize,
|
||||
zIndex: isHighlighted ? 2 : 1,
|
||||
pointerEvents: "auto",
|
||||
@@ -489,6 +505,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
cursor: canDrag ? "ew-resize" : "pointer",
|
||||
padding: 0,
|
||||
touchAction: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -510,7 +530,12 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
}}
|
||||
title={`${kf.percentage}%`}
|
||||
>
|
||||
<svg width={diamondSize} height={diamondSize} viewBox="0 0 10 10">
|
||||
<svg
|
||||
width={marker.visualSize}
|
||||
height={marker.visualSize}
|
||||
viewBox="0 0 10 10"
|
||||
style={{ flexShrink: 0, pointerEvents: "none" }}
|
||||
>
|
||||
{isKfSelected && (
|
||||
<path
|
||||
d="M5 0L10 5L5 10L0 5Z"
|
||||
|
||||
@@ -112,13 +112,7 @@ function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] {
|
||||
);
|
||||
}
|
||||
|
||||
// The mid-segment ease button is revealed on hover (Figma parity), so tests must
|
||||
// hover the segment strip before its button exists. React derives onMouseEnter
|
||||
// from a bubbling mouseover, so dispatching that is what arms the hover.
|
||||
function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null {
|
||||
act(() => {
|
||||
segment.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
return segment.querySelector<HTMLButtonElement>("button[data-keyframe-ease-button]");
|
||||
}
|
||||
|
||||
@@ -281,7 +275,7 @@ describe("TimelinePropertyLanes", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("reveals one midpoint ease button per segment on hover, regardless of selection", () => {
|
||||
it("keeps one accessible midpoint ease button per segment, regardless of selection", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
@@ -295,12 +289,15 @@ describe("TimelinePropertyLanes", () => {
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]);
|
||||
expect(laneDiamonds(host, "position")).toHaveLength(3);
|
||||
// Resting state: no button until a segment is hovered.
|
||||
expect(laneEaseButtons(host, "position")).toHaveLength(0);
|
||||
|
||||
// Hovering reveals exactly one button — the hovered segment's.
|
||||
expect(revealEaseButton(segments[0]!)).not.toBeNull();
|
||||
expect(laneEaseButtons(host, "position")).toHaveLength(1);
|
||||
const buttons = laneEaseButtons(host, "position");
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true);
|
||||
expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(buttons.every((button) => button.classList.contains("focus-visible:opacity-100"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The ease button is available on hover even when the element is NOT selected
|
||||
// (a lane shows for the track's active/primary clip, not only the selected one).
|
||||
|
||||
@@ -73,9 +73,9 @@ export interface TimelineEditCallbacks {
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
||||
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onDeleteAllKeyframes?: (element: TimelineElement) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframeToPlayhead?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
||||
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|
||||
onMoveKeyframe?: (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
timelineKeyframeSelectionKey,
|
||||
timelineKeyframeTargetFromSelectionKey,
|
||||
} from "./timelineKeyframeIdentity";
|
||||
|
||||
describe("timeline keyframe selection identity", () => {
|
||||
it("round-trips an expanded lane with colon-bearing identities", () => {
|
||||
const key = timelineKeyframeSelectionKey("comp#a:child", {
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child:position",
|
||||
});
|
||||
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child:position",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not confuse an expanded lane whose element id extends the active id", () => {
|
||||
const key = timelineKeyframeSelectionKey("comp#a:child", {
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child-position",
|
||||
});
|
||||
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull();
|
||||
});
|
||||
|
||||
it("retains the collapsed key fallback and rejects malformed percentages", () => {
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({
|
||||
percentage: 30,
|
||||
});
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull();
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,50 @@ export function timelineKeyframeSelectionKey(
|
||||
target: TimelineKeyframeTarget,
|
||||
): string {
|
||||
if (!target.propertyGroup) return `${elementId}:${target.percentage}`;
|
||||
const groupKey = target.animationId
|
||||
? `${target.propertyGroup}:${target.animationId}`
|
||||
: target.propertyGroup;
|
||||
return `${elementId}:${groupKey}:${target.percentage}`;
|
||||
return JSON.stringify([
|
||||
elementId,
|
||||
target.propertyGroup,
|
||||
target.animationId ?? "",
|
||||
target.percentage,
|
||||
target.tweenPercentage ?? target.percentage,
|
||||
]);
|
||||
}
|
||||
|
||||
export function timelineKeyframeTargetFromSelectionKey(
|
||||
elementId: string,
|
||||
key: string,
|
||||
): TimelineKeyframeTarget | null {
|
||||
if (key.startsWith("[")) {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(decoded) || decoded.length !== 5) return null;
|
||||
const [selectedElementId, propertyGroup, animationId, percentage, tweenPercentage] = decoded;
|
||||
if (
|
||||
selectedElementId !== elementId ||
|
||||
typeof propertyGroup !== "string" ||
|
||||
propertyGroup.length === 0 ||
|
||||
typeof animationId !== "string" ||
|
||||
typeof percentage !== "number" ||
|
||||
!Number.isFinite(percentage) ||
|
||||
typeof tweenPercentage !== "number" ||
|
||||
!Number.isFinite(tweenPercentage)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
propertyGroup,
|
||||
animationId: animationId || undefined,
|
||||
percentage,
|
||||
tweenPercentage,
|
||||
};
|
||||
}
|
||||
|
||||
const separator = key.lastIndexOf(":");
|
||||
if (separator < 0 || key.slice(0, separator) !== elementId) return null;
|
||||
const percentage = Number(key.slice(separator + 1));
|
||||
return Number.isFinite(percentage) ? { percentage } : null;
|
||||
}
|
||||
|
||||
@@ -77,10 +77,9 @@ export function useTimelineKeyframeHandlers({
|
||||
const onContextMenuKeyframe = useCallback(
|
||||
(e: ReactMouseEvent, elId: string, target: TimelineKeyframeTarget) => {
|
||||
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
if (!el) return;
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find(
|
||||
(item) => Math.abs(item.percentage - target.percentage) < 0.2,
|
||||
@@ -93,6 +92,7 @@ export function useTimelineKeyframeHandlers({
|
||||
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
||||
propertyGroup: target.propertyGroup,
|
||||
animationId: target.animationId,
|
||||
element: el,
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -31,6 +31,12 @@ describe("absoluteToPercentage", () => {
|
||||
expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
|
||||
});
|
||||
|
||||
test("preserves playhead timing beyond tenths of a percent", () => {
|
||||
const percentage = absoluteToPercentage(17, 12.17, 20);
|
||||
expect(percentage).toBe(24.15);
|
||||
expect(percentageToAbsolute(percentage, 12.17, 20)).toBe(17);
|
||||
});
|
||||
|
||||
test("clamps below tween start to 0%", () => {
|
||||
expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
|
||||
});
|
||||
@@ -106,6 +112,10 @@ describe("resolveTweenDuration", () => {
|
||||
test("missing duration defaults to GSAP default (0.5)", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
|
||||
});
|
||||
|
||||
test("missing duration can use its editor timing basis", () => {
|
||||
expect(resolveTweenDuration(makeAnim({ duration: undefined }), 16.26)).toBe(16.26);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTweenAtTime", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function absoluteToPercentage(
|
||||
): number {
|
||||
if (tweenDuration <= 0) return 0;
|
||||
const raw = ((time - tweenStart) / tweenDuration) * 100;
|
||||
return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
|
||||
return Math.max(0, Math.min(100, Math.round(raw * 1000) / 1000));
|
||||
}
|
||||
|
||||
export function percentageToAbsolute(
|
||||
@@ -34,8 +34,8 @@ export function resolveTweenStart(animation: GsapAnimation): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveTweenDuration(animation: GsapAnimation): number {
|
||||
return animation.duration ?? 0.5;
|
||||
export function resolveTweenDuration(animation: GsapAnimation, fallback = 0.5): number {
|
||||
return animation.duration ?? fallback;
|
||||
}
|
||||
|
||||
export function findTweenAtTime(
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { selectedKeyframePercentagesForElement } from "./keyframeSelection";
|
||||
|
||||
describe("selectedKeyframePercentagesForElement", () => {
|
||||
it("returns the percentages of keyframes on the active element", () => {
|
||||
const selected = new Set(["comp#a:25", "comp#a:75"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25, 75]);
|
||||
});
|
||||
|
||||
it("drops keyframes that belong to other elements", () => {
|
||||
// The bug: a stale shift-selection on `comp#b` would otherwise have its
|
||||
// percentages applied to the now-active `comp#a`, deleting the wrong keyframes.
|
||||
const selected = new Set(["comp#a:25", "comp#b:50", "comp#b:80"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25]);
|
||||
});
|
||||
|
||||
it("returns nothing when no key belongs to the active element", () => {
|
||||
const selected = new Set(["comp#b:50"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing when there is no active element", () => {
|
||||
const selected = new Set(["comp#a:25"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty selection", () => {
|
||||
expect(selectedKeyframePercentagesForElement(new Set(), "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits on the final colon so element ids containing ':' still match", () => {
|
||||
const selected = new Set(["a:b:40"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "a:b")).toEqual([40]);
|
||||
});
|
||||
|
||||
it("skips keys without a percentage separator", () => {
|
||||
const selected = new Set(["comp#a"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips keys whose percentage is not a finite number", () => {
|
||||
const selected = new Set(["comp#a:abc", "comp#a:NaN", "comp#a:30"]);
|
||||
expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([30]);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Resolves which keyframe percentages a bulk operation should act on.
|
||||
*
|
||||
* `selectedKeyframes` holds `"<elementId>:<percentage>"` keys and can contain
|
||||
* keyframes from more than one element — e.g. a shift-selection made before the
|
||||
* active element changed (via a keyframe click, a clip click, the layers panel,
|
||||
* or the keyframe context menu). A bulk delete only targets the active
|
||||
* element's animation, so keys belonging to other elements must be dropped;
|
||||
* otherwise their percentages get applied to the active element and remove
|
||||
* keyframes the user never selected on it.
|
||||
*
|
||||
* The element id is everything before the final `:` so element ids that happen
|
||||
* to contain `:` are handled correctly.
|
||||
*/
|
||||
export function selectedKeyframePercentagesForElement(
|
||||
selectedKeyframes: ReadonlySet<string>,
|
||||
activeElementId: string | null,
|
||||
): number[] {
|
||||
if (!activeElementId) return [];
|
||||
const percentages: number[] = [];
|
||||
for (const key of selectedKeyframes) {
|
||||
const separator = key.lastIndexOf(":");
|
||||
if (separator < 0) continue;
|
||||
if (key.slice(0, separator) !== activeElementId) continue;
|
||||
const percentage = Number(key.slice(separator + 1));
|
||||
if (Number.isFinite(percentage)) percentages.push(percentage);
|
||||
}
|
||||
return percentages;
|
||||
}
|
||||
Reference in New Issue
Block a user