mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add timeline keyframe retiming interactions
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -94,12 +97,12 @@ 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 },
|
||||
]);
|
||||
});
|
||||
@@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
||||
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.
|
||||
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
|
||||
expect(r.pctRemap).toEqual([
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 63.6 },
|
||||
{ from: 50, to: 63.636 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -152,13 +152,13 @@ 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) => {
|
||||
onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
const sel = domEditSelection;
|
||||
if (!target || !sel) return;
|
||||
if (!target || !sel) return false;
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
||||
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
||||
if (!anim || tweenStart === null) return;
|
||||
if (!anim || tweenStart === null) return false;
|
||||
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
||||
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
@@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
|
||||
duration: decision.duration,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
||||
for (const anim of selectedGsapAnimations) {
|
||||
|
||||
@@ -8,18 +8,32 @@ export interface KeyframeDiamondContextMenuState {
|
||||
elementId: string;
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
currentEase?: string;
|
||||
}
|
||||
|
||||
interface KeyframeDiamondContextMenuProps {
|
||||
state: KeyframeDiamondContextMenuState;
|
||||
onClose: () => void;
|
||||
onDelete: (elementId: string, percentage: number) => void;
|
||||
onDelete: (
|
||||
elementId: string,
|
||||
percentage: number,
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
) => void;
|
||||
onDeleteAll: (elementId: string) => void;
|
||||
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onCopyProperties?: (elementId: string, percentage: number) => void;
|
||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
|
||||
onMoveToPlayhead?: (
|
||||
elementId: string,
|
||||
fromPercentage: number,
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
||||
@@ -51,7 +65,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||
// miss the lookup on any tween whose window is shorter than the clip.
|
||||
onMoveToPlayhead(state.elementId, state.percentage);
|
||||
onMoveToPlayhead(
|
||||
state.elementId,
|
||||
state.percentage,
|
||||
state.propertyGroup,
|
||||
state.tweenPercentage,
|
||||
state.animationId,
|
||||
);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -64,7 +84,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.elementId, state.percentage);
|
||||
onDelete(
|
||||
state.elementId,
|
||||
state.percentage,
|
||||
state.propertyGroup,
|
||||
state.tweenPercentage,
|
||||
state.animationId,
|
||||
);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -84,12 +84,24 @@ describe("TimelineClipDiamonds", () => {
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={5000}
|
||||
@@ -101,6 +113,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -117,7 +130,12 @@ describe("TimelineClipDiamonds", () => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith({
|
||||
percentage: 50,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
});
|
||||
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -126,20 +144,39 @@ describe("TimelineClipDiamonds", () => {
|
||||
// keyframe) committed the move but never selected/parked on the result —
|
||||
// the diamond it was just dragged looked exactly like one nothing happened
|
||||
// to. Select it at its NEW position too.
|
||||
it("selects the keyframe at its new position after a real drag-retime", () => {
|
||||
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
{
|
||||
percentage: 20,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 40,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 60,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
@@ -151,6 +188,81 @@ describe("TimelineClipDiamonds", () => {
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith(
|
||||
{
|
||||
percentage: 40,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
50,
|
||||
);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith({
|
||||
percentage: 50,
|
||||
tweenPercentage: 75,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("composes a rapid second retime from the pending position", () => {
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -161,13 +273,109 @@ describe("TimelineClipDiamonds", () => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
||||
// a real retime.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
|
||||
// The cache still exposes 50%, but this second +10% drag starts at the
|
||||
// pending 75% destination and must therefore land at 85%, not 60%.
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
||||
// The second move must identify the FROM keyframe by the pending (already-
|
||||
// moved) position 75%, not the stale rendered 50%; otherwise the serialized
|
||||
// mutation can't locate the keyframe the first move relocated.
|
||||
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
percentage: 75,
|
||||
tweenPercentage: 75,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
85,
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it.each([
|
||||
["returns false", () => Promise.resolve(false)],
|
||||
["rejects", () => Promise.reject(new Error("retime failed"))],
|
||||
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
||||
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
60,
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -212,4 +420,88 @@ describe("TimelineClipDiamonds", () => {
|
||||
expect(suppressClickRef.current).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
const renderSegmentLane = (lastAmbiguous: boolean) => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
||||
percentage,
|
||||
tweenPercentage: percentage,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: percentage },
|
||||
...extra,
|
||||
});
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onSelectSegment={vi.fn()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
it("hides the inline ease button on an ambiguous merged segment", () => {
|
||||
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
||||
// keyframe, so its hover/ease-button area is not rendered.
|
||||
const { host, root } = renderSegmentLane(true);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the inline ease button on unambiguous merged segments", () => {
|
||||
const { host, root } = renderSegmentLane(false);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the inline ease button on a segment with no source animation id", () => {
|
||||
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
||||
// target; the segment ending on it must not render a (dead) ease button.
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const kf = (percentage: number, animationId?: string) => ({
|
||||
percentage,
|
||||
tweenPercentage: percentage,
|
||||
propertyGroup: "position",
|
||||
...(animationId ? { animationId } : {}),
|
||||
properties: { x: percentage },
|
||||
});
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onSelectSegment={vi.fn()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
||||
import { BEAT_BAND_H } from "./BeatStrip";
|
||||
import {
|
||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||
previewClipPct,
|
||||
resolveKeyframeDrag,
|
||||
} from "../../components/editor/keyframeDrag";
|
||||
|
||||
interface KeyframeEntry {
|
||||
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
|
||||
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
||||
import { LANE_H } from "./timelineLayout";
|
||||
import {
|
||||
timelineKeyframeSelectionKey,
|
||||
type TimelineKeyframeTarget,
|
||||
} from "./timelineKeyframeIdentity";
|
||||
interface TimelineDiamondKeyframe {
|
||||
percentage: number;
|
||||
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
||||
* ease button can't target one): the collapsed row hides the button here. */
|
||||
easeAmbiguous?: boolean;
|
||||
}
|
||||
|
||||
interface KeyframeCacheEntry {
|
||||
format: string;
|
||||
keyframes: KeyframeEntry[];
|
||||
keyframes: TimelineDiamondKeyframe[];
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
@@ -32,7 +43,7 @@ interface TimelineClipDiamondsProps {
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
elementId: string;
|
||||
selectedKeyframes: Set<string>;
|
||||
selectedKeyframes: ReadonlySet<string>;
|
||||
onClickKeyframe?: (percentage: number) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
@@ -44,13 +55,33 @@ interface TimelineClipDiamondsProps {
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
/** Open the segment ease editor for the hovered mid-point button — available on
|
||||
* the inline clip row too, not just the expanded lanes. */
|
||||
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||
* toggles selection off when already selected) ignores the native "click"
|
||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||
suppressClickRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
interface TimelineDiamondLaneProps extends Omit<
|
||||
TimelineClipDiamondsProps,
|
||||
| "onClickKeyframe"
|
||||
| "onShiftClickKeyframe"
|
||||
| "onContextMenuKeyframe"
|
||||
| "onMoveKeyframe"
|
||||
| "onSelectSegment"
|
||||
> {
|
||||
groupAware?: boolean;
|
||||
globalEase?: string;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
|
||||
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
||||
// slightly outside [0, 100] (from rounding or stale cache during the async
|
||||
@@ -66,7 +97,21 @@ type DragState = {
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
function keyframeTarget(
|
||||
keyframe: TimelineDiamondKeyframe,
|
||||
groupAware: boolean,
|
||||
): TimelineKeyframeTarget {
|
||||
return groupAware
|
||||
? {
|
||||
percentage: keyframe.percentage,
|
||||
tweenPercentage: keyframe.tweenPercentage,
|
||||
propertyGroup: keyframe.propertyGroup,
|
||||
animationId: keyframe.animationId,
|
||||
}
|
||||
: { percentage: keyframe.percentage };
|
||||
}
|
||||
|
||||
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
keyframesData,
|
||||
clipWidthPx,
|
||||
clipHeightPx,
|
||||
@@ -80,14 +125,35 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
onSelectSegment,
|
||||
suppressClickRef,
|
||||
}: TimelineClipDiamondsProps) {
|
||||
groupAware = false,
|
||||
globalEase = "none",
|
||||
}: TimelineDiamondLaneProps) {
|
||||
// Hooks must run before the early return below.
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
// Pending retime destination (clip + tween %) per keyframe key, so a rapid
|
||||
// second drag composes from where the first move left the keyframe (whose
|
||||
// cache entry has not rebuilt yet) instead of the stale rendered value.
|
||||
const pendingRetimeRef = useRef(new Map<string, { clipPct: number; tweenPct: number }>());
|
||||
useEffect(() => {
|
||||
// Clear a pending entry once the authoritative cache reflects a keyframe at
|
||||
// ~its destination. Match by tolerance, not equality: cache writers round
|
||||
// clip %s, so an exact check would leak an entry after every successful retime.
|
||||
for (const [key, pending] of pendingRetimeRef.current) {
|
||||
if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
|
||||
pendingRetimeRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, [keyframesData.keyframes]);
|
||||
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
||||
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||
// on drop re-keys the diamond from source.
|
||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
||||
// Index of the segment whose mid-point ease button is revealed on hover, like
|
||||
// Figma. Null = no segment hovered → no button shown (resting state is just
|
||||
// the connector line + diamonds).
|
||||
const [hoveredSegment, setHoveredSegment] = useState<number | null>(null);
|
||||
// The button element can re-render (reposition/unmount) synchronously from
|
||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||
// browser gets to auto-synthesize the "click" event that normally follows
|
||||
@@ -108,7 +174,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
|
||||
// When the beat strip occupies the top band, shrink the diamonds and center
|
||||
// them in the remaining bottom region so they don't collide with it.
|
||||
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
|
||||
// One consistent keyframe-diamond size everywhere (clip bars + property lanes),
|
||||
// matching the property-lane size (LANE_H · ratio). Beat-strip tracks still
|
||||
// shrink to fit under the strip.
|
||||
const diamondSize = beatsActive
|
||||
? Math.round(clipHeightPx * 0.45)
|
||||
: Math.round(LANE_H * DIAMOND_RATIO);
|
||||
const half = diamondSize / 2;
|
||||
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
||||
const sorted = keyframesData.keyframes
|
||||
@@ -141,10 +212,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
||||
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
||||
if (x2 - x1 < 1) return null;
|
||||
// Group-aware target for the ease button: the segment ease is
|
||||
// per-keyframe (each keyframe carries its own animationId/tweenPercentage).
|
||||
// On a merged inline row the button is hidden where the segment is
|
||||
// ambiguous (two source animations collide at this % with different
|
||||
// eases; see easeAmbiguous) or the keyframe has no source animation id
|
||||
// (runtime-scanned) so there is no tween to target.
|
||||
const target = keyframeTarget(kf, true);
|
||||
const ease = kf.ease ?? globalEase;
|
||||
return (
|
||||
<Fragment key={`line-${i}-${prev.percentage}-${kf.percentage}`}>
|
||||
<div
|
||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
||||
className="absolute"
|
||||
data-keyframe-connector={groupAware ? "" : undefined}
|
||||
style={{
|
||||
left: x1,
|
||||
top: centerY,
|
||||
@@ -156,17 +236,66 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
|
||||
<div
|
||||
className="absolute"
|
||||
data-keyframe-ease-segment=""
|
||||
style={{
|
||||
left: x1,
|
||||
top: centerY,
|
||||
width: x2 - x1,
|
||||
height: 18,
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSegment(i)}
|
||||
onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))}
|
||||
>
|
||||
{hoveredSegment === i && (
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-ease-button=""
|
||||
aria-label={`Edit ${ease} easing`}
|
||||
title={`Edit ${ease} easing`}
|
||||
className="absolute flex items-center justify-center rounded"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 3,
|
||||
pointerEvents: "auto",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(255, 255, 255, 0.14)",
|
||||
background: "#171717",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectSegment(target);
|
||||
}}
|
||||
>
|
||||
<MiniCurveSvg ease={ease} active size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{sorted.map((kf, i) => {
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
const target = keyframeTarget(kf, groupAware);
|
||||
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
||||
// While dragging this diamond, render it at the live preview clip-%.
|
||||
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
||||
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
||||
// is the clip's left edge (the left half overflows, which the
|
||||
// overflow-visible clip shows) — NOT shifted fully inside.
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half, so the
|
||||
// diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
||||
// The 0% diamond's left half lands in the reserved left gutter (the
|
||||
// content origin is inset past the label column, Figma-style) so it stays
|
||||
// fully visible instead of being clipped by the sticky label column.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - half;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
@@ -181,7 +310,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
dragRef.current = {
|
||||
kfKey,
|
||||
startX: e.clientX,
|
||||
fromClipPct: kf.percentage,
|
||||
fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
@@ -212,8 +341,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
if (!d || d.kfKey !== kfKey) {
|
||||
if (e.button !== 0) return;
|
||||
suppressNextClick();
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||
else onClickKeyframe?.(target);
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
@@ -235,14 +364,54 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
// back onto ~the same position — no real retime, so treat it as the
|
||||
// click it was. Otherwise a normal click with a few px of mouse/
|
||||
// trackpad drift silently does nothing: no selection, no move.
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||
else onClickKeyframe?.(target);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
const animKfs =
|
||||
target.animationId === undefined
|
||||
? keyframesData.keyframes
|
||||
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
|
||||
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
|
||||
// linearly, so a boundary drag past the range would otherwise reselect
|
||||
// an out-of-range tween % (e.g. 150%) even though the mutation clamps
|
||||
// the moved endpoint back to the boundary.
|
||||
const tweenPcts = animKfs
|
||||
.map((k) => k.tweenPercentage)
|
||||
.filter((v): v is number => typeof v === "number");
|
||||
const clampTween = (v: number) =>
|
||||
tweenPcts.length
|
||||
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
|
||||
: v;
|
||||
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
|
||||
// For a rapid second retime the diamond still renders the stale cache
|
||||
// position, so identify the FROM keyframe by the pending (already-moved)
|
||||
// position; the mutation locates the source keyframe by this identity.
|
||||
const pendingBefore = pendingRetimeRef.current.get(kfKey);
|
||||
const fromTarget = pendingBefore
|
||||
? {
|
||||
...target,
|
||||
percentage: pendingBefore.clipPct,
|
||||
tweenPercentage: pendingBefore.tweenPct,
|
||||
}
|
||||
: target;
|
||||
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
||||
pendingRetimeRef.current.set(kfKey, pending);
|
||||
const clearPending = () => {
|
||||
if (pendingRetimeRef.current.get(kfKey) === pending) {
|
||||
pendingRetimeRef.current.delete(kfKey);
|
||||
}
|
||||
};
|
||||
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
||||
if (!committed) clearPending();
|
||||
}, clearPending);
|
||||
// A retime still targeted this exact diamond — park/select it at its
|
||||
// new position, same as a plain click, or a drag that actually moved
|
||||
// something looks identical to one that silently did nothing.
|
||||
onClickKeyframe?.(res.toClipPct);
|
||||
onClickKeyframe?.({
|
||||
...target,
|
||||
percentage: res.toClipPct,
|
||||
tweenPercentage: newTweenPct,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,6 +420,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
key={`${i}-${kf.percentage}`}
|
||||
type="button"
|
||||
className="absolute"
|
||||
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
||||
data-keyframe-percentage={
|
||||
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
||||
}
|
||||
style={{
|
||||
left: leftPx,
|
||||
top: centerY,
|
||||
@@ -268,10 +441,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={(e) => {
|
||||
// Browser/OS cancellation (or lost capture) ends the drag without a
|
||||
// pointerup, so clear the armed drag and preview or a ghost diamond
|
||||
// stays stuck at the last previewed position.
|
||||
if (dragRef.current?.kfKey !== kfKey) return;
|
||||
dragRef.current = null;
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenuKeyframe?.(e, elementId, kf.percentage);
|
||||
onContextMenuKeyframe?.(e, target);
|
||||
}}
|
||||
title={`${kf.percentage}%`}
|
||||
>
|
||||
@@ -297,3 +479,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
|
||||
props: TimelineClipDiamondsProps,
|
||||
) {
|
||||
return (
|
||||
<TimelineDiamondLane
|
||||
{...props}
|
||||
globalEase={props.keyframesData.ease}
|
||||
onClickKeyframe={(target) => props.onClickKeyframe?.(target.percentage)}
|
||||
onShiftClickKeyframe={(target) =>
|
||||
props.onShiftClickKeyframe?.(props.elementId, target.percentage)
|
||||
}
|
||||
onContextMenuKeyframe={(e, target) =>
|
||||
props.onContextMenuKeyframe?.(e, props.elementId, target.percentage)
|
||||
}
|
||||
onMoveKeyframe={
|
||||
props.onMoveKeyframe
|
||||
? (target, toClipPercentage) =>
|
||||
props.onMoveKeyframe?.(props.elementId, target.percentage, toClipPercentage) ??
|
||||
Promise.resolve(false)
|
||||
: undefined
|
||||
}
|
||||
onSelectSegment={
|
||||
props.onSelectSegment
|
||||
? (target) => props.onSelectSegment?.(props.elementId, target)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface TimelineLaneBaseProps {
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
/**
|
||||
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
||||
|
||||
@@ -70,6 +70,6 @@ export interface TimelineEditCallbacks {
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface TimelineKeyframeTarget {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
|
||||
export function timelineKeyframeSelectionKey(
|
||||
elementId: string,
|
||||
target: TimelineKeyframeTarget,
|
||||
): string {
|
||||
if (!target.propertyGroup) return `${elementId}:${target.percentage}`;
|
||||
const groupKey = target.animationId
|
||||
? `${target.propertyGroup}:${target.animationId}`
|
||||
: target.propertyGroup;
|
||||
return `${elementId}:${groupKey}:${target.percentage}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
|
||||
|
||||
const ELEMENT: TimelineElement = {
|
||||
id: "clip-1",
|
||||
label: "Hero card",
|
||||
tag: "div",
|
||||
start: 1,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
};
|
||||
|
||||
const TARGET: TimelineKeyframeTarget = {
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "position-tween",
|
||||
};
|
||||
|
||||
const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "position-tween",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
trackStudioSegmentEaseEdit.mockClear();
|
||||
usePlayerStore.setState({ focusedEaseSegment: null });
|
||||
});
|
||||
|
||||
describe("useTimelineKeyframeHandlers", () => {
|
||||
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
||||
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
||||
|
||||
function Harness() {
|
||||
({ onSelectSegment } = useTimelineKeyframeHandlers({
|
||||
expandedElements: [ELEMENT],
|
||||
keyframeCache: new Map(),
|
||||
setSelectedElementId: vi.fn(),
|
||||
setKfContextMenu: vi.fn(),
|
||||
toggleSelectedKeyframe: vi.fn(),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = mountReactHarness(<Harness />);
|
||||
act(() => onSelectSegment?.(ELEMENT.id, TARGET));
|
||||
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
|
||||
const onSeek = vi.fn();
|
||||
const onSelectElement = vi.fn();
|
||||
const setSelectedElementId = vi.fn();
|
||||
let onClickKeyframe:
|
||||
| ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
|
||||
| undefined;
|
||||
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
||||
|
||||
function Harness() {
|
||||
({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
|
||||
expandedElements: [ELEMENT],
|
||||
keyframeCache: new Map(),
|
||||
onSelectElement,
|
||||
onSeek,
|
||||
setSelectedElementId,
|
||||
setKfContextMenu: vi.fn(),
|
||||
toggleSelectedKeyframe: vi.fn(),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = mountReactHarness(<Harness />);
|
||||
|
||||
// Selecting a segment must NOT move the playhead.
|
||||
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||
animationId: "position-tween",
|
||||
tweenPercentage: 100,
|
||||
elementId: ELEMENT.id,
|
||||
});
|
||||
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
|
||||
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
|
||||
|
||||
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
|
||||
act(() => onClickKeyframe?.(ELEMENT, TARGET));
|
||||
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
import {
|
||||
timelineKeyframeSelectionKey,
|
||||
type TimelineKeyframeTarget,
|
||||
} from "./timelineKeyframeIdentity";
|
||||
|
||||
interface UseTimelineKeyframeHandlersInput {
|
||||
expandedElements: TimelineElement[];
|
||||
@@ -23,42 +28,71 @@ export function useTimelineKeyframeHandlers({
|
||||
toggleSelectedKeyframe,
|
||||
}: UseTimelineKeyframeHandlersInput) {
|
||||
const onClickKeyframe = useCallback(
|
||||
(el: TimelineElement, pct: number) => {
|
||||
(el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => {
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
onSeek?.(el.start + (pct / 100) * el.duration);
|
||||
toggleSelectedKeyframe(timelineKeyframeSelectionKey(elKey, target));
|
||||
// Clicking a diamond seeks the playhead to it; selecting a segment to edit
|
||||
// its ease (options.seek === false) must NOT move the playhead.
|
||||
if (options?.seek !== false) {
|
||||
onSeek?.(el.start + (target.percentage / 100) * el.duration);
|
||||
}
|
||||
const kfData = keyframeCache.get(elKey);
|
||||
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
|
||||
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
||||
const kf = kfData?.keyframes.find(
|
||||
(item) => Math.abs(item.percentage - target.percentage) < 0.5,
|
||||
);
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setActiveKeyframePct(target.tweenPercentage ?? kf?.tweenPercentage ?? null);
|
||||
},
|
||||
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
|
||||
);
|
||||
|
||||
const onShiftClickKeyframe = useCallback(
|
||||
(elId: string, pct: number) => {
|
||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||
(elId: string, target: TimelineKeyframeTarget) => {
|
||||
toggleSelectedKeyframe(timelineKeyframeSelectionKey(elId, target));
|
||||
},
|
||||
[toggleSelectedKeyframe],
|
||||
);
|
||||
|
||||
const onSelectSegment = useCallback(
|
||||
(elId: string, target: TimelineKeyframeTarget) => {
|
||||
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
||||
if (!el) return;
|
||||
onClickKeyframe(el, target, { seek: false });
|
||||
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
|
||||
usePlayerStore.getState().setFocusedEaseSegment({
|
||||
animationId: target.animationId,
|
||||
tweenPercentage: target.tweenPercentage,
|
||||
elementId: elId,
|
||||
});
|
||||
trackStudioSegmentEaseEdit({ action: "open" });
|
||||
}
|
||||
},
|
||||
[expandedElements, onClickKeyframe],
|
||||
);
|
||||
|
||||
const onContextMenuKeyframe = useCallback(
|
||||
(e: ReactMouseEvent, elId: string, pct: number) => {
|
||||
(e: ReactMouseEvent, elId: string, target: TimelineKeyframeTarget) => {
|
||||
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
||||
const kf = kfData?.keyframes.find(
|
||||
(item) => Math.abs(item.percentage - target.percentage) < 0.2,
|
||||
);
|
||||
setKfContextMenu({
|
||||
x: e.clientX + 4,
|
||||
y: e.clientY + 2,
|
||||
elementId: elId,
|
||||
percentage: pct,
|
||||
tweenPercentage: kf?.tweenPercentage,
|
||||
percentage: target.percentage,
|
||||
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
||||
propertyGroup: target.propertyGroup,
|
||||
animationId: target.animationId,
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
},
|
||||
@@ -67,6 +101,7 @@ export function useTimelineKeyframeHandlers({
|
||||
|
||||
return {
|
||||
onClickKeyframe,
|
||||
onSelectSegment,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user