keyframe snap and move
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user