From 84799219e7f4d752e7d00f51a0b3366e4ab14186 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 16 Jul 2026 17:25:19 -0400 Subject: [PATCH] feat(studio): add timeline keyframe interactions --- .../components/KeyframeDiamondContextMenu.tsx | 34 ++- .../components/TimelineClipDiamonds.test.tsx | 240 +++++++++++++++- .../components/TimelineClipDiamonds.tsx | 272 +++++++++++++++--- .../components/timelineKeyframeIdentity.ts | 17 ++ .../useTimelineKeyframeHandlers.test.tsx | 108 +++++++ .../components/useTimelineKeyframeHandlers.ts | 57 +++- 6 files changed, 665 insertions(+), 63 deletions(-) create mode 100644 packages/studio/src/player/components/timelineKeyframeIdentity.ts create mode 100644 packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 34a1cc41ff..e40f0c295b 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -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(); }} > diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index 254e5d6086..281f251e54 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -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( - { 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,7 +144,7 @@ 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 host = document.createElement("div"); @@ -134,12 +152,31 @@ describe("TimelineClipDiamonds", () => { const root = createRoot(host); act(() => { root.render( - { selectedKeyframes={new Set()} onClickKeyframe={onClickKeyframe} onMoveKeyframe={onMoveKeyframe} + groupAware + />, + ); + }); + const diamond = host.querySelector('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(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , ); }); @@ -161,13 +273,29 @@ 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()); }); @@ -212,4 +340,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 = {}) => ({ + percentage, + tweenPercentage: percentage, + propertyGroup: "position", + animationId: "anim-1", + properties: { x: percentage }, + ...extra, + }); + act(() => { + root.render( + , + ); + }); + 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( + , + ); + }); + expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 129104dfb8..b888c4dee2 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,22 +1,34 @@ -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"; +import { MiniCurveSvg } from "../../components/editor/EaseCurveSection"; +import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; +import { LANE_H } from "./timelineLayout"; +import { + timelineKeyframeSelectionKey, + type TimelineKeyframeTarget, +} from "./timelineKeyframeIdentity"; -interface KeyframeEntry { +interface TimelineDiamondKeyframe { percentage: number; /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ tweenPercentage?: number; + propertyGroup?: string; + animationId?: string; properties: Record; 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 +44,7 @@ interface TimelineClipDiamondsProps { isSelected: boolean; currentPercentage: number; elementId: string; - selectedKeyframes: Set; + selectedKeyframes: ReadonlySet; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; @@ -45,12 +57,32 @@ interface TimelineClipDiamondsProps { fromClipPercentage: number, toClipPercentage: number, ) => void; + /** 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; } +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) => void; +} + 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 +98,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 +126,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(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()); + 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(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 +175,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,32 +213,90 @@ 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 ( -
+ +
+ {onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && ( +
setHoveredSegment(i)} + onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))} + > + {hoveredSegment === 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-%. 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 +311,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 +342,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 +365,46 @@ 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; + pendingRetimeRef.current.set(kfKey, { clipPct: res.toClipPct, tweenPct: newTweenPct }); + onMoveKeyframe?.(fromTarget, res.toClipPct); // 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 +413,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 +434,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 +472,32 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
); }); + +export const TimelineClipDiamonds = memo(function TimelineClipDiamonds( + props: TimelineClipDiamondsProps, +) { + return ( + 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) + : undefined + } + onSelectSegment={ + props.onSelectSegment + ? (target) => props.onSelectSegment?.(props.elementId, target) + : undefined + } + /> + ); +}); diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.ts new file mode 100644 index 0000000000..8cf58d7118 --- /dev/null +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.ts @@ -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}`; +} diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx new file mode 100644 index 0000000000..0c10202dbf --- /dev/null +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx @@ -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(); + 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(); + + // 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()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index d9a91abd63..e697d49277 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -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, };