From 3b1d09576f62ab8c4c1985863e03536af4f34b8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 16:04:54 -0400 Subject: [PATCH] fix(studio): remove keyframe dragging from the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging timeline keyframe diamonds was unreliable — clip<->tween percentage remapping, an optimistic-hold workaround, and an intermittent no-op/revert when the GSAP session lagged the drag (its own comments document the flakiness). Remove the drag interaction entirely: diamonds still display, click-to-seek, and offer the context menu (add/remove/ease) — keyframe timing is edited via the playhead + panel, which are deterministic. Deletes the keyframe-move plan module + its wiring through TimelineClipDiamonds -> TimelineCanvas -> Timeline -> TimelineEditContext. --- .../src/components/StudioPreviewArea.tsx | 41 ----- .../components/editor/keyframeMove.test.ts | 101 ------------ .../src/components/editor/keyframeMove.ts | 151 ------------------ .../src/contexts/TimelineEditContext.tsx | 1 - .../studio/src/player/components/Timeline.tsx | 15 -- .../src/player/components/TimelineCanvas.tsx | 13 -- .../components/TimelineClipDiamonds.tsx | 129 +-------------- .../player/components/timelineCallbacks.ts | 1 - 8 files changed, 4 insertions(+), 448 deletions(-) delete mode 100644 packages/studio/src/components/editor/keyframeMove.test.ts delete mode 100644 packages/studio/src/components/editor/keyframeMove.ts diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 45419a4b14..be92a377a4 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -21,8 +21,6 @@ import { useDomEditActionsContext, useDomEditSelectionContext } from "../context import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; -import { fetchParsedAnimations } from "../hooks/useGsapTweenCache"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; export interface StudioPreviewAreaProps { @@ -182,45 +180,6 @@ export function StudioPreviewArea({ } }, // fallow-ignore-next-line complexity - onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => { - // Resolve the dragged element's selection + parsed animations on demand - // (both awaited and cached) rather than relying on the async DOM-edit - // session being loaded for this element — that coupling made the commit - // intermittently no-op (revert) when dragging before the session caught up. - if (!projectId) return; - const sourceFile = _el.sourceFile || activeCompPath || "index.html"; - const [selection, parsed] = await Promise.all([ - buildDomSelectionForTimelineElement(_el), - fetchParsedAnimations(projectId, sourceFile), - ]); - if (!selection || !parsed) return; - - const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id); - const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); - const origAbsTime = _el.start + (oldPct / 100) * _el.duration; - const anim = pickKeyframeTween( - parsed.animations, - _el, - origAbsTime, - cachedKf?.propertyGroup, - ); - if (!anim) return; - - const plan = computeKeyframeMovePlan( - anim, - cachedKf?.tweenPercentage ?? oldPct, - _el, - newPct, - ); - if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection); - for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection); - for (const add of plan.adds) { - for (const [prop, val] of Object.entries(add.properties)) { - handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection); - } - } - }, - // fallow-ignore-next-line complexity onToggleKeyframeAtPlayhead: (el: TimelineElement) => { const currentTime = usePlayerStore.getState().currentTime; const pct = diff --git a/packages/studio/src/components/editor/keyframeMove.test.ts b/packages/studio/src/components/editor/keyframeMove.test.ts deleted file mode 100644 index d7ee679e37..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { pickKeyframeTween, computeKeyframeMovePlan } from "./keyframeMove"; - -const flat = (id: string, target: string, position: number, duration: number, group?: string) => ({ - id, - targetSelector: target, - position, - duration, - resolvedStart: position, - propertyGroup: group, -}); - -const el = { start: 0, duration: 10, domId: "box", selector: "#box" }; - -describe("pickKeyframeTween", () => { - it("matches by the element's selector", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", "#box", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)?.id).toBe("b"); - }); - - it("prefers the dragged keyframe's property group", () => { - const anims = [flat("pos", "#box", 0, 8, "position"), flat("vis", "#box", 0, 8, "visual")]; - expect(pickKeyframeTween(anims, el, 1, "visual")?.id).toBe("vis"); - }); - - it("among same-group tweens picks the one whose window contains the original time", () => { - const fadeIn = flat("in", "#box", 1, 1, "visual"); - const fadeOut = flat("out", "#box", 8, 1, "visual"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 8.5, "visual")?.id).toBe("out"); - expect(pickKeyframeTween([fadeIn, fadeOut], el, 1.2, "visual")?.id).toBe("in"); - }); - - it("returns undefined when there are no tweens", () => { - expect(pickKeyframeTween([], el, 1, undefined)).toBeUndefined(); - }); - - it("returns undefined rather than editing another element on a selector mismatch", () => { - const anims = [flat("a", "#other", 0, 5), flat("b", ".unrelated", 2, 3)]; - expect(pickKeyframeTween(anims, el, 3, undefined)).toBeUndefined(); - }); -}); - -describe("computeKeyframeMovePlan — flat tween", () => { - const anim = flat("t", "#box", 2, 4); // window [2, 6] - - it("start point trims the front, keeping the end fixed", () => { - // newPct 30% → abs 3 → start moves to 3, duration shrinks to 3. - const plan = computeKeyframeMovePlan(anim, 0, el, 30); - expect(plan.meta).toEqual({ position: 3, duration: 3 }); - expect(plan.removes).toEqual([]); - }); - - it("end point resizes, keeping the start", () => { - // tweenOldPct 100 (end) → newPct 80% → abs 8 → duration 6, start unchanged. - const plan = computeKeyframeMovePlan(anim, 100, el, 80); - expect(plan.meta).toEqual({ position: 2, duration: 6 }); - }); -}); - -describe("computeKeyframeMovePlan — keyframe-array tween", () => { - const anim = { - id: "k", - targetSelector: "#box", - position: 0, - duration: 10, - resolvedStart: 0, - keyframes: { - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 50, properties: { x: 50 } }, - { percentage: 100, properties: { x: 100 } }, - ], - }, - }; - - it("moves an intermediate keyframe without touching the tween or others", () => { - // mid keyframe (tweenPct 50) → newPct 70% → abs 7 → 70% of the tween. - const plan = computeKeyframeMovePlan(anim, 50, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([50]); - expect(plan.adds).toEqual([{ pct: 70, properties: { x: 50 } }]); - }); - - it("start move remaps intermediates to preserve their absolute times", () => { - // start (tweenPct 0) → newPct 20% → abs 2 → window [2,10]. The 50% keyframe - // was at abs 5 → now (5-2)/8 = 37.5%. - const plan = computeKeyframeMovePlan(anim, 0, el, 20); - expect(plan.meta).toEqual({ position: 2, duration: 8 }); - expect(plan.removes).toContain(50); - const mid = plan.adds.find((a) => a.properties.x === 50); - expect(mid?.pct).toBeCloseTo(37.5, 1); - }); - - it("is a no-op when the dragged keyframe can't be located (stale cache)", () => { - // tweenOldPct 33 matches no keyframe (0/50/100) → must NOT resize the tween. - const plan = computeKeyframeMovePlan(anim, 33, el, 70); - expect(plan.meta).toBeUndefined(); - expect(plan.removes).toEqual([]); - expect(plan.adds).toEqual([]); - }); -}); diff --git a/packages/studio/src/components/editor/keyframeMove.ts b/packages/studio/src/components/editor/keyframeMove.ts deleted file mode 100644 index 6b38ca7943..0000000000 --- a/packages/studio/src/components/editor/keyframeMove.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Pure helpers for committing a keyframe-diamond drag: pick the tween the - * dragged keyframe belongs to, and compute the GSAP mutations (tween - * position/duration and/or keyframe add/remove) for the move. Kept free of - * React/store so the timeline drag handler stays a thin orchestrator. - */ - -interface TweenLike { - id: string; - targetSelector: string; - position: number | string; - duration?: number; - resolvedStart?: number; - propertyGroup?: string; - keyframes?: { keyframes: { percentage: number; properties: Record }[] }; -} - -interface ElementWindow { - start: number; - duration: number; - domId?: string; - selector?: string; -} - -export interface KeyframeMovePlan { - /** Tween timing change (start/end point drags). */ - meta?: { position: number; duration: number }; - /** Keyframe percentages to remove, then re-add (intermediate move / remap). */ - removes: number[]; - adds: { pct: number; properties: Record }[]; -} - -const round3 = (n: number) => Math.round(n * 1000) / 1000; -const clampPct = (n: number) => Math.max(0, Math.min(100, Math.round(n * 100) / 100)); -const MIN_DUR = 0.05; - -function tweenWindow(a: TweenLike): { start: number; dur: number } { - return { - start: a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0), - dur: a.duration ?? 0, - }; -} - -type Kf = { percentage: number; properties: Record }; - -/** - * Remap every keyframe except `keepIdx` from the old tween window to the new one - * so their absolute times stay fixed after a start/end resize. Returns the - * remove/add ops (empty for flat tweens, which have no intermediates). - */ -function remapKeyframes( - kfs: Kf[], - keepIdx: number, - oldStart: number, - oldDur: number, - newStart: number, - newDur: number, -): Pick { - const removes: number[] = []; - const adds: KeyframeMovePlan["adds"] = []; - if (newDur <= 0) return { removes, adds }; - for (let i = 0; i < kfs.length; i++) { - if (i === keepIdx) continue; - const k = kfs[i]!; - const absT = oldStart + (k.percentage / 100) * oldDur; - const remapped = clampPct(((absT - newStart) / newDur) * 100); - if (Math.abs(remapped - k.percentage) < 0.05) continue; - removes.push(k.percentage); - adds.push({ pct: remapped, properties: k.properties }); - } - return { removes, adds }; -} - -/** - * Pick the tween the dragged keyframe belongs to: restrict to the element's - * selector and (if known) the keyframe's property group, then choose the one - * whose time window contains — or is nearest — the keyframe's original time. - * An element can have several tweens in one group (e.g. fade-in + fade-out). - */ -export function pickKeyframeTween( - anims: T[], - el: ElementWindow, - origAbsTime: number, - group: string | undefined, -): T | undefined { - const selectors = [el.domId ? `#${el.domId}` : null, el.selector].filter(Boolean); - const forEl = anims.filter((a) => selectors.includes(a.targetSelector)); - // Only ever pick among THIS element's tweens. Don't fall back to all - // animations — a selector mismatch (e.g. a class/compound-selector tween) - // would otherwise edit a different element's keyframes. No match → no-op. - if (forEl.length === 0) return undefined; - const groupPool = group ? forEl.filter((a) => a.propertyGroup === group) : []; - const candidates = groupPool.length > 0 ? groupPool : forEl; - const dist = (a: T): number => { - const { start, dur } = tweenWindow(a); - if (origAbsTime >= start && origAbsTime <= start + dur) return 0; - return Math.min(Math.abs(origAbsTime - start), Math.abs(origAbsTime - (start + dur))); - }; - return candidates.reduce((best, a) => (dist(a) < dist(best) ? a : best), candidates[0]!); -} - -/** - * Compute the mutations for moving a keyframe to `newPct` (clip-relative): - * - start point → trim front (position moves, end fixed), - * - end point → resize (duration changes, start fixed), - * - intermediate → move only that keyframe; start/end moves remap the other - * keyframes so their absolute times stay put. - */ -// fallow-ignore-next-line complexity -export function computeKeyframeMovePlan( - anim: TweenLike, - tweenOldPct: number, - el: ElementWindow, - newPct: number, -): KeyframeMovePlan { - const newAbsTime = el.start + (newPct / 100) * el.duration; - const tweenStart = tweenWindow(anim).start; - const tweenDur = anim.duration ?? el.duration; - const kfs = anim.keyframes - ? anim.keyframes.keyframes.slice().sort((a, b) => a.percentage - b.percentage) - : null; - const idx = kfs ? kfs.findIndex((k) => Math.abs(k.percentage - tweenOldPct) < 0.5) : -1; - - // Keyframe-array tween but the dragged keyframe couldn't be located (stale - // cache / precision drift): no-op rather than falling through to an end-point - // resize that would silently rescale the whole tween and re-time every key. - if (kfs && idx === -1) return { removes: [], adds: [] }; - - if (kfs && idx > 0 && idx < kfs.length - 1) { - const movedPct = tweenDur > 0 ? clampPct(((newAbsTime - tweenStart) / tweenDur) * 100) : 0; - return { removes: [tweenOldPct], adds: [{ pct: movedPct, properties: kfs[idx]!.properties }] }; - } - - const isStartPoint = kfs ? idx === 0 : tweenOldPct <= 50; - let newStart = tweenStart; - let newDur = tweenDur; - if (isStartPoint) { - const end = tweenStart + tweenDur; - newStart = Math.max(0, Math.min(newAbsTime, end - MIN_DUR)); - newDur = end - newStart; - } else { - newDur = Math.max(MIN_DUR, newAbsTime - tweenStart); - } - - const windowChanged = newStart !== tweenStart || newDur !== tweenDur; - const remap = - kfs && windowChanged - ? remapKeyframes(kfs, idx, tweenStart, tweenDur, newStart, newDur) - : { removes: [], adds: [] }; - return { meta: { position: round3(newStart), duration: round3(newDur) }, ...remap }; -} diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 5ff84cf953..8481d2054a 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -39,7 +39,6 @@ export function TimelineEditProvider({ value.onDeleteKeyframe, value.onDeleteAllKeyframes, value.onChangeKeyframeEase, - value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, ], ); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index b63589912d..2e2bc2cb3a 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -20,7 +20,6 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; -import { snapKeyframePctToBeat } from "./timelineEditing"; import { ClipContextMenu } from "./ClipContextMenu"; import { GUTTER, @@ -87,7 +86,6 @@ export const Timeline = memo(function Timeline({ onDeleteKeyframe, onDeleteAllKeyframes, onChangeKeyframeEase, - onMoveKeyframe, } = useResolvedTimelineEditCallbacks({ onMoveElement: onMoveElementOverride, onResizeElement: onResizeElementOverride, @@ -481,19 +479,6 @@ export const Timeline = memo(function Timeline({ onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); }} - onDragKeyframe={(el, oldPct, newPct) => { - onMoveKeyframe?.(el, oldPct, newPct); - }} - onSnapKeyframePct={(el, pct) => - snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps) - } - onPickKeyframeElement={(el) => { - const elKey = el.key ?? el.id; - if (selectedElementId !== elKey) { - setSelectedElementId(elKey); - onSelectElement?.(el); - } - }} onContextMenuKeyframe={(e, elId, pct) => { const el = expandedElements.find((x) => (x.key ?? x.id) === elId); if (el) { diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index fa12a9f1e1..9bdab10aee 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -91,11 +91,6 @@ interface TimelineCanvasProps { currentTime: number; onClickKeyframe?: (element: TimelineElement, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; - /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */ - onSnapKeyframePct?: (element: TimelineElement, pct: number) => number; - /** Select the element when a keyframe drag starts (loads its GSAP session). */ - onPickKeyframeElement?: (element: TimelineElement) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; beatAnalysis?: MusicBeatAnalysis | null; @@ -143,9 +138,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ currentTime, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, - onSnapKeyframePct, - onPickKeyframeElement, onContextMenuKeyframe, onContextMenuClip, beatAnalysis, @@ -446,11 +438,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({ selectedKeyframes={selectedKeyframes} onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} onShiftClickKeyframe={onShiftClickKeyframe} - onDragKeyframe={(oldPct, newPct) => - onDragKeyframe?.(previewElement, oldPct, newPct) - } - snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct} - onPickForDrag={() => onPickKeyframeElement?.(previewElement)} onContextMenuKeyframe={onContextMenuKeyframe} /> )} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index d396e54f8a..84885f754a 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from "react"; +import { memo } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; interface KeyframeEntry { @@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps { selectedKeyframes: Set; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onDragKeyframe?: (percentage: number, newPercentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; - /** Snap a clip-relative percentage to the nearest beat (returns it unchanged - * when no beat is within range). Drives live beat-snapping while dragging. */ - snapPct?: (percentage: number) => number; - /** Select this element when a keyframe drag begins, so its GSAP session is - * loaded by the time the move commits (diamonds render on unselected clips - * too, and a drag suppresses the selecting click). */ - onPickForDrag?: () => void; } const DIAMOND_RATIO = 0.8; @@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ selectedKeyframes, onClickKeyframe, onShiftClickKeyframe, - onDragKeyframe, onContextMenuKeyframe, - snapPct, - onPickForDrag, }: TimelineClipDiamondsProps) { - // Live drag: which keyframe (by original %) is being dragged and its current - // (beat-snapped) %, so the diamond + its connecting lines follow the cursor. - const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null); - const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null); - // Commit through the latest callback, not the one captured at pointer-down: - // selecting the element on drag-start loads its GSAP session asynchronously, - // and the commit must use the closure that sees the loaded session. - const onDragKeyframeRef = useRef(onDragKeyframe); - onDragKeyframeRef.current = onDragKeyframe; - // Optimistic hold: after a commit, keep the diamond at the dropped position - // until the cache reflects the change (the file round-trip rewrites - // keyframesData), so it doesn't flash back to the old spot in between. - const pendingRef = useRef(false); - const pendingHeldPctRef = useRef(null); - const pendingTimerRef = useRef | null>(null); - // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag - // (clip deleted, comp switch, zoom-out → early return) doesn't leak them. - const dragCleanupRef = useRef<(() => void) | null>(null); - - useEffect(() => { - if (!pendingRef.current) return; - // Only release the optimistic hold once the cache actually reflects the - // committed position (a keyframe near the held %). An unrelated cache - // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME - // percentages — releasing then would flash the diamond back to the old spot. - const held = pendingHeldPctRef.current; - if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) { - return; - } - pendingRef.current = false; - pendingHeldPctRef.current = null; - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - setDrag(null); - }, [keyframesData]); - - useEffect( - () => () => { - clearTimeout(pendingTimerRef.current ?? undefined); - dragCleanupRef.current?.(); - }, - [], - ); - if (clipWidthPx < 20) return null; // When the beat strip occupies the top band, shrink the diamonds and center @@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ } }; - const handlePointerDown = (e: React.PointerEvent, pct: number) => { - if (e.button !== 0) return; - e.stopPropagation(); - // Ignore a new drag while a prior drop is still settling: `pct` comes from - // props (the pre-drop position) but the diamond is held at its dropped spot - // via effPct(), so a re-grab would track from a stale origin and commit - // against the wrong tween. The hold clears on the cache round-trip (≤2s). - if (pendingRef.current) return; - // Select the element up front so its GSAP session loads during the drag and - // the commit (which resolves the animation from the selection) isn't a no-op. - onPickForDrag?.(); - const startX = e.clientX; - dragRef.current = { origPct: pct, pct, moved: false }; - - const handleMove = (me: PointerEvent) => { - const d = dragRef.current; - if (!d) return; - const dx = me.clientX - startX; - // 4px dead zone so a click doesn't register as a drag. - if (!d.moved && Math.abs(dx) <= 4) return; - d.moved = true; - const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100)); - const snapped = snapPct ? snapPct(rawPct) : rawPct; - d.pct = snapped; - setDrag({ origPct: pct, pct: snapped }); - }; - - const handleUp = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - dragCleanupRef.current = null; - const d = dragRef.current; - dragRef.current = null; - const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5); - if (willCommit && d) { - // Hold the dropped position optimistically; the effect clears it once the - // cache round-trip lands (fallback timeout in case it never does). - pendingRef.current = true; - pendingHeldPctRef.current = d.pct; - setDrag({ origPct: d.origPct, pct: d.pct }); - if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current); - pendingTimerRef.current = setTimeout(() => { - pendingRef.current = false; - pendingHeldPctRef.current = null; - setDrag(null); - }, 2000); - onDragKeyframeRef.current?.(d.origPct, d.pct); - } else { - setDrag(null); - } - }; - - dragCleanupRef.current = () => { - document.removeEventListener("pointermove", handleMove); - document.removeEventListener("pointerup", handleUp); - }; - - document.addEventListener("pointermove", handleMove); - document.addEventListener("pointerup", handleUp); - }; - - const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p); - return (
{sorted.map((kf, i) => { if (i === 0) return null; const prev = sorted[i - 1]!; - const x1 = Math.max( - 0, - Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx), - ); - const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.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)); if (x2 - x1 < 1) return null; return (
handleClick(e, kf.percentage)} - onPointerDown={(e) => handlePointerDown(e, kf.percentage)} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 9202acc304..a219e2a710 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -39,6 +39,5 @@ export interface TimelineEditCallbacks { onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; }