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 { describe, expect, it } from "vitest";
|
||||||
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
||||||
|
|
||||||
@@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
|||||||
expect(r.kind).toBe("resize");
|
expect(r.kind).toBe("resize");
|
||||||
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
||||||
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
|
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 +
|
// 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).
|
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
|
||||||
expect(r.pctRemap).toEqual([
|
expect(r.pctRemap).toEqual([
|
||||||
{ from: 0, to: 0 },
|
{ from: 0, to: 0 },
|
||||||
{ from: 50, to: 33.3 },
|
{ from: 50, to: 33.333 },
|
||||||
{ from: 100, to: 100 },
|
{ from: 100, to: 100 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
|||||||
expect(r.kind).toBe("resize");
|
expect(r.kind).toBe("resize");
|
||||||
expect(r.position).toBeCloseTo(0.5, 5);
|
expect(r.position).toBeCloseTo(0.5, 5);
|
||||||
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.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([
|
expect(r.pctRemap).toEqual([
|
||||||
{ from: 0, to: 0 },
|
{ from: 0, to: 0 },
|
||||||
{ from: 50, to: 63.6 },
|
{ from: 50, to: 63.636 },
|
||||||
{ from: 100, to: 100 },
|
{ from: 100, to: 100 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
|
|||||||
const MIN_TWEEN_DURATION = 0.01;
|
const MIN_TWEEN_DURATION = 0.01;
|
||||||
|
|
||||||
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
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));
|
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. */
|
/** 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 pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
|
||||||
const absTime =
|
const absTime =
|
||||||
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
|
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 {
|
return {
|
||||||
|
|||||||
@@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
|
|||||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
// 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).
|
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||||
// fallow-ignore-next-line complexity
|
// 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 target = resolveKeyframeTarget(fromClipPct);
|
||||||
const sel = domEditSelection;
|
const sel = domEditSelection;
|
||||||
if (!target || !sel) return;
|
if (!target || !sel) return false;
|
||||||
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
||||||
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
||||||
if (!anim || tweenStart === null) return;
|
if (!anim || tweenStart === null) return false;
|
||||||
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
||||||
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
||||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||||
@@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
|
|||||||
duration: decision.duration,
|
duration: decision.duration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
||||||
for (const anim of selectedGsapAnimations) {
|
for (const anim of selectedGsapAnimations) {
|
||||||
|
|||||||
@@ -8,18 +8,32 @@ export interface KeyframeDiamondContextMenuState {
|
|||||||
elementId: string;
|
elementId: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
tweenPercentage?: number;
|
tweenPercentage?: number;
|
||||||
|
propertyGroup?: string;
|
||||||
|
animationId?: string;
|
||||||
currentEase?: string;
|
currentEase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyframeDiamondContextMenuProps {
|
interface KeyframeDiamondContextMenuProps {
|
||||||
state: KeyframeDiamondContextMenuState;
|
state: KeyframeDiamondContextMenuState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDelete: (elementId: string, percentage: number) => void;
|
onDelete: (
|
||||||
|
elementId: string,
|
||||||
|
percentage: number,
|
||||||
|
propertyGroup?: string,
|
||||||
|
tweenPercentage?: number,
|
||||||
|
animationId?: string,
|
||||||
|
) => void;
|
||||||
onDeleteAll: (elementId: string) => void;
|
onDeleteAll: (elementId: string) => void;
|
||||||
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||||
onCopyProperties?: (elementId: string, percentage: number) => void;
|
onCopyProperties?: (elementId: string, percentage: number) => void;
|
||||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
/** 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({
|
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-%
|
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||||
// miss the lookup on any tween whose window is shorter than the clip.
|
// 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();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -64,7 +84,13 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|||||||
type="button"
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
onDelete(state.elementId, state.percentage);
|
onDelete(
|
||||||
|
state.elementId,
|
||||||
|
state.percentage,
|
||||||
|
state.propertyGroup,
|
||||||
|
state.tweenPercentage,
|
||||||
|
state.animationId,
|
||||||
|
);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
@@ -84,12 +84,24 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<TimelineClipDiamonds
|
<TimelineDiamondLane
|
||||||
keyframesData={{
|
keyframesData={{
|
||||||
format: "percentage",
|
format: "percentage",
|
||||||
keyframes: [
|
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}
|
clipWidthPx={5000}
|
||||||
@@ -101,6 +113,7 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
selectedKeyframes={new Set()}
|
selectedKeyframes={new Set()}
|
||||||
onClickKeyframe={onClickKeyframe}
|
onClickKeyframe={onClickKeyframe}
|
||||||
onMoveKeyframe={onMoveKeyframe}
|
onMoveKeyframe={onMoveKeyframe}
|
||||||
|
groupAware
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -117,7 +130,12 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
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();
|
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
@@ -126,20 +144,39 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
// keyframe) committed the move but never selected/parked on the result —
|
// keyframe) committed the move but never selected/parked on the result —
|
||||||
// the diamond it was just dragged looked exactly like one nothing happened
|
// the diamond it was just dragged looked exactly like one nothing happened
|
||||||
// to. Select it at its NEW position too.
|
// 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 onClickKeyframe = vi.fn();
|
||||||
const onMoveKeyframe = vi.fn();
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
document.body.append(host);
|
document.body.append(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<TimelineClipDiamonds
|
<TimelineDiamondLane
|
||||||
keyframesData={{
|
keyframesData={{
|
||||||
format: "percentage",
|
format: "percentage",
|
||||||
keyframes: [
|
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}
|
clipWidthPx={200}
|
||||||
@@ -151,6 +188,81 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
selectedKeyframes={new Set()}
|
selectedKeyframes={new Set()}
|
||||||
onClickKeyframe={onClickKeyframe}
|
onClickKeyframe={onClickKeyframe}
|
||||||
onMoveKeyframe={onMoveKeyframe}
|
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(
|
diamond!.dispatchEvent(
|
||||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||||
);
|
);
|
||||||
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||||
// a real retime.
|
|
||||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
// 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);
|
// The second move must identify the FROM keyframe by the pending (already-
|
||||||
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
// 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());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,4 +420,88 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
expect(suppressClickRef.current).toBe(true);
|
expect(suppressClickRef.current).toBe(true);
|
||||||
act(() => root.unmount());
|
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 { BEAT_BAND_H } from "./BeatStrip";
|
||||||
import {
|
import {
|
||||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||||
previewClipPct,
|
previewClipPct,
|
||||||
resolveKeyframeDrag,
|
resolveKeyframeDrag,
|
||||||
} from "../../components/editor/keyframeDrag";
|
} from "../../components/editor/keyframeDrag";
|
||||||
|
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
|
||||||
interface KeyframeEntry {
|
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
||||||
|
import { LANE_H } from "./timelineLayout";
|
||||||
|
import {
|
||||||
|
timelineKeyframeSelectionKey,
|
||||||
|
type TimelineKeyframeTarget,
|
||||||
|
} from "./timelineKeyframeIdentity";
|
||||||
|
interface TimelineDiamondKeyframe {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
||||||
tweenPercentage?: number;
|
tweenPercentage?: number;
|
||||||
|
propertyGroup?: string;
|
||||||
|
animationId?: string;
|
||||||
properties: Record<string, number | string>;
|
properties: Record<string, number | string>;
|
||||||
ease?: 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 {
|
interface KeyframeCacheEntry {
|
||||||
format: string;
|
format: string;
|
||||||
keyframes: KeyframeEntry[];
|
keyframes: TimelineDiamondKeyframe[];
|
||||||
ease?: string;
|
ease?: string;
|
||||||
easeEach?: string;
|
easeEach?: string;
|
||||||
}
|
}
|
||||||
@@ -32,7 +43,7 @@ interface TimelineClipDiamondsProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
currentPercentage: number;
|
currentPercentage: number;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
selectedKeyframes: Set<string>;
|
selectedKeyframes: ReadonlySet<string>;
|
||||||
onClickKeyframe?: (percentage: number) => void;
|
onClickKeyframe?: (percentage: number) => void;
|
||||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||||
@@ -44,13 +55,33 @@ interface TimelineClipDiamondsProps {
|
|||||||
elementId: string,
|
elementId: string,
|
||||||
fromClipPercentage: number,
|
fromClipPercentage: number,
|
||||||
toClipPercentage: 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
|
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||||
* toggles selection off when already selected) ignores the native "click"
|
* toggles selection off when already selected) ignores the native "click"
|
||||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||||
suppressClickRef?: React.RefObject<boolean>;
|
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;
|
const DIAMOND_RATIO = 0.8;
|
||||||
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
||||||
// slightly outside [0, 100] (from rounding or stale cache during the async
|
// slightly outside [0, 100] (from rounding or stale cache during the async
|
||||||
@@ -66,7 +97,21 @@ type DragState = {
|
|||||||
moved: boolean;
|
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,
|
keyframesData,
|
||||||
clipWidthPx,
|
clipWidthPx,
|
||||||
clipHeightPx,
|
clipHeightPx,
|
||||||
@@ -80,14 +125,35 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
onShiftClickKeyframe,
|
onShiftClickKeyframe,
|
||||||
onContextMenuKeyframe,
|
onContextMenuKeyframe,
|
||||||
onMoveKeyframe,
|
onMoveKeyframe,
|
||||||
|
onSelectSegment,
|
||||||
suppressClickRef,
|
suppressClickRef,
|
||||||
}: TimelineClipDiamondsProps) {
|
groupAware = false,
|
||||||
|
globalEase = "none",
|
||||||
|
}: TimelineDiamondLaneProps) {
|
||||||
// Hooks must run before the early return below.
|
// Hooks must run before the early return below.
|
||||||
const dragRef = useRef<DragState | null>(null);
|
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
|
// 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
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||||
// on drop re-keys the diamond from source.
|
// on drop re-keys the diamond from source.
|
||||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
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 button element can re-render (reposition/unmount) synchronously from
|
||||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||||
// browser gets to auto-synthesize the "click" event that normally follows
|
// 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
|
// 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.
|
// 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 half = diamondSize / 2;
|
||||||
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
||||||
const sorted = keyframesData.keyframes
|
const sorted = keyframesData.keyframes
|
||||||
@@ -141,32 +212,90 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
||||||
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
||||||
if (x2 - x1 < 1) return null;
|
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 (
|
return (
|
||||||
<div
|
<Fragment key={`line-${i}-${prev.percentage}-${kf.percentage}`}>
|
||||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
<div
|
||||||
className="absolute"
|
className="absolute"
|
||||||
style={{
|
data-keyframe-connector={groupAware ? "" : undefined}
|
||||||
left: x1,
|
style={{
|
||||||
top: centerY,
|
left: x1,
|
||||||
width: x2 - x1,
|
top: centerY,
|
||||||
height: 2,
|
width: x2 - x1,
|
||||||
transform: "translateY(-1px)",
|
height: 2,
|
||||||
background: baseColor,
|
transform: "translateY(-1px)",
|
||||||
opacity: baseOpacity,
|
background: baseColor,
|
||||||
borderRadius: 1,
|
opacity: baseOpacity,
|
||||||
}}
|
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) => {
|
{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-%.
|
// While dragging this diamond, render it at the live preview clip-%.
|
||||||
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
||||||
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
// Center the diamond ON its keyframe %: left = (% · width) − half, so the
|
||||||
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
// diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
||||||
// is the clip's left edge (the left half overflows, which the
|
// The 0% diamond's left half lands in the reserved left gutter (the
|
||||||
// overflow-visible clip shows) — NOT shifted fully inside.
|
// 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 leftPx = (renderPct / 100) * clipWidthPx - half;
|
||||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||||
@@ -181,7 +310,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
dragRef.current = {
|
dragRef.current = {
|
||||||
kfKey,
|
kfKey,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
fromClipPct: kf.percentage,
|
fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
|
||||||
moved: false,
|
moved: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -212,8 +341,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
if (!d || d.kfKey !== kfKey) {
|
if (!d || d.kfKey !== kfKey) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
suppressNextClick();
|
suppressNextClick();
|
||||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||||
else onClickKeyframe?.(kf.percentage);
|
else onClickKeyframe?.(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.stopPropagation();
|
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
|
// 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/
|
// click it was. Otherwise a normal click with a few px of mouse/
|
||||||
// trackpad drift silently does nothing: no selection, no move.
|
// trackpad drift silently does nothing: no selection, no move.
|
||||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||||
else onClickKeyframe?.(kf.percentage);
|
else onClickKeyframe?.(target);
|
||||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
} 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
|
// 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
|
// new position, same as a plain click, or a drag that actually moved
|
||||||
// something looks identical to one that silently did nothing.
|
// 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}`}
|
key={`${i}-${kf.percentage}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute"
|
className="absolute"
|
||||||
|
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
||||||
|
data-keyframe-percentage={
|
||||||
|
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
left: leftPx,
|
left: leftPx,
|
||||||
top: centerY,
|
top: centerY,
|
||||||
@@ -268,10 +441,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
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) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenuKeyframe?.(e, elementId, kf.percentage);
|
onContextMenuKeyframe?.(e, target);
|
||||||
}}
|
}}
|
||||||
title={`${kf.percentage}%`}
|
title={`${kf.percentage}%`}
|
||||||
>
|
>
|
||||||
@@ -297,3 +479,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
</div>
|
</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,
|
elementId: string,
|
||||||
fromClipPercentage: number,
|
fromClipPercentage: number,
|
||||||
toClipPercentage: number,
|
toClipPercentage: number,
|
||||||
) => void;
|
) => Promise<boolean>;
|
||||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||||
/**
|
/**
|
||||||
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
||||||
|
|||||||
@@ -70,6 +70,6 @@ export interface TimelineEditCallbacks {
|
|||||||
elementId: string,
|
elementId: string,
|
||||||
fromClipPercentage: number,
|
fromClipPercentage: number,
|
||||||
toClipPercentage: number,
|
toClipPercentage: number,
|
||||||
) => void;
|
) => Promise<boolean>;
|
||||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
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 { useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||||
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||||
|
import {
|
||||||
|
timelineKeyframeSelectionKey,
|
||||||
|
type TimelineKeyframeTarget,
|
||||||
|
} from "./timelineKeyframeIdentity";
|
||||||
|
|
||||||
interface UseTimelineKeyframeHandlersInput {
|
interface UseTimelineKeyframeHandlersInput {
|
||||||
expandedElements: TimelineElement[];
|
expandedElements: TimelineElement[];
|
||||||
@@ -23,42 +28,71 @@ export function useTimelineKeyframeHandlers({
|
|||||||
toggleSelectedKeyframe,
|
toggleSelectedKeyframe,
|
||||||
}: UseTimelineKeyframeHandlersInput) {
|
}: UseTimelineKeyframeHandlersInput) {
|
||||||
const onClickKeyframe = useCallback(
|
const onClickKeyframe = useCallback(
|
||||||
(el: TimelineElement, pct: number) => {
|
(el: TimelineElement, target: TimelineKeyframeTarget, options?: { seek?: boolean }) => {
|
||||||
usePlayerStore.getState().clearSelectedKeyframes();
|
usePlayerStore.getState().clearSelectedKeyframes();
|
||||||
const elKey = el.key ?? el.id;
|
const elKey = el.key ?? el.id;
|
||||||
setSelectedElementId(elKey);
|
setSelectedElementId(elKey);
|
||||||
onSelectElement?.(el);
|
onSelectElement?.(el);
|
||||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
toggleSelectedKeyframe(timelineKeyframeSelectionKey(elKey, target));
|
||||||
onSeek?.(el.start + (pct / 100) * el.duration);
|
// 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 kfData = keyframeCache.get(elKey);
|
||||||
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
|
const kf = kfData?.keyframes.find(
|
||||||
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
(item) => Math.abs(item.percentage - target.percentage) < 0.5,
|
||||||
|
);
|
||||||
|
usePlayerStore
|
||||||
|
.getState()
|
||||||
|
.setActiveKeyframePct(target.tweenPercentage ?? kf?.tweenPercentage ?? null);
|
||||||
},
|
},
|
||||||
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
|
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onShiftClickKeyframe = useCallback(
|
const onShiftClickKeyframe = useCallback(
|
||||||
(elId: string, pct: number) => {
|
(elId: string, target: TimelineKeyframeTarget) => {
|
||||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
toggleSelectedKeyframe(timelineKeyframeSelectionKey(elId, target));
|
||||||
},
|
},
|
||||||
[toggleSelectedKeyframe],
|
[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(
|
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);
|
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
||||||
if (el) {
|
if (el) {
|
||||||
setSelectedElementId(elId);
|
setSelectedElementId(elId);
|
||||||
onSelectElement?.(el);
|
onSelectElement?.(el);
|
||||||
}
|
}
|
||||||
const kfData = keyframeCache.get(elId);
|
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({
|
setKfContextMenu({
|
||||||
x: e.clientX + 4,
|
x: e.clientX + 4,
|
||||||
y: e.clientY + 2,
|
y: e.clientY + 2,
|
||||||
elementId: elId,
|
elementId: elId,
|
||||||
percentage: pct,
|
percentage: target.percentage,
|
||||||
tweenPercentage: kf?.tweenPercentage,
|
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
||||||
|
propertyGroup: target.propertyGroup,
|
||||||
|
animationId: target.animationId,
|
||||||
currentEase: kf?.ease ?? kfData?.ease,
|
currentEase: kf?.ease ?? kfData?.ease,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -67,6 +101,7 @@ export function useTimelineKeyframeHandlers({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
onClickKeyframe,
|
onClickKeyframe,
|
||||||
|
onSelectSegment,
|
||||||
onShiftClickKeyframe,
|
onShiftClickKeyframe,
|
||||||
onContextMenuKeyframe,
|
onContextMenuKeyframe,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user