keyframe snap and move

This commit is contained in:
Siddharth
2026-02-06 22:14:39 -08:00
parent 05f4e74de6
commit 8e94dcbc2c
2 changed files with 96 additions and 12 deletions
@@ -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<HTMLDivElement>;
}
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => {
const { sidebarWidth, range, valueToPixels } = useTimelineContext();
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
onKeyframeMove,
videoDurationMs,
timelineRef
}) => {
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(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 (
<div
key={kf.id}
className={`absolute top-8 cursor-pointer ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{ left: `${sidebarWidth + offset - 8}px`, zIndex: 40 }}
onClick={e => {
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)`}
>
<div style={{
width: '10px',
@@ -36,7 +91,6 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKe
background: '#ffe100ff',
transform: 'rotate(45deg)',
border: 'none',
opacity: isSelected ? 1 : 0.6,
transition: 'opacity 0.15s',
}} />
@@ -160,11 +160,13 @@ function PlaybackCursor({
videoDurationMs,
onSeek,
timelineRef,
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
timelineRef: React.RefObject<HTMLDivElement>;
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<HTMLDivElement | null>(null);
@@ -432,6 +448,7 @@ function Timeline({
videoDurationMs={videoDurationMs}
onSeek={onSeek}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
<Row id={ZOOM_ROW_ID}>
@@ -526,6 +543,7 @@ export default function TimelineEditor({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const timelineContainerRef = useRef<HTMLDivElement>(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({
</span>
</div>
</div>
<div className="flex-1 overflow-hidden bg-[#09090b] relative"
<div
ref={timelineContainerRef}
className="flex-1 overflow-hidden bg-[#09090b] relative"
onClick={() => setSelectedKeyframeId(null)}
>
<TimelineWrapper
@@ -940,6 +966,9 @@ export default function TimelineEditor({
keyframes={keyframes}
selectedKeyframeId={selectedKeyframeId}
setSelectedKeyframeId={setSelectedKeyframeId}
onKeyframeMove={handleKeyframeMove}
videoDurationMs={totalMs}
timelineRef={timelineContainerRef}
/>
<Timeline
items={timelineItems}
@@ -953,6 +982,7 @@ export default function TimelineEditor({
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
keyframes={keyframes}
/>
</TimelineWrapper>
</div>