From 8e94dcbc2ce149ad838956fa367e5b4cb3fa81f9 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 6 Feb 2026 22:14:39 -0800 Subject: [PATCH] keyframe snap and move --- .../video-editor/timeline/KeyframeMarkers.tsx | 70 ++++++++++++++++--- .../video-editor/timeline/TimelineEditor.tsx | 38 ++++++++-- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/timeline/KeyframeMarkers.tsx b/src/components/video-editor/timeline/KeyframeMarkers.tsx index ce4973d..ed0bbfc 100644 --- a/src/components/video-editor/timeline/KeyframeMarkers.tsx +++ b/src/components/video-editor/timeline/KeyframeMarkers.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { useTimelineContext } from "dnd-timeline"; interface Keyframe { @@ -10,25 +10,80 @@ interface KeyframeMarkersProps { keyframes: Keyframe[]; selectedKeyframeId: string | null; setSelectedKeyframeId: (id: string | null) => void; + onKeyframeMove: (id: string, newTime: number) => void; + videoDurationMs: number; + timelineRef: React.RefObject; } -const KeyframeMarkers: React.FC = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => { - const { sidebarWidth, range, valueToPixels } = useTimelineContext(); +const KeyframeMarkers: React.FC = ({ + keyframes, + selectedKeyframeId, + setSelectedKeyframeId, + onKeyframeMove, + videoDurationMs, + timelineRef +}) => { + const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext(); + const [draggingKeyframeId, setDraggingKeyframeId] = useState(null); + + useEffect(() => { + if (!draggingKeyframeId) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + const rect = timelineRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left - sidebarWidth; + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + // Update the keyframe position in real-time + onKeyframeMove(draggingKeyframeId, absoluteMs); + }; + + const handleMouseUp = () => { + setDraggingKeyframeId(null); + document.body.style.cursor = ''; + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'ew-resize'; + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + }; + }, [draggingKeyframeId, onKeyframeMove, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]); + return ( <> {keyframes.map(kf => { const offset = valueToPixels(kf.time - range.start); const isSelected = kf.id === selectedKeyframeId; + const isDragging = kf.id === draggingKeyframeId; + return (
{ + className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`} + style={{ + left: `${sidebarWidth + offset - 8}px`, + zIndex: isDragging ? 50 : 40, + transition: isDragging ? 'none' : 'left 0.1s ease-out' + }} + onMouseDown={e => { + e.stopPropagation(); + setSelectedKeyframeId(kf.id); + setDraggingKeyframeId(kf.id); + }} + onContextMenu={e => { + e.preventDefault(); e.stopPropagation(); setSelectedKeyframeId(kf.id); }} - title={`Keyframe @ ${kf.time}ms`} + title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`} >
= ({ keyframes, selectedKe background: '#ffe100ff', transform: 'rotate(45deg)', border: 'none', - opacity: isSelected ? 1 : 0.6, transition: 'opacity 0.15s', }} /> diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index bdf9558..635e1c3 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -160,11 +160,13 @@ function PlaybackCursor({ videoDurationMs, onSeek, timelineRef, + keyframes = [], }: { currentTimeMs: number; videoDurationMs: number; onSeek?: (time: number) => void; timelineRef: React.RefObject; + keyframes?: { id: string; time: number }[]; }) { const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -181,7 +183,19 @@ function PlaybackCursor({ // Allow dragging outside to 0 or max, but clamp the value const relativeMs = pixelsToValue(clickX); - const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + // Snap to nearby keyframe if within threshold (150ms) + const snapThresholdMs = 150; + const nearbyKeyframe = keyframes.find(kf => + Math.abs(kf.time - absoluteMs) <= snapThresholdMs && + kf.time >= range.start && + kf.time <= range.end + ); + + if (nearbyKeyframe) { + absoluteMs = nearbyKeyframe.time; + } onSeek(absoluteMs / 1000); }; @@ -200,7 +214,7 @@ function PlaybackCursor({ window.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; }; - }, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]); + }, [isDragging, onSeek, timelineRef, sidebarWidth, range.start, range.end, videoDurationMs, pixelsToValue, keyframes]); if (videoDurationMs <= 0 || currentTimeMs < 0) { return null; @@ -372,6 +386,7 @@ function Timeline({ selectedZoomId, selectedTrimId, selectedAnnotationId, + keyframes = [], }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -384,6 +399,7 @@ function Timeline({ selectedZoomId: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; + keyframes?: { id: string; time: number }[]; }) { const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const localTimelineRef = useRef(null); @@ -432,6 +448,7 @@ function Timeline({ videoDurationMs={videoDurationMs} onSeek={onSeek} timelineRef={localTimelineRef} + keyframes={keyframes} /> @@ -526,6 +543,7 @@ export default function TimelineEditor({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' }); + const timelineContainerRef = useRef(null); useEffect(() => { formatShortcut(['shift', 'mod', 'Scroll']).then(pan => { @@ -550,6 +568,11 @@ export default function TimelineEditor({ setSelectedKeyframeId(null); }, [selectedKeyframeId]); + // Move keyframe to new time position + const handleKeyframeMove = useCallback((id: string, newTime: number) => { + setKeyframes(prev => prev.map(kf => kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf)); + }, [totalMs]); + // Delete selected zoom item const deleteSelectedZoom = useCallback(() => { if (!selectedZoomId) return; @@ -756,7 +779,8 @@ export default function TimelineEditor({ } } } - if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) { + // Delete key or Ctrl+D / Cmd+D + if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) { if (selectedKeyframeId) { deleteSelectedKeyframe(); } else if (selectedZoomId) { @@ -923,7 +947,9 @@ export default function TimelineEditor({
-
setSelectedKeyframeId(null)} >