mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
Merge pull request #2791 from heygen-com/codex/studio-timeline-b-expanded-lanes-v2
feat(studio): wire expanded keyframe timeline lanes
This commit is contained in:
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
||||
) => Promise<void> | void;
|
||||
setCompIdToSrc: (map: Map<string, string>) => void;
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -90,6 +91,7 @@ export function EditorShell({
|
||||
handleRazorSplitAll,
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
isGestureRecording,
|
||||
recordingState,
|
||||
@@ -149,6 +151,7 @@ export function EditorShell({
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
previewOverlay={
|
||||
<PreviewOverlays
|
||||
shouldShowMotionPath={shouldShowMotionPath}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
blockPreview={blockPreview}
|
||||
isGestureRecording={isGestureRecording}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isPlayheadWithinTween,
|
||||
type EnableKeyframesSession,
|
||||
} from "../hooks/useEnableKeyframes";
|
||||
import { computeElementPercentage } from "../hooks/gsapShared";
|
||||
import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
|
||||
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
||||
import {
|
||||
getNextTimelineZoomPercent,
|
||||
@@ -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) <= KEYFRAME_PCT_MATCH ||
|
||||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
|
||||
);
|
||||
}
|
||||
|
||||
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, session.domEditSelection)) {
|
||||
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) <= KEYFRAME_PCT_MATCH,
|
||||
);
|
||||
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
|
||||
|
||||
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
||||
return presetConfig.ease;
|
||||
}
|
||||
|
||||
function renderExpandedCard({
|
||||
animation,
|
||||
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
||||
function renderCard({
|
||||
animation = baseAnimation(),
|
||||
defaultExpanded = true,
|
||||
flat,
|
||||
onUpdateMeta = vi.fn(),
|
||||
onUpdateKeyframeEase = vi.fn(),
|
||||
onDeleteAnimation = noop,
|
||||
}: {
|
||||
animation: GsapAnimation;
|
||||
animation?: GsapAnimation;
|
||||
defaultExpanded?: boolean;
|
||||
flat?: boolean;
|
||||
onUpdateMeta?: ReturnType<typeof vi.fn>;
|
||||
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
onDeleteAnimation?: (id: string) => void;
|
||||
} = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -63,11 +68,11 @@ function renderExpandedCard({
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={animation}
|
||||
defaultExpanded
|
||||
defaultExpanded={defaultExpanded}
|
||||
flat={flat}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
onDeleteAnimation={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
|
||||
const view = renderCard({ animation, onUpdateKeyframeEase });
|
||||
|
||||
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("0% → 50%"),
|
||||
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
|
||||
action: "commit",
|
||||
ease,
|
||||
});
|
||||
act(() => view.root.unmount());
|
||||
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
|
||||
const onUpdateMeta = vi.fn();
|
||||
const onUpdateKeyframeEase = vi.fn();
|
||||
const animation = baseAnimation({ id: "flat-tween" });
|
||||
const view = renderExpandedCard({
|
||||
const view = renderCard({
|
||||
animation,
|
||||
flat: true,
|
||||
onUpdateMeta,
|
||||
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
describe("AnimationCard flat branch", () => {
|
||||
it("renders a mint border-left and panel-token colors when flat", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
|
||||
const card = host.querySelector('[data-flat-effect-card="true"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.className).toContain("border-panel-accent");
|
||||
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
|
||||
});
|
||||
|
||||
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false });
|
||||
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
|
||||
expect(host.textContent).toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
|
||||
for (const flat of [false, true]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat={flat || undefined}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
|
||||
expect(host.textContent).not.toContain("Remove");
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
|
||||
const onDeleteAnimation = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={true}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const removeButton = buttons.find((b) => b.textContent === "Remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
|
||||
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
||||
animation: GsapAnimation;
|
||||
defaultExpanded: boolean;
|
||||
flat?: boolean;
|
||||
focusedSegment?: { tweenPercentage: number } | null;
|
||||
onFocusSegmentConsumed?: () => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
animation,
|
||||
defaultExpanded,
|
||||
flat,
|
||||
focusedSegment,
|
||||
onFocusSegmentConsumed,
|
||||
onUpdateProperty,
|
||||
onUpdateMeta,
|
||||
onDeleteAnimation,
|
||||
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
const [addingProp, setAddingProp] = useState(false);
|
||||
const [addingFromProp, setAddingFromProp] = useState(false);
|
||||
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const pendingAutoScrollRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedSegment) return;
|
||||
setExpanded(true);
|
||||
pendingAutoScrollRef.current = true;
|
||||
setExpandedKfPct(focusedSegment.tweenPercentage);
|
||||
onFocusSegmentConsumed?.();
|
||||
}, [focusedSegment, onFocusSegmentConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
|
||||
const segment = cardRef.current?.querySelector<HTMLElement>(
|
||||
`[data-ease-segment-pct="${expandedKfPct}"]`,
|
||||
);
|
||||
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
pendingAutoScrollRef.current = false;
|
||||
}, [expandedKfPct]);
|
||||
|
||||
const usedProps = useMemo(
|
||||
() => new Set(Object.keys(animation.properties)),
|
||||
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-flat-effect-card={flat ? "true" : undefined}
|
||||
className={
|
||||
flat
|
||||
@@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
onToggle={setExpandedKfPct}
|
||||
onEaseCommit={(pct, ease) => {
|
||||
onUpdateKeyframeEase(animation.id, pct, ease);
|
||||
trackStudioSegmentEaseEdit({ ease });
|
||||
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
||||
}}
|
||||
onApplyAll={
|
||||
onSetAllKeyframeEases
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
|
||||
const STYLES = {
|
||||
classic: {
|
||||
method:
|
||||
"rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
|
||||
cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
|
||||
trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
|
||||
},
|
||||
flat: {
|
||||
method:
|
||||
"rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
|
||||
cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
|
||||
trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
|
||||
},
|
||||
};
|
||||
|
||||
export function GsapAddAnimationControl({
|
||||
open,
|
||||
setOpen,
|
||||
onAddAnimation,
|
||||
track,
|
||||
variant,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
track: (control: string, name: string) => void;
|
||||
variant: keyof typeof STYLES;
|
||||
}) {
|
||||
const styles = STYLES[variant];
|
||||
|
||||
return (
|
||||
<div className="relative pt-1">
|
||||
{open ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles.method}
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={styles.trigger}
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { memo, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
animations: GsapAnimation[];
|
||||
@@ -21,41 +23,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
animations,
|
||||
multipleTimelines,
|
||||
unsupportedTimelinePattern,
|
||||
onUpdateProperty,
|
||||
onUpdateMeta,
|
||||
onDeleteAnimation,
|
||||
onAddProperty,
|
||||
onRemoveProperty,
|
||||
onUpdateFromProperty,
|
||||
onAddFromProperty,
|
||||
onRemoveFromProperty,
|
||||
onAddAnimation,
|
||||
onLivePreview,
|
||||
onLivePreviewEnd,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
onUnroll,
|
||||
...callbacks
|
||||
}: GsapAnimationSectionProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
const updateMeta = (
|
||||
animationId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
onUpdateMeta(animationId, updates);
|
||||
};
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
|
||||
return (
|
||||
<Section title="Animation" icon={<Film size={15} />}>
|
||||
@@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={updateMeta}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateFromProperty(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
onAddFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
onRemoveFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={onLivePreview}
|
||||
onLivePreviewEnd={onLivePreviewEnd}
|
||||
onSetArcPath={
|
||||
onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
onSetArcPath(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
onUpdateArcSegment(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
onUpdateKeyframeEase(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
onSetAllKeyframeEases(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
onUnroll(animationId);
|
||||
}
|
||||
: undefined
|
||||
focusedSegment={
|
||||
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
|
||||
}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="relative pt-1">
|
||||
{addMenuOpen ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(false)}
|
||||
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(true)}
|
||||
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="classic"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
|
||||
? "Custom"
|
||||
: (EASE_LABELS[segEase] ?? segEase);
|
||||
return (
|
||||
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
|
||||
<div
|
||||
key={`${i}-${kf.percentage}`}
|
||||
data-ease-segment-pct={kf.percentage}
|
||||
className="rounded-md bg-neutral-900/50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
||||
|
||||
@@ -11,17 +11,50 @@ interface KeyframeNavigationProps {
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** The tween that authored this keyframe, when the cache knows it. */
|
||||
animationId?: string;
|
||||
}> | null;
|
||||
/** Current playhead percentage within the element's lifetime (0-100) */
|
||||
currentPercentage: number;
|
||||
onSeek: (percentage: number) => void;
|
||||
onAddKeyframe: (percentage: number) => void;
|
||||
onRemoveKeyframe: (percentage: number) => void;
|
||||
/** `animationId` is the clicked keyframe's OWN tween; see handleDiamondClick. */
|
||||
onRemoveKeyframe: (percentage: number, animationId?: string) => void;
|
||||
onConvertToKeyframes: () => void;
|
||||
}
|
||||
|
||||
const TOLERANCE = 0.5;
|
||||
|
||||
interface NavigableKeyframe {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
|
||||
keyframes: readonly Keyframe[],
|
||||
currentPercentage: number,
|
||||
property?: string,
|
||||
) {
|
||||
const propertyKeyframes = property
|
||||
? keyframes.filter((keyframe) => property in keyframe.properties)
|
||||
: keyframes;
|
||||
return {
|
||||
propertyKeyframes,
|
||||
prevKeyframe:
|
||||
propertyKeyframes
|
||||
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
|
||||
.at(-1) ?? null,
|
||||
nextKeyframe:
|
||||
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
|
||||
null,
|
||||
currentKeyframe:
|
||||
propertyKeyframes.find(
|
||||
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
|
||||
) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
|
||||
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
|
||||
@@ -92,18 +125,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
}: KeyframeNavigationProps) {
|
||||
// Find keyframes that contain this property
|
||||
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
|
||||
|
||||
const prevKf =
|
||||
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
|
||||
|
||||
const nextKf =
|
||||
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
|
||||
|
||||
const atCurrent =
|
||||
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
|
||||
null;
|
||||
const {
|
||||
propertyKeyframes,
|
||||
prevKeyframe: prevKf,
|
||||
nextKeyframe: nextKf,
|
||||
currentKeyframe: atCurrent,
|
||||
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
|
||||
|
||||
// Diamond state
|
||||
let diamondState: DiamondState;
|
||||
@@ -128,7 +155,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
if (diamondState === "ghost") {
|
||||
onConvertToKeyframes();
|
||||
} else if (diamondState === "active" && atCurrent) {
|
||||
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
|
||||
// Report the keyframe's OWN tween. A merged gutter row shows the keyframes
|
||||
// of every tween in the property group, so the caller's "the group's
|
||||
// animation" guess names the wrong tween whenever the clicked keyframe
|
||||
// belongs to a sibling — and the writer then finds no keyframe at that
|
||||
// percentage and silently does nothing.
|
||||
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage, atCurrent.animationId);
|
||||
} else {
|
||||
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
|
||||
beforeAll(installReactActEnvironment);
|
||||
|
||||
/**
|
||||
* Regression: a merged gutter row shows the keyframes of EVERY tween in the
|
||||
* property group. The panel guesses "the group's animation" for the write, so a
|
||||
* click on a keyframe authored by a sibling tween named the wrong animation and
|
||||
* the writer silently found nothing to remove. The diamond now reports the
|
||||
* clicked keyframe's own animationId.
|
||||
*/
|
||||
const MERGED_ROW = [
|
||||
{ percentage: 0, tweenPercentage: 0, properties: { x: 0 }, animationId: "delta-to-500-position" },
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
properties: { x: 120 },
|
||||
animationId: "delta-to-4000-position",
|
||||
},
|
||||
];
|
||||
|
||||
function clickDiamond(currentPercentage: number, onRemoveKeyframe: (...args: never[]) => void) {
|
||||
const root = mountReactHarness(
|
||||
<KeyframeNavigation
|
||||
property="x"
|
||||
keyframes={MERGED_ROW}
|
||||
currentPercentage={currentPercentage}
|
||||
onSeek={vi.fn()}
|
||||
onAddKeyframe={vi.fn()}
|
||||
onRemoveKeyframe={onRemoveKeyframe as never}
|
||||
onConvertToKeyframes={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const diamond = document.querySelector<HTMLElement>('[title="Remove x keyframe"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
act(() => {
|
||||
diamond?.click();
|
||||
});
|
||||
act(() => root.unmount());
|
||||
}
|
||||
|
||||
describe("KeyframeNavigation diamond", () => {
|
||||
it("reports the clicked keyframe's own animation on a merged row", () => {
|
||||
const onRemoveKeyframe = vi.fn();
|
||||
clickDiamond(50, onRemoveKeyframe);
|
||||
expect(onRemoveKeyframe).toHaveBeenCalledWith(25, "delta-to-4000-position");
|
||||
});
|
||||
|
||||
it("still reports the first tween's keyframe as its own", () => {
|
||||
const onRemoveKeyframe = vi.fn();
|
||||
clickDiamond(0, onRemoveKeyframe);
|
||||
expect(onRemoveKeyframe).toHaveBeenCalledWith(0, "delta-to-500-position");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,19 @@ type DragState = {
|
||||
ref: MotionNodeRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tween-% for a stop inserted at fraction `t` along the segment between two
|
||||
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
|
||||
*/
|
||||
function interpolatedKeyframePct(
|
||||
a: MotionNodeRef | undefined,
|
||||
b: MotionNodeRef | undefined,
|
||||
t: number,
|
||||
): number | null {
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
|
||||
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
|
||||
}
|
||||
|
||||
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
||||
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
|
||||
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
||||
@@ -88,6 +101,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);
|
||||
@@ -395,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
|
||||
} else {
|
||||
// Linear keyframe path: interpolate the new stop's tween-% from the two
|
||||
// keyframes bounding the clicked segment (np.t = fraction along it), then
|
||||
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
|
||||
// after to bend the path.
|
||||
const a = abs[np.segIndex]?.ref;
|
||||
const b = abs[np.segIndex + 1]?.ref;
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
|
||||
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
|
||||
// keyframes bounding the clicked segment, then insert it. Lands ON the
|
||||
// current line, so the dot doesn't jump — drag it after to bend the path.
|
||||
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
|
||||
if (pct === null) return;
|
||||
e.stopPropagation();
|
||||
void commitAddKeyframe(animId, pct, x, y, commitMutation);
|
||||
}
|
||||
@@ -418,12 +435,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,
|
||||
@@ -512,9 +530,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
<KeyframeDiamondContextMenu
|
||||
state={kfMenu}
|
||||
onClose={() => setKfMenu(null)}
|
||||
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
|
||||
onDelete={(_elId, keyframe) =>
|
||||
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
||||
}
|
||||
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
||||
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
|
||||
onMoveToPlayhead={(_element, keyframe) =>
|
||||
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -408,7 +408,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "x", displayX)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
|
||||
/>
|
||||
)}
|
||||
@@ -433,7 +435,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "y", displayY)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
|
||||
/>
|
||||
)}
|
||||
@@ -458,7 +462,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "width", displayW)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
|
||||
/>
|
||||
)}
|
||||
@@ -483,7 +489,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "height", displayH)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
|
||||
/>
|
||||
)}
|
||||
@@ -507,7 +515,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "rotation", displayR)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview } from "./propertyPanelSections";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import {
|
||||
FlatColorGradingAccessory,
|
||||
FlatColorGradingSection,
|
||||
@@ -231,7 +232,40 @@ export function PropertyPanelFlat({
|
||||
: "layout",
|
||||
);
|
||||
|
||||
// Animate only the groups that changed during this toggle cycle.
|
||||
// Tracks which group(s) are actively transitioning this toggle cycle, so
|
||||
// their header/body gets the fast entrance animation (hf-flat-group-enter)
|
||||
// and no one else's does. Deliberately NOT derived from remounting alone:
|
||||
// FlatGroupHeader instances are keyed by group id and React normally
|
||||
// preserves them across re-renders, but toggling a non-adjacent group still
|
||||
// shifts the untouched collapsed siblings between the before/after-open
|
||||
// slices below, and Chromium restarts a CSS animation on that kind of
|
||||
// position shift even though nothing about the sibling actually changed.
|
||||
// Gating on these ids (cleared shortly after the 120ms CSS animation
|
||||
// finishes) keeps the animation scoped to only the groups that actually
|
||||
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
|
||||
// AND whichever group was open immediately before the click and got
|
||||
// implicitly closed by it — both freshly-mounted headers need to animate.
|
||||
// When the inline timeline ease button focuses a segment on this element,
|
||||
// force the Motion group open so its AnimationCard (which only mounts while
|
||||
// the group is expanded) can consume the focus and reveal the ease editor.
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
// The element THIS panel renders, not the store's selectedElementId: that
|
||||
// flips synchronously while the panel still renders its predecessor, so a
|
||||
// stale panel would consume a request meant for its successor whenever the
|
||||
// two share a class-selector animation id.
|
||||
const renderedElementId = `${element.sourceFile}#${element.id}`;
|
||||
// Adjusted during render (not an effect) so the card mounts on the same
|
||||
// commit the request lands on. Keyed on request identity: a group the user
|
||||
// closes afterwards stays closed.
|
||||
const [consumedFocus, setConsumedFocus] = useState(focusedEaseSegment);
|
||||
if (focusedEaseSegment !== consumedFocus) {
|
||||
setConsumedFocus(focusedEaseSegment);
|
||||
const focusesThisPanel =
|
||||
focusedEaseSegment?.elementId === renderedElementId &&
|
||||
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
|
||||
if (focusesThisPanel) setOpenGroupId("motion");
|
||||
}
|
||||
|
||||
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
|
||||
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const panelBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -493,6 +527,17 @@ export function PropertyPanelFlat({
|
||||
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
|
||||
const openGroup = openIndex === -1 ? null : groups[openIndex];
|
||||
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
|
||||
const renderClosedGroup = (group: FlatGroupDescriptor) => (
|
||||
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
|
||||
<FlatGroupHeader
|
||||
title={group.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(group.id)}
|
||||
summary={group.summary}
|
||||
animateEntrance={justToggledIds.includes(group.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<DesignPanelInputProvider ui="flat">
|
||||
@@ -520,17 +565,7 @@ export function PropertyPanelFlat({
|
||||
data-flat-panel-body="true"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
{beforeOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
{beforeOpen.map(renderClosedGroup)}
|
||||
{openGroup && (
|
||||
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
|
||||
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
|
||||
@@ -549,17 +584,7 @@ export function PropertyPanelFlat({
|
||||
</div>
|
||||
</DesignPanelInputProvider>
|
||||
)}
|
||||
{afterOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
{afterOpen.map(renderClosedGroup)}
|
||||
</div>
|
||||
<DesignPanelInputProvider section="footer">
|
||||
<PropertyPanelFlatFooter
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
|
||||
function requiredCallbacks(): GsapAnimationEditCallbacks {
|
||||
return {
|
||||
onUpdateProperty: vi.fn(),
|
||||
onUpdateMeta: vi.fn(),
|
||||
onDeleteAnimation: vi.fn(),
|
||||
onAddProperty: vi.fn(),
|
||||
onRemoveProperty: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function requireCallback<T>(callback: T | undefined): T {
|
||||
if (callback === undefined) throw new Error("expected callback to be present");
|
||||
return callback;
|
||||
}
|
||||
|
||||
describe("withTrackedGsapAnimationCallbacks", () => {
|
||||
it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => {
|
||||
const callbacks = requiredCallbacks();
|
||||
const onLivePreview = vi.fn();
|
||||
const onLivePreviewEnd = vi.fn();
|
||||
callbacks.onLivePreview = onLivePreview;
|
||||
callbacks.onLivePreviewEnd = onLivePreviewEnd;
|
||||
|
||||
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
|
||||
|
||||
expect(tracked.onUpdateFromProperty).toBeUndefined();
|
||||
expect(tracked.onAddFromProperty).toBeUndefined();
|
||||
expect(tracked.onRemoveFromProperty).toBeUndefined();
|
||||
expect(tracked.onSetArcPath).toBeUndefined();
|
||||
expect(tracked.onUpdateArcSegment).toBeUndefined();
|
||||
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
|
||||
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
|
||||
expect(tracked.onUnroll).toBeUndefined();
|
||||
expect(tracked.onLivePreview).toBe(onLivePreview);
|
||||
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
|
||||
});
|
||||
|
||||
it("tracks each edit once before invoking its mutation callback", () => {
|
||||
const events: string[] = [];
|
||||
const mutation = (name: string) => () => events.push(`mutate:${name}`);
|
||||
const callbacks: GsapAnimationEditCallbacks = {
|
||||
onUpdateProperty: mutation("update-property"),
|
||||
onUpdateMeta: mutation("update-meta"),
|
||||
onDeleteAnimation: mutation("delete"),
|
||||
onAddProperty: mutation("add-property"),
|
||||
onRemoveProperty: mutation("remove-property"),
|
||||
onUpdateFromProperty: mutation("update-from"),
|
||||
onAddFromProperty: mutation("add-from"),
|
||||
onRemoveFromProperty: mutation("remove-from"),
|
||||
onSetArcPath: mutation("arc-path"),
|
||||
onUpdateArcSegment: mutation("arc-segment"),
|
||||
onUpdateKeyframeEase: mutation("keyframe-ease"),
|
||||
onSetAllKeyframeEases: mutation("all-eases"),
|
||||
onUnroll: mutation("unroll"),
|
||||
};
|
||||
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
|
||||
events.push(`track:${control}:${name}`);
|
||||
});
|
||||
|
||||
tracked.onUpdateProperty("a1", "visibility", 1);
|
||||
tracked.onUpdateProperty("a1", "filter", "blur(2px)");
|
||||
tracked.onUpdateProperty("a1", "opacity", 0.5);
|
||||
tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 });
|
||||
tracked.onDeleteAnimation("a1");
|
||||
tracked.onAddProperty("a1", "scale");
|
||||
tracked.onRemoveProperty("a1", "scale");
|
||||
requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none");
|
||||
requireCallback(tracked.onAddFromProperty)("a1", "x");
|
||||
requireCallback(tracked.onRemoveFromProperty)("a1", "x");
|
||||
requireCallback(tracked.onSetArcPath)("a1", { enabled: true });
|
||||
requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true });
|
||||
requireCallback(tracked.onUpdateArcSegment)("a1", 1, {});
|
||||
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
|
||||
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
|
||||
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
|
||||
requireCallback(tracked.onUnroll)("a1");
|
||||
|
||||
expect(events).toEqual([
|
||||
"track:toggle:visibility",
|
||||
"mutate:update-property",
|
||||
"track:text:filter",
|
||||
"mutate:update-property",
|
||||
"track:metric:opacity",
|
||||
"mutate:update-property",
|
||||
"track:metric:Length",
|
||||
"track:select:Speed",
|
||||
"track:metric:Starts at",
|
||||
"mutate:update-meta",
|
||||
"track:button:Remove animation",
|
||||
"mutate:delete",
|
||||
"track:select:Add effect property",
|
||||
"mutate:add-property",
|
||||
"track:button:Remove scale",
|
||||
"mutate:remove-property",
|
||||
"track:text:clipPath",
|
||||
"mutate:update-from",
|
||||
"track:select:Add from property",
|
||||
"mutate:add-from",
|
||||
"track:button:Remove from x",
|
||||
"mutate:remove-from",
|
||||
"track:toggle:Arc motion",
|
||||
"mutate:arc-path",
|
||||
"track:toggle:Auto rotate",
|
||||
"mutate:arc-path",
|
||||
"track:button:Reset arc segment 2",
|
||||
"mutate:arc-segment",
|
||||
"mutate:arc-segment",
|
||||
"track:select:Keyframe ease",
|
||||
"mutate:keyframe-ease",
|
||||
"track:select:All keyframe eases",
|
||||
"mutate:all-eases",
|
||||
"track:button:Unroll animation",
|
||||
"mutate:unroll",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
||||
import { usePlayerStore } from "../../player";
|
||||
|
||||
/**
|
||||
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
|
||||
@@ -35,6 +36,18 @@ export interface GsapAnimationEditCallbacks {
|
||||
onUnroll?: (animationId: string) => void;
|
||||
}
|
||||
|
||||
type TrackDesignInput = (control: string, name: string) => void;
|
||||
|
||||
function trackAnimationProperty(track: TrackDesignInput, property: string): void {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
}
|
||||
|
||||
// User-facing control label for each animation-meta field. The ease control is
|
||||
// labelled "Speed" in the card UI, so ease/easeEach map there.
|
||||
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
|
||||
@@ -51,13 +64,104 @@ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> =
|
||||
* added later is attributed honestly by its own key instead of poisoning another
|
||||
* control's usage count.
|
||||
*/
|
||||
export function trackAnimationMetaUpdate(
|
||||
track: (control: string, name: string) => void,
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(updates)) {
|
||||
const mapped = ANIMATION_META_LABELS[key];
|
||||
if (mapped) track(mapped.control, mapped.name);
|
||||
else track("select", key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add design-input telemetry to the shared animation-edit callback surface.
|
||||
* Optional callbacks remain absent, pass-through preview callbacks keep their
|
||||
* original identity, and every tracked event fires once before its mutation.
|
||||
*/
|
||||
export function withTrackedGsapAnimationCallbacks(
|
||||
callbacks: GsapAnimationEditCallbacks,
|
||||
track: TrackDesignInput,
|
||||
): GsapAnimationEditCallbacks {
|
||||
return {
|
||||
onUpdateProperty: (animationId, property, value) => {
|
||||
trackAnimationProperty(track, property);
|
||||
callbacks.onUpdateProperty(animationId, property, value);
|
||||
},
|
||||
onUpdateMeta: (animationId, updates) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
callbacks.onUpdateMeta(animationId, updates);
|
||||
},
|
||||
onDeleteAnimation: (animationId) => {
|
||||
track("button", "Remove animation");
|
||||
callbacks.onDeleteAnimation(animationId);
|
||||
},
|
||||
onAddProperty: (animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
callbacks.onAddProperty(animationId, property);
|
||||
},
|
||||
onRemoveProperty: (animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
callbacks.onRemoveProperty(animationId, property);
|
||||
},
|
||||
onUpdateFromProperty: callbacks.onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackAnimationProperty(track, property);
|
||||
callbacks.onUpdateFromProperty?.(animationId, property, value);
|
||||
}
|
||||
: undefined,
|
||||
onAddFromProperty: callbacks.onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
callbacks.onAddFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined,
|
||||
onRemoveFromProperty: callbacks.onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
callbacks.onRemoveFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined,
|
||||
onLivePreview: callbacks.onLivePreview,
|
||||
onLivePreviewEnd: callbacks.onLivePreviewEnd,
|
||||
onSetArcPath: callbacks.onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
|
||||
callbacks.onSetArcPath?.(animationId, config);
|
||||
}
|
||||
: undefined,
|
||||
onUpdateArcSegment: callbacks.onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined,
|
||||
onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
|
||||
}
|
||||
: undefined,
|
||||
onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
callbacks.onSetAllKeyframeEases?.(animationId, ease);
|
||||
}
|
||||
: undefined,
|
||||
onUnroll: callbacks.onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
callbacks.onUnroll?.(animationId);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable consumer for the store's one-shot ease-focus request. Module-level on
|
||||
* purpose: an inline arrow in the section components is a dep of AnimationCard's
|
||||
* focus effect, so a fresh identity each render re-runs that effect every render.
|
||||
*/
|
||||
export function clearFocusedEaseSegment(): void {
|
||||
usePlayerStore.getState().setFocusedEaseSegment(null);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Boundary cases share an arrange/assert shape on purpose: each case states its
|
||||
// own window, drag, and expected remap so a failure reads without cross-referencing.
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
||||
|
||||
@@ -11,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({
|
||||
@@ -94,29 +113,21 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
||||
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
|
||||
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
|
||||
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
|
||||
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
|
||||
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
|
||||
expect(r.pctRemap).toEqual([
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 33.3 },
|
||||
{ from: 50, to: 33.333 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
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.6 / 100.
|
||||
expect(r.pctRemap).toEqual([
|
||||
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
|
||||
expectLeftResize(KEYFRAMES, 0, [
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 63.6 },
|
||||
{ from: 50, to: 63.636 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
@@ -139,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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
|
||||
const MIN_TWEEN_DURATION = 0.01;
|
||||
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
|
||||
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||
|
||||
/** Resolve timing for a flat tween's synthesized start/end diamond. */
|
||||
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
|
||||
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
|
||||
const absTime =
|
||||
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
|
||||
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
|
||||
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -83,10 +83,10 @@ function KeyframeGutter({
|
||||
track("button", `Add ${property} keyframe`);
|
||||
void onCommitAnimatedProperty(element, property, displayValue);
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => {
|
||||
onRemoveKeyframe={(pct, animationId) => {
|
||||
if (!onRemoveKeyframe) return;
|
||||
track("button", `Remove ${property} keyframe`);
|
||||
onRemoveKeyframe(animIdForProp(property), pct);
|
||||
onRemoveKeyframe(animationId ?? animIdForProp(property), pct);
|
||||
}}
|
||||
onConvertToKeyframes={() => {
|
||||
if (!onConvertToKeyframes) return;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { parseTimingValue } from "./propertyPanelTimingSection";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
@@ -135,15 +137,17 @@ export function FlatMotionSection({
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
// Only consume a focus request aimed at the element THIS panel renders (not
|
||||
// the store's selectedElementId, which flips synchronously during async
|
||||
// selection resolution), so a shared class-selector animation id can't open
|
||||
// the wrong element's editor.
|
||||
const renderedElementId = `${element.sourceFile}#${element.id}`;
|
||||
const focusedHere =
|
||||
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
||||
? focusedEaseSegment
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -172,140 +176,22 @@ export function FlatMotionSection({
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={(animationId, updates) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
callbacks.onUpdateMeta(animationId, updates);
|
||||
}}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
callbacks.onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
callbacks.onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
callbacks.onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
callbacks.onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateFromProperty?.(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
callbacks.onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
callbacks.onAddFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
callbacks.onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
callbacks.onRemoveFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={callbacks.onLivePreview}
|
||||
onLivePreviewEnd={callbacks.onLivePreviewEnd}
|
||||
onSetArcPath={
|
||||
callbacks.onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
callbacks.onSetArcPath?.(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
callbacks.onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
callbacks.onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
callbacks.onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
callbacks.onSetAllKeyframeEases?.(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
callbacks.onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
callbacks.onUnroll?.(animationId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<div className="relative pt-1">
|
||||
{addMenuOpen ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(false)}
|
||||
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(true)}
|
||||
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,12 @@ export function FlatTextSection({
|
||||
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
|
||||
element.textFields[0]?.key ?? null,
|
||||
);
|
||||
// Armed by the add handler so the newly added field mounts focused. State, not
|
||||
// a ref cleared during render: Strict Mode renders twice, so the first pass
|
||||
// would eat the marker and the second would mount the field unfocused. Nothing
|
||||
// clears it on read either — `autoFocus` is a mount-only DOM prop and the
|
||||
// editor is keyed on the field, so it can only fire once per added field.
|
||||
const [autoFocusFieldKey, setAutoFocusFieldKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFields = element.textFields;
|
||||
@@ -271,6 +277,8 @@ export function FlatTextSection({
|
||||
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
|
||||
if (!activeField) return null;
|
||||
|
||||
const autoFocusActiveField = autoFocusFieldKey === activeField.key;
|
||||
|
||||
if (textFields.length > 1) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
@@ -278,10 +286,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 +308,7 @@ export function FlatTextSection({
|
||||
onSetText={onSetText}
|
||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
||||
autoFocus
|
||||
autoFocus={autoFocusActiveField}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -316,7 +329,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"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PreviewOverlaysProps {
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -132,6 +133,7 @@ export function resolveZIndexEntries(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PreviewOverlays({
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
blockPreview,
|
||||
isGestureRecording,
|
||||
@@ -274,7 +276,7 @@ export function PreviewOverlays({
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<MotionPathOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
selection={shouldShowMotionPath ? domEditSelection : null}
|
||||
compositionSize={compositionDimensions}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,36 @@ describe("resolveTimelineKeyframeTarget", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the rendered animation identity to resolve a same-group collision", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
50,
|
||||
[
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "position-b",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: "position-a", propertyGroup: "position" },
|
||||
{ id: "position-b", propertyGroup: "position" },
|
||||
],
|
||||
),
|
||||
).toEqual({ animId: "position-b", tweenPct: 25 });
|
||||
});
|
||||
|
||||
it("rejects a rendered animation identity absent from the element", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
50,
|
||||
[{ percentage: 50, animationId: "stale-position" }],
|
||||
[{ id: "position", propertyGroup: "position" }],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a keyframed and flat tween in the same property group unresolved", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
|
||||
installReactActEnvironment();
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: {
|
||||
handleGsapRemoveKeyframe: vi.fn(),
|
||||
handleGsapMoveKeyframeToPlayhead: vi.fn(),
|
||||
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
|
||||
handleGsapAddKeyframe: vi.fn(),
|
||||
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleGsapConvertToKeyframes: vi.fn(),
|
||||
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
|
||||
handleGsapDeleteAnimation: vi.fn(),
|
||||
buildDomSelectionForTimelineElement: vi.fn(),
|
||||
},
|
||||
selection: { id: "box", selector: "#box", sourceFile: "index.html" },
|
||||
animations: Array<GsapAnimation>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/StudioContext", () => ({
|
||||
useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/DomEditContext", () => ({
|
||||
useDomEditActionsContext: () => mocks.actions,
|
||||
useDomEditSelectionContext: () => ({
|
||||
domEditSelection: mocks.selection,
|
||||
selectedGsapAnimations: mocks.animations,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
|
||||
|
||||
const element: TimelineElement = {
|
||||
id: "box",
|
||||
key: "index.html#box",
|
||||
domId: "box",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
sourceFile: "index.html",
|
||||
};
|
||||
|
||||
const flatAnimation: GsapAnimation = {
|
||||
id: "box-to-0-position",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 0,
|
||||
resolvedStart: 0,
|
||||
duration: 1,
|
||||
properties: { x: 420 },
|
||||
propertyGroup: "position",
|
||||
};
|
||||
|
||||
const otherFlatAnimation: GsapAnimation = {
|
||||
...flatAnimation,
|
||||
id: "circle-to-0-position",
|
||||
targetSelector: "#circle",
|
||||
};
|
||||
|
||||
const otherKeyframedAnimation: GsapAnimation = {
|
||||
...otherFlatAnimation,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function authoredInteriorAnimation(): GsapAnimation {
|
||||
return {
|
||||
...flatAnimation,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 210 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
|
||||
let callbacks: TimelineEditCallbacks | null = null;
|
||||
function Harness() {
|
||||
callbacks = useTimelineEditCallbacks({
|
||||
handleTimelineElementMove: vi.fn(),
|
||||
handleTimelineElementsMove: vi.fn(),
|
||||
handleTimelineElementResize: vi.fn(),
|
||||
handleTimelineGroupResize: vi.fn(),
|
||||
handleToggleTrackHidden: vi.fn(),
|
||||
handleBlockedTimelineEdit: vi.fn(),
|
||||
handleTimelineElementSplit: vi.fn(),
|
||||
handleRazorSplit: vi.fn(),
|
||||
handleRazorSplitAll: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
if (!callbacks) throw new Error("timeline callbacks did not initialize");
|
||||
return { callbacks, unmount: () => act(() => root.unmount()) };
|
||||
}
|
||||
|
||||
// One selection PER element, so a callback that resolves the selection for the
|
||||
// wrong element gets a visibly different object. A single mockResolvedValue
|
||||
// hands every element the same selection, which passes just as happily when the
|
||||
// write is committed through whatever happens to be selected.
|
||||
function selectionForElement(el: TimelineElement): {
|
||||
id: string;
|
||||
selector: string;
|
||||
sourceFile: string;
|
||||
} {
|
||||
if (el.id === "box") return mocks.selection;
|
||||
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
|
||||
});
|
||||
return { circle, selection: selectionForElement(circle) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.animations = [flatAnimation];
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
|
||||
Promise.resolve(selectionForElement(el)),
|
||||
);
|
||||
usePlayerStore.setState({
|
||||
currentTime: 0.5,
|
||||
elements: [element],
|
||||
domClipChildren: [],
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map([["box", [flatAnimation]]]),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({
|
||||
elements: [],
|
||||
domClipChildren: [],
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
it("adds an interior point through the add-keyframe persist boundary", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
|
||||
animationId: flatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
properties: { x: 210 },
|
||||
remove: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
50,
|
||||
{ x: 210 },
|
||||
undefined,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
},
|
||||
25,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
|
||||
expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
{ position: 0.25, duration: 0.75 },
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
||||
// resize-keyframed-tween would convert the flat tween to keyframes form.
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
|
||||
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
|
||||
const view = renderCallbacks();
|
||||
|
||||
// The diamond snaps back on `false`. Answering `true` the moment update-meta
|
||||
// was dispatched left a rejected boundary drag rendered at its drop position.
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
},
|
||||
25,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: otherFlatAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Persisted through the CLICKED element's own selection, not the current one,
|
||||
// and as a remove-keyframe the writer can refuse — never a whole-tween delete.
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
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.onDeleteAllKeyframes?.(circle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
selection,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
|
||||
const opacityAnimation: GsapAnimation = {
|
||||
...otherKeyframedAnimation,
|
||||
id: "circle-to-0-visual",
|
||||
propertyGroup: "visual",
|
||||
};
|
||||
const { circle } = arrangeClickedCircle();
|
||||
usePlayerStore.setState({
|
||||
gsapAnimations: new Map([
|
||||
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
|
||||
]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
|
||||
otherKeyframedAnimation.id,
|
||||
opacityAnimation.id,
|
||||
]);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
||||
const { circle } = arrangeClickedCircle();
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No selection for the clicked element means there is nothing safe to write
|
||||
// to: falling back to the current selection would edit a different file.
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
|
||||
// The retime target, the selection it commits through, and the animation the
|
||||
// playhead percentage is computed against all come from the CLICKED element.
|
||||
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
selection,
|
||||
otherKeyframedAnimation,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onDeleteKeyframe?.("box", {
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
|
||||
animationId: flatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
properties: { x: 420 },
|
||||
remove: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// The lane-header toggle fires on whichever element owns the lane, which need
|
||||
// not be the selected one. It must still commit through that element's own
|
||||
// selection, and it must never escalate a flat tween to a whole-tween delete.
|
||||
it("removes a non-selected element's flat tween through that element's own selection", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, {
|
||||
animationId: otherFlatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
properties: { x: 0 },
|
||||
remove: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps authored interior deletion on the per-keyframe path", () => {
|
||||
mocks.animations = [authoredInteriorAnimation()];
|
||||
usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onDeleteKeyframe?.("box", {
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: flatAnimation.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps an authored interior drag on the per-keyframe move path", async () => {
|
||||
const authored = authoredInteriorAnimation();
|
||||
mocks.animations = [authored];
|
||||
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: authored.id,
|
||||
},
|
||||
75,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
authored.id,
|
||||
50,
|
||||
75,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// A drag starts on whatever diamond the pointer is over, which need not be the
|
||||
// selected element. Resolving against the selection would retime the selected
|
||||
// element's tween and commit it through the selected element's file.
|
||||
it("retimes a non-selected element's keyframe through that element's own selection", 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" };
|
||||
const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
|
||||
});
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onMoveKeyframe?.(
|
||||
"scenes/main.html#circle",
|
||||
{
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: circleAnimation.id,
|
||||
},
|
||||
75,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
circleAnimation.id,
|
||||
50,
|
||||
75,
|
||||
circleSelection,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// The diamond's rapid-second-retime path reports the PENDING clip-% (where the
|
||||
// first drag put the keyframe), which the keyframe cache has not caught up to.
|
||||
// TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
|
||||
// the real callback resolves that stale-cache position off the identity fields
|
||||
// instead of failing the lookup.
|
||||
it("retimes from a pending position the keyframe cache has not caught up to", async () => {
|
||||
const authored = authoredInteriorAnimation();
|
||||
mocks.animations = [authored];
|
||||
usePlayerStore.setState({
|
||||
elements: [element],
|
||||
gsapAnimations: new Map([["index.html#box", [authored]]]),
|
||||
// Still the pre-drag positions: 75% is not in here.
|
||||
keyframeCache: new Map([
|
||||
[
|
||||
"index.html#box",
|
||||
{
|
||||
format: "percentage" as const,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 210 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"index.html#box",
|
||||
{
|
||||
percentage: 75,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: authored.id,
|
||||
},
|
||||
85,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
authored.id,
|
||||
50,
|
||||
85,
|
||||
mocks.selection,
|
||||
);
|
||||
|
||||
// Control: the same drag WITHOUT the identity fields falls back to the cache
|
||||
// lookup, finds nothing at 75%, and cannot retime.
|
||||
mocks.actions.handleGsapMoveKeyframe.mockClear();
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
|
||||
).resolves.toBe(false);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
||||
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);
|
||||
|
||||
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
|
||||
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
|
||||
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
|
||||
// zero-length tween, or the clip's own 0-100 %) produces a different number.
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
durationless.id,
|
||||
50,
|
||||
expect.closeTo(87.601, 3),
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
@@ -10,8 +11,15 @@ import {
|
||||
} from "../../contexts/DomEditContext";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
||||
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
||||
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
||||
import type { DomEditSelection } from "../editor/domEditingTypes";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import {
|
||||
getTimelineElementIdentity,
|
||||
splitTimelineElementKey,
|
||||
} from "../../player/lib/timelineElementHelpers";
|
||||
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
|
||||
|
||||
export interface TimelineEditCallbackDeps {
|
||||
handleTimelineElementMove: (
|
||||
@@ -46,15 +54,14 @@ interface TimelineCachedKeyframe {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a rendered timeline diamond back to the animation that authored it.
|
||||
* Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
|
||||
* a property group nor real keyframes. The cache currently carries a property
|
||||
* group, not an animation id, so resolution is safe only when that group has a
|
||||
* single candidate. Ambiguous candidates remain unresolved rather than
|
||||
* retiming an arbitrary tween.
|
||||
* Prefer the animation identity carried by the rendered keyframe. Legacy cache
|
||||
* entries without one are safe only when their property group has one candidate;
|
||||
* ambiguous candidates remain unresolved rather than retiming an arbitrary tween.
|
||||
*/
|
||||
export function resolveTimelineKeyframeTarget(
|
||||
pct: number,
|
||||
@@ -63,6 +70,14 @@ export function resolveTimelineKeyframeTarget(
|
||||
): { animId: string; tweenPct: number } | null {
|
||||
const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
||||
if (!kf) return null;
|
||||
const identifiedAnimation = kf.animationId
|
||||
? animations.find((animation) => animation.id === kf.animationId)
|
||||
: undefined;
|
||||
if (kf.animationId) {
|
||||
return identifiedAnimation
|
||||
? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct }
|
||||
: null;
|
||||
}
|
||||
const group = kf?.propertyGroup;
|
||||
const candidates = group
|
||||
? animations.filter((animation) => animation.propertyGroup === group)
|
||||
@@ -98,22 +113,69 @@ export function useTimelineEditCallbacks({
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
buildDomSelectionForTimelineElement,
|
||||
} = useDomEditActionsContext();
|
||||
|
||||
const resolveElementAnimations = useCallback(
|
||||
(elementKey: string): GsapAnimation[] => {
|
||||
const { gsapAnimations } = usePlayerStore.getState();
|
||||
const { sourceFile, domId } = splitTimelineElementKey(elementKey);
|
||||
const scope = sourceFile ?? activeCompPath ?? "index.html";
|
||||
// elementCacheKeys owns the key-variant list the writers use; reading it
|
||||
// back by hand here is how the two sides drift.
|
||||
for (const key of elementCacheKeys(scope, domId)) {
|
||||
const animations = gsapAnimations.get(key);
|
||||
if (animations) return animations;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[activeCompPath],
|
||||
);
|
||||
|
||||
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
||||
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
||||
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
||||
// anim in the keyframe's property group, falling back to the first keyframed one.
|
||||
const resolveKeyframeTarget = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(pct: number): { animId: string; tweenPct: number } | null => {
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
||||
return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
|
||||
(
|
||||
elementKey: string,
|
||||
target: TimelineKeyframeTarget,
|
||||
animations: GsapAnimation[] = selectedGsapAnimations,
|
||||
): { animId: string; tweenPct: number } | null => {
|
||||
const carriesIdentity =
|
||||
target.propertyGroup !== undefined ||
|
||||
target.tweenPercentage !== undefined ||
|
||||
target.animationId !== undefined;
|
||||
// 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(
|
||||
(animationId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
|
||||
// A flat tween's two diamonds are SYNTHESIZED endpoints, not authored
|
||||
// keyframes, so "remove keyframe" has nothing to remove. Escalating to a
|
||||
// whole-animation delete here destroyed the authored tween and its source
|
||||
// comment on a single click, with no undo beyond the editor's own stack.
|
||||
// Always post remove-keyframe: the writer refuses it for a flat tween
|
||||
// (`changed:false`, file untouched), which is the correct no-op.
|
||||
handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
|
||||
},
|
||||
[handleGsapRemoveKeyframe],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -127,22 +189,59 @@ 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);
|
||||
if (!anim) return;
|
||||
handleGsapRemoveAllKeyframes(anim.id);
|
||||
const elementKey = getTimelineElementIdentity(element);
|
||||
// Every keyframed tween on the layer, not just the first: a layer with
|
||||
// position AND opacity keyframes left the second one keyframed, so
|
||||
// "Delete All Keyframes" visibly did half the job.
|
||||
const anims = resolveElementAnimations(elementKey).filter(
|
||||
(animation) => animation.keyframes,
|
||||
);
|
||||
if (anims.length === 0) return;
|
||||
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
||||
if (!selection) return;
|
||||
// Serial: each removal rewrites the same source file, so dispatching
|
||||
// them together would have the later writes read a pre-edit document.
|
||||
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
|
||||
});
|
||||
},
|
||||
onDeleteKeyframe: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
|
||||
onDeleteKeyframe: (elId, keyframe) => {
|
||||
const animations = resolveElementAnimations(elId);
|
||||
const target = resolveKeyframeTarget(elId, keyframe, animations);
|
||||
if (!target) return;
|
||||
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
||||
if (!element) {
|
||||
removeKeyframeTarget(target.animId, target.tweenPct);
|
||||
return;
|
||||
}
|
||||
// Persist through the CLICKED element's own selection so a deletion on a
|
||||
// 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) => {
|
||||
if (selection) removeKeyframeTarget(target.animId, target.tweenPct, selection);
|
||||
});
|
||||
},
|
||||
// Retime the keyframe to the playhead, preserving its value + ease.
|
||||
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
|
||||
// Retime the keyframe to the playhead, preserving its value + ease. The
|
||||
// clicked element owns the whole write: its animations resolve the target,
|
||||
// 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: (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;
|
||||
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
||||
if (selection) {
|
||||
handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation);
|
||||
}
|
||||
});
|
||||
},
|
||||
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
||||
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
|
||||
@@ -152,14 +251,19 @@ export function useTimelineEditCallbacks({
|
||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
||||
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
const sel = domEditSelection;
|
||||
if (!target || !sel) return;
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
||||
onMoveKeyframe: async (elId, keyframe, toClipPct) => {
|
||||
const animations = resolveElementAnimations(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
|
||||
// through that clip's selection, or it retimes whatever is selected.
|
||||
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
||||
const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection;
|
||||
if (!sel) return false;
|
||||
const anim = animations.find((a) => a.id === target.animId);
|
||||
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
||||
if (!anim || tweenStart === null) return;
|
||||
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
||||
if (!anim || tweenStart === null) return Promise.resolve(false);
|
||||
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(
|
||||
@@ -168,6 +272,7 @@ export function useTimelineEditCallbacks({
|
||||
elements,
|
||||
domClipChildren,
|
||||
);
|
||||
const tweenDuration = resolveTweenDuration(anim, elDuration);
|
||||
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
||||
const decision = resolveKeyframeRetime({
|
||||
keyframes: anim.keyframes?.keyframes ?? [],
|
||||
@@ -177,35 +282,37 @@ export function useTimelineEditCallbacks({
|
||||
dropAbsTime,
|
||||
});
|
||||
if (decision.kind === "move" && decision.toTweenPct != null) {
|
||||
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
|
||||
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(
|
||||
// An empty remap means a FLAT tween's synthesized boundary: there is no
|
||||
// keyframe node to re-key, only the window to move. Sending it through
|
||||
// the keyframed-resize writer would rewrite the authored flat tween into
|
||||
// keyframes form as a side effect of a pure position/duration change, so
|
||||
// dispatch update-meta and leave the tween as the author wrote it.
|
||||
if (decision.pctRemap.length === 0) {
|
||||
// Report the write's real settlement, like every other branch here:
|
||||
// answering `true` while the meta update is still in flight tells the
|
||||
// diamond the retime landed, so a rejected write never snaps back.
|
||||
return handleGsapUpdateMeta(
|
||||
target.animId,
|
||||
decision.position,
|
||||
decision.duration,
|
||||
decision.pctRemap,
|
||||
{ position: decision.position, duration: decision.duration },
|
||||
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,
|
||||
});
|
||||
}
|
||||
return handleGsapResizeKeyframedTween(
|
||||
target.animId,
|
||||
decision.position,
|
||||
decision.duration,
|
||||
decision.pctRemap,
|
||||
sel,
|
||||
);
|
||||
}
|
||||
},
|
||||
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
||||
for (const anim of selectedGsapAnimations) {
|
||||
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
|
||||
@@ -214,18 +321,49 @@ export function useTimelineEditCallbacks({
|
||||
el.duration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
|
||||
: 0;
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim?.keyframes) {
|
||||
const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
if (existing) {
|
||||
handleGsapRemoveKeyframe(anim.id, existing.percentage);
|
||||
// Same frame for read and write: the toggled element's animations decide
|
||||
// add-vs-remove, and its selection is what the mutation commits through.
|
||||
const animations = resolveElementAnimations(getTimelineElementIdentity(el));
|
||||
void buildDomSelectionForTimelineElement(el).then((selection) => {
|
||||
if (!selection) return;
|
||||
const anim = animations.find((a) => a.keyframes);
|
||||
if (anim?.keyframes) {
|
||||
const existing = anim.keyframes.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= 1,
|
||||
);
|
||||
if (existing) {
|
||||
handleGsapRemoveKeyframe(anim.id, existing.percentage, undefined, selection);
|
||||
} else {
|
||||
handleGsapAddKeyframe(anim.id, pct, "x", 0, selection);
|
||||
}
|
||||
} else {
|
||||
handleGsapAddKeyframe(anim.id, pct, "x", 0);
|
||||
const flatAnim = animations.find((a) => !a.keyframes);
|
||||
if (flatAnim) {
|
||||
void handleGsapConvertToKeyframes(
|
||||
flatAnim.id,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selection,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
|
||||
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
|
||||
});
|
||||
},
|
||||
onTogglePropertyGroupKeyframe: async (element, target) => {
|
||||
const selection = await buildDomSelectionForTimelineElement(element);
|
||||
if (!selection) return;
|
||||
if (target.remove) {
|
||||
removeKeyframeTarget(target.animationId, target.tweenPercentage, selection);
|
||||
return;
|
||||
}
|
||||
await handleGsapAddKeyframeBatch(
|
||||
target.animationId,
|
||||
target.tweenPercentage,
|
||||
target.properties,
|
||||
undefined,
|
||||
selection,
|
||||
);
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -240,14 +378,16 @@ export function useTimelineEditCallbacks({
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
resolveElementAnimations,
|
||||
resolveKeyframeTarget,
|
||||
removeKeyframeTarget,
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapMoveKeyframeToPlayhead,
|
||||
handleGsapMoveKeyframe,
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
buildDomSelectionForTimelineElement,
|
||||
projectId,
|
||||
|
||||
Reference in New Issue
Block a user