From b3ce4ddb5f5412769a8f14475a5d7ad922f9864a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 16:46:58 +0200 Subject: [PATCH 1/3] feat(studio): add timeline keyframe retiming interactions --- .../components/editor/keyframeRetime.test.ts | 11 +- .../src/components/editor/keyframeRetime.ts | 3 +- .../nle/useTimelineEditCallbacks.ts | 9 +- .../components/KeyframeDiamondContextMenu.tsx | 34 +- .../components/TimelineClipDiamonds.test.tsx | 322 +++++++++++++++++- .../components/TimelineClipDiamonds.tsx | 284 +++++++++++++-- .../src/player/components/TimelineLanes.tsx | 2 +- .../player/components/timelineCallbacks.ts | 2 +- .../components/timelineKeyframeIdentity.ts | 17 + .../useTimelineKeyframeHandlers.test.tsx | 108 ++++++ .../components/useTimelineKeyframeHandlers.ts | 57 +++- 11 files changed, 772 insertions(+), 77 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/components/editor/keyframeRetime.test.ts b/packages/studio/src/components/editor/keyframeRetime.test.ts index 876c5129a5..fc8844e54d 100644 --- a/packages/studio/src/components/editor/keyframeRetime.test.ts +++ b/packages/studio/src/components/editor/keyframeRetime.test.ts @@ -1,3 +1,6 @@ +// Boundary cases share an arrange/assert shape on purpose: each case states its +// own window, drag, and expected remap so a failure reads without cross-referencing. +// fallow-ignore-file code-duplication import { describe, expect, it } from "vitest"; import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime"; @@ -94,12 +97,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => { expect(r.kind).toBe("resize"); expect(r.position).toBeCloseTo(2, 5); // start unchanged expect(r.duration).toBeCloseTo(6, 5); // 8 - 2 - // abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each + // abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each // existing keyframe's old→new tween-%; the commit re-keys in place (value + // ease + _auto preserved by round-tripping the source node, not re-emitted here). expect(r.pctRemap).toEqual([ { from: 0, to: 0 }, - { from: 50, to: 33.3 }, + { from: 50, to: 33.333 }, { from: 100, to: 100 }, ]); }); @@ -113,10 +116,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => { expect(r.kind).toBe("resize"); expect(r.position).toBeCloseTo(0.5, 5); expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5 - // abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100. + // abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100. expect(r.pctRemap).toEqual([ { from: 0, to: 0 }, - { from: 50, to: 63.6 }, + { from: 50, to: 63.636 }, { from: 100, to: 100 }, ]); }); diff --git a/packages/studio/src/components/editor/keyframeRetime.ts b/packages/studio/src/components/editor/keyframeRetime.ts index f235b418ed..f7fa1d1344 100644 --- a/packages/studio/src/components/editor/keyframeRetime.ts +++ b/packages/studio/src/components/editor/keyframeRetime.ts @@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4; const MIN_TWEEN_DURATION = 0.01; const round3 = (n: number) => Math.round(n * 1000) / 1000; -const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); /** Resolve timing for a flat tween's synthesized start/end diamond. */ @@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: { const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => { const absTime = i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration; - return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) }; + return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) }; }); return { diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 76122eb47c..a1fa658521 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({ // resizes the tween — position/duration grow so the dragged keyframe lands at // the drop while every other keyframe keeps its absolute time (value+ease too). // fallow-ignore-next-line complexity - onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { + onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => { const target = resolveKeyframeTarget(fromClipPct); const sel = domEditSelection; - if (!target || !sel) return; + if (!target || !sel) return false; const anim = selectedGsapAnimations.find((a) => a.id === target.animId); const tweenStart = anim ? resolveTweenStart(anim) : null; - if (!anim || tweenStart === null) return; + if (!anim || tweenStart === null) return false; const tweenDuration = anim.duration ?? resolveTweenDuration(anim); const sourceFile = sel.sourceFile || activeCompPath || "index.html"; const { elements, domClipChildren } = usePlayerStore.getState(); @@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({ duration: decision.duration, }); } + } else { + return false; } + return true; }, onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { for (const anim of selectedGsapAnimations) { 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..d22ac46a11 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,20 +144,39 @@ describe("TimelineClipDiamonds", () => { // keyframe) committed the move but never selected/parked on the result — // the diamond it was just dragged looked exactly like one nothing happened // to. Select it at its NEW position too. - it("selects the keyframe at its new position after a real drag-retime", () => { + it("reselects a retimed keyframe with its post-move tween percentage", () => { const onClickKeyframe = vi.fn(); - const onMoveKeyframe = vi.fn(); + const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( - { 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().mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , ); }); @@ -161,13 +273,109 @@ describe("TimelineClipDiamonds", () => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); - // 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon, - // a real retime. - diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + + // The cache still exposes 50%, but this second +10% drag starts at the + // pending 75% destination and must therefore land at 85%, not 60%. + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); + }); + + // The second move must identify the FROM keyframe by the pending (already- + // moved) position 75%, not the stale rendered 50%; otherwise the serialized + // mutation can't locate the keyframe the first move relocated. + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 2, + { + percentage: 75, + tweenPercentage: 75, + propertyGroup: "position", + animationId: "anim-1", + }, + 85, + ); + act(() => root.unmount()); + }); + + it.each([ + ["returns false", () => Promise.resolve(false)], + ["rejects", () => Promise.reject(new Error("retime failed"))], + ])("clears a failed pending retime when the callback %s", async (_label, settle) => { + const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); }); + const diamond = host.querySelector('button[title="50%"]'); + expect(diamond).not.toBeNull(); - expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52); - expect(onClickKeyframe).toHaveBeenCalledWith(52); + await act(async () => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + await Promise.resolve(); + }); + + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 })); + }); + + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 2, + { + percentage: 50, + tweenPercentage: 50, + propertyGroup: "position", + animationId: "anim-1", + }, + 60, + ); act(() => root.unmount()); }); @@ -212,4 +420,88 @@ describe("TimelineClipDiamonds", () => { expect(suppressClickRef.current).toBe(true); act(() => root.unmount()); }); + + const renderSegmentLane = (lastAmbiguous: boolean) => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const kf = (percentage: number, extra: Record = {}) => ({ + 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..29fb6b9c77 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,22 +1,33 @@ -import { memo, useRef, useState } from "react"; +import { Fragment, memo, useEffect, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; import { KEYFRAME_DRAG_THRESHOLD_PX, previewClipPct, resolveKeyframeDrag, } from "../../components/editor/keyframeDrag"; - -interface KeyframeEntry { +import { MiniCurveSvg } from "../../components/editor/EaseCurveSection"; +import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; +import { LANE_H } from "./timelineLayout"; +import { + timelineKeyframeSelectionKey, + type TimelineKeyframeTarget, +} from "./timelineKeyframeIdentity"; +interface TimelineDiamondKeyframe { percentage: number; /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ tweenPercentage?: number; + propertyGroup?: string; + animationId?: string; properties: Record; ease?: string; + /** Set when 2+ source animations collide at this percentage (a single inline + * ease button can't target one): the collapsed row hides the button here. */ + easeAmbiguous?: boolean; } interface KeyframeCacheEntry { format: string; - keyframes: KeyframeEntry[]; + keyframes: TimelineDiamondKeyframe[]; ease?: string; easeEach?: string; } @@ -32,7 +43,7 @@ interface TimelineClipDiamondsProps { isSelected: boolean; currentPercentage: number; elementId: string; - selectedKeyframes: Set; + selectedKeyframes: ReadonlySet; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; @@ -44,13 +55,33 @@ interface TimelineClipDiamondsProps { elementId: string, fromClipPercentage: number, toClipPercentage: number, - ) => void; + ) => Promise; + /** 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) => Promise; +} + const DIAMOND_RATIO = 0.8; // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes // slightly outside [0, 100] (from rounding or stale cache during the async @@ -66,7 +97,21 @@ type DragState = { moved: boolean; }; -export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ +function keyframeTarget( + keyframe: TimelineDiamondKeyframe, + groupAware: boolean, +): TimelineKeyframeTarget { + return groupAware + ? { + percentage: keyframe.percentage, + tweenPercentage: keyframe.tweenPercentage, + propertyGroup: keyframe.propertyGroup, + animationId: keyframe.animationId, + } + : { percentage: keyframe.percentage }; +} + +export const TimelineDiamondLane = memo(function TimelineDiamondLane({ keyframesData, clipWidthPx, clipHeightPx, @@ -80,14 +125,35 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, + onSelectSegment, suppressClickRef, -}: TimelineClipDiamondsProps) { + groupAware = false, + globalEase = "none", +}: TimelineDiamondLaneProps) { // Hooks must run before the early return below. const dragRef = useRef(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 +174,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // When the beat strip occupies the top band, shrink the diamonds and center // them in the remaining bottom region so they don't collide with it. - const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); + // One consistent keyframe-diamond size everywhere (clip bars + property lanes), + // matching the property-lane size (LANE_H · ratio). Beat-strip tracks still + // shrink to fit under the strip. + const diamondSize = beatsActive + ? Math.round(clipHeightPx * 0.45) + : Math.round(LANE_H * DIAMOND_RATIO); const half = diamondSize / 2; const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; const sorted = keyframesData.keyframes @@ -141,32 +212,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 +310,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ dragRef.current = { kfKey, startX: e.clientX, - fromClipPct: kf.percentage, + fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage, moved: false, }; } @@ -212,8 +341,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ if (!d || d.kfKey !== kfKey) { if (e.button !== 0) return; suppressNextClick(); - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); return; } e.stopPropagation(); @@ -235,14 +364,54 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // back onto ~the same position — no real retime, so treat it as the // click it was. Otherwise a normal click with a few px of mouse/ // trackpad drift silently does nothing: no selection, no move. - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); } else if (res.kind === "move" && res.toClipPct != null) { - onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct); + const animKfs = + target.animationId === undefined + ? keyframesData.keyframes + : keyframesData.keyframes.filter((k) => k.animationId === target.animationId); + // Clamp to the mapped tween range: clipToTweenPercentage extrapolates + // linearly, so a boundary drag past the range would otherwise reselect + // an out-of-range tween % (e.g. 150%) even though the mutation clamps + // the moved endpoint back to the boundary. + const tweenPcts = animKfs + .map((k) => k.tweenPercentage) + .filter((v): v is number => typeof v === "number"); + const clampTween = (v: number) => + tweenPcts.length + ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v)) + : v; + const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct)); + // For a rapid second retime the diamond still renders the stale cache + // position, so identify the FROM keyframe by the pending (already-moved) + // position; the mutation locates the source keyframe by this identity. + const pendingBefore = pendingRetimeRef.current.get(kfKey); + const fromTarget = pendingBefore + ? { + ...target, + percentage: pendingBefore.clipPct, + tweenPercentage: pendingBefore.tweenPct, + } + : target; + const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct }; + pendingRetimeRef.current.set(kfKey, pending); + const clearPending = () => { + if (pendingRetimeRef.current.get(kfKey) === pending) { + pendingRetimeRef.current.delete(kfKey); + } + }; + void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => { + if (!committed) clearPending(); + }, clearPending); // A retime still targeted this exact diamond — park/select it at its // new position, same as a plain click, or a drag that actually moved // something looks identical to one that silently did nothing. - onClickKeyframe?.(res.toClipPct); + onClickKeyframe?.({ + ...target, + percentage: res.toClipPct, + tweenPercentage: newTweenPct, + }); } }; @@ -251,6 +420,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ key={`${i}-${kf.percentage}`} type="button" className="absolute" + data-keyframe-group={groupAware ? kf.propertyGroup : undefined} + data-keyframe-percentage={ + groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined + } style={{ left: leftPx, top: centerY, @@ -268,10 +441,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} + onPointerCancel={(e) => { + // Browser/OS cancellation (or lost capture) ends the drag without a + // pointerup, so clear the armed drag and preview or a ghost diamond + // stays stuck at the last previewed position. + if (dragRef.current?.kfKey !== kfKey) return; + dragRef.current = null; + setPreview(null); + e.currentTarget.releasePointerCapture?.(e.pointerId); + }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); - onContextMenuKeyframe?.(e, elementId, kf.percentage); + onContextMenuKeyframe?.(e, target); }} title={`${kf.percentage}%`} > @@ -297,3 +479,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
); }); + +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) ?? + Promise.resolve(false) + : undefined + } + onSelectSegment={ + props.onSelectSegment + ? (target) => props.onSelectSegment?.(props.elementId, target) + : undefined + } + /> + ); +}); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 75069c1a0b..13707c05c7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -78,7 +78,7 @@ export interface TimelineLaneBaseProps { elementId: string, fromClipPercentage: number, toClipPercentage: number, - ) => void; + ) => Promise; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** * Right-click on EMPTY lane space (not on a clip — those preventDefault diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 2ba62c5bd6..71db7d8637 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -70,6 +70,6 @@ export interface TimelineEditCallbacks { elementId: string, fromClipPercentage: number, toClipPercentage: number, - ) => void; + ) => Promise; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } 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, }; From 2c3ed8faceec9d7668e8309047331e0c11fec9e6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 25 Jul 2026 22:53:14 +0200 Subject: [PATCH 2/3] fix(studio): let Escape cancel a keyframe retime and throttle its preview Escape now ends an in-flight diamond drag the way it already ends clip and element drags: the armed gesture is marked cancelled, the preview is dropped, and the pointerup that follows is swallowed instead of falling through to the click branch. The preview also flushes once per animation frame instead of once per pointermove, so a high-rate trackpad no longer re-renders every diamond in the row several times a frame. Single-diamond retime stays the documented scope; multi-select drag needs a batched mutation the script ops do not express yet. --- .../components/TimelineClipDiamonds.test.tsx | 47 +++++++++++++ .../components/TimelineClipDiamonds.tsx | 66 +++++++++++++++++-- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index d22ac46a11..7a6022a846 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -379,6 +379,53 @@ describe("TimelineClipDiamonds", () => { act(() => root.unmount()); }); + it("cancels an in-flight retime on Escape without committing or selecting", () => { + const onClickKeyframe = vi.fn(); + const onMoveKeyframe = vi.fn().mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="40%"]'); + expect(diamond).not.toBeNull(); + + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), + ); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 })); + }); + + // Escape ends the gesture: no retime is written, and the release is not + // reinterpreted as a click that would park the playhead on the keyframe. + expect(onMoveKeyframe).not.toHaveBeenCalled(); + expect(onClickKeyframe).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + // Regression: onClickKeyframe's state updates can re-render the diamond // button out from under the gesture before the browser auto-synthesizes the // "click" event that follows a button's pointerdown+pointerup. That orphaned diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 29fb6b9c77..37510955b0 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -95,6 +95,13 @@ type DragState = { startX: number; fromClipPct: number; moved: boolean; + /** Latest pointer x, flushed to the preview once per frame. */ + lastX: number; + /** Index in the sorted row, needed by the neighbour clamp off the render path. */ + index: number; + /** Escape was pressed: the drag is dead, and the pointerup that follows is + * swallowed rather than falling through to the click branch. */ + cancelled?: boolean; }; function keyframeTarget( @@ -150,6 +157,31 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ // (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); + // One preview render per frame: a 120Hz trackpad fires pointermove far faster + // than the lane can repaint, and every diamond in the row re-evaluates its + // memo on each of those renders. + const previewFrameRef = useRef(null); + const cancelPreviewFrame = () => { + if (previewFrameRef.current === null) return; + cancelAnimationFrame(previewFrameRef.current); + previewFrameRef.current = null; + }; + // Escape backs out of an in-flight retime, the way clip and element drags + // already do. Nothing was written yet (the commit happens on pointerup), so + // dropping the preview is the whole undo. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return; + dragRef.current.cancelled = true; + cancelPreviewFrame(); + setPreview(null); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + cancelPreviewFrame(); + }; + }, []); // Index of the segment whose mid-point ease button is revealed on hover, like // Figma. Null = no segment hovered → no button shown (resting state is just // the connector line + diamonds). @@ -310,6 +342,8 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ dragRef.current = { kfKey, startX: e.clientX, + lastX: e.clientX, + index: i, fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage, moved: false, }; @@ -317,26 +351,38 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ }; const onPointerMove = (e: React.PointerEvent) => { const d = dragRef.current; - if (!d || d.kfKey !== kfKey) return; + if (!d || d.kfKey !== kfKey || d.cancelled) return; + d.lastX = e.clientX; if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) { d.moved = true; } - if (d.moved) { + if (!d.moved || previewFrameRef.current !== null) return; + previewFrameRef.current = requestAnimationFrame(() => { + previewFrameRef.current = null; + const live = dragRef.current; + if (!live || live.kfKey !== kfKey || live.cancelled) return; setPreview({ kfKey, clipPct: previewClipPct({ - pointerDownX: d.startX, - pointerMoveX: e.clientX, + pointerDownX: live.startX, + pointerMoveX: live.lastX, clipWidthPx, - draggedClipPct: d.fromClipPct, - draggedIndex: i, + draggedClipPct: live.fromClipPct, + draggedIndex: live.index, sortedClipPcts, }), }); - } + }); }; const onPointerUp = (e: React.PointerEvent) => { const d = dragRef.current; + if (d?.kfKey === kfKey && d.cancelled) { + // Escape already ended this drag; the release is not a click. + dragRef.current = null; + e.currentTarget.releasePointerCapture?.(e.pointerId); + suppressNextClick(); + return; + } // No drag armed (canDrag false / non-primary press) → treat as a click. if (!d || d.kfKey !== kfKey) { if (e.button !== 0) return; @@ -347,9 +393,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ } e.stopPropagation(); dragRef.current = null; + cancelPreviewFrame(); setPreview(null); e.currentTarget.releasePointerCapture?.(e.pointerId); suppressNextClick(); + // Single-diamond retime by design: a multi-select drag would have to + // move every selected keyframe as one mutation, which the script ops + // do not express yet. Selecting several and dragging one moves only + // the dragged one. const res = resolveKeyframeDrag({ pointerDownX: d.startX, pointerUpX: e.clientX, @@ -447,6 +498,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ // stays stuck at the last previewed position. if (dragRef.current?.kfKey !== kfKey) return; dragRef.current = null; + cancelPreviewFrame(); setPreview(null); e.currentTarget.releasePointerCapture?.(e.pointerId); }} From 32427d9d46d33607eb1febdb7ececfeafef783dc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 26 Jul 2026 00:57:53 +0200 Subject: [PATCH 3/3] refactor(studio): guard the diamond connector's previous keyframe CONTRIBUTING.md asks for a guard clause rather than a non-null assertion outside an already-checked path. The index check and the lookup are now the same guard. --- .../studio/src/player/components/TimelineClipDiamonds.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 37510955b0..a592a4db50 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -239,8 +239,8 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ }} > {sorted.map((kf, i) => { - if (i === 0) return null; - const prev = sorted[i - 1]!; + const prev = sorted[i - 1]; + if (!prev) return null; const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); if (x2 - x1 < 1) return null;