fix: some timeline issues

This commit is contained in:
Maze Winther
2025-06-26 00:37:35 +02:00
parent 75eede20af
commit e225272ec3
4 changed files with 2586 additions and 2384 deletions

View File

@ -21,11 +21,9 @@ export function TimelineClip({
onClipClick, onClipClick,
}: TimelineClipProps) { }: TimelineClipProps) {
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { updateClipTrim, addClipToTrack, removeClipFromTrack } = const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
useTimelineStore(); useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
const { draggedClipId, getDraggedClipPosition } =
useDragClip(zoomLevel);
const [resizing, setResizing] = useState<ResizeState | null>(null); const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false); const [clipMenuOpen, setClipMenuOpen] = useState(false);
@ -34,12 +32,13 @@ export function TimelineClip({
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position // Use real-time position during drag, otherwise use stored position
const dragPosition = getDraggedClipPosition(clip.id); const isBeingDragged = dragState.clipId === clip.id;
const clipStartTime = dragPosition !== null ? dragPosition : clip.startTime; const clipStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel; const clipLeft = clipStartTime * 50 * zoomLevel;
const isBeingDragged = draggedClipId === clip.id;
const getTrackColor = (type: string) => { const getTrackColor = (type: string) => {
switch (type) { switch (type) {
case "video": case "video":
@ -210,7 +209,7 @@ export function TimelineClip({
return ( return (
<div <div
className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""} ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`} className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onMouseDown={(e) => onClipMouseDown(e, clip)} onMouseDown={(e) => onClipMouseDown(e, clip)}
onClick={(e) => onClipClick(e, clip)} onClick={(e) => onClipClick(e, clip)}

View File

@ -60,6 +60,14 @@ export function Timeline() {
updateClipTrim, updateClipTrim,
undo, undo,
redo, redo,
moveClipToTrack,
updateClipStartTime,
selectClip,
deselectClip,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
} = useTimelineStore(); } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { const {
@ -941,7 +949,7 @@ export function Timeline() {
{/* Playhead for tracks area (scrubbable) */} {/* Playhead for tracks area (scrubbable) */}
{tracks.length > 0 && ( {tracks.length > 0 && (
<div <div
className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize" className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20"
style={{ style={{
left: `${playheadPosition * 50 * zoomLevel}px`, left: `${playheadPosition * 50 * zoomLevel}px`,
height: `${tracks.length * 60}px`, height: `${tracks.length * 60}px`,
@ -1143,11 +1151,13 @@ function TimelineTrackContent({
selectedClips, selectedClips,
selectClip, selectClip,
deselectClip, deselectClip,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
} = useTimelineStore(); } = useTimelineStore();
// Mouse-based drag hook const timelineRef = useRef<HTMLDivElement>(null);
const { isDragging, startDrag, endDrag, timelineRef } =
useDragClip(zoomLevel);
const [isDropping, setIsDropping] = useState(false); const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null); const [dropPosition, setDropPosition] = useState<number | null>(null);
const [wouldOverlap, setWouldOverlap] = useState(false); const [wouldOverlap, setWouldOverlap] = useState(false);
@ -1155,6 +1165,92 @@ function TimelineTrackContent({
const [justFinishedDrag, setJustFinishedDrag] = useState(false); const [justFinishedDrag, setJustFinishedDrag] = useState(false);
// Set up mouse event listeners for drag
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const snappedTime = Math.round(adjustedTime * 10) / 10;
updateDragTime(snappedTime);
};
const handleMouseUp = () => {
if (!dragState.clipId || !dragState.trackId) return;
const finalTime = dragState.currentTime;
// Check for overlaps and update position
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingClip = sourceTrack?.clips.find(
(c) => c.id === dragState.clipId
);
if (movingClip) {
const movingClipDuration =
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
const movingClipEnd = finalTime + movingClipDuration;
const targetTrack = tracks.find((t) => t.id === track.id);
const hasOverlap = targetTrack?.clips.some((existingClip) => {
if (
dragState.trackId === track.id &&
existingClip.id === dragState.clipId
) {
return false;
}
const existingStart = existingClip.startTime;
const existingEnd =
existingClip.startTime +
(existingClip.duration -
existingClip.trimStart -
existingClip.trimEnd);
return finalTime < existingEnd && movingClipEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === track.id) {
updateClipStartTime(track.id, dragState.clipId, finalTime);
} else {
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
requestAnimationFrame(() => {
updateClipStartTime(track.id, dragState.clipId!, finalTime);
});
}
}
}
endDragAction();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.clipId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
track.id,
updateDragTime,
updateClipStartTime,
moveClipToTrack,
endDragAction,
]);
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
// Handle selection first // Handle selection first
if (!justFinishedDrag) { if (!justFinishedDrag) {
@ -1178,23 +1274,39 @@ function TimelineTrackContent({
const clickOffsetX = e.clientX - clipRect.left; const clickOffsetX = e.clientX - clipRect.left;
const clickOffsetTime = clickOffsetX / (50 * zoomLevel); const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); startDragAction(
clip.id,
track.id,
e.clientX,
clip.startTime,
clickOffsetTime
);
}; };
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
e.stopPropagation(); e.stopPropagation();
console.log(
"handleClipClick called, contextMenu:",
JSON.stringify(contextMenu)
);
console.log("Boolean check:", !!contextMenu, "Type:", typeof contextMenu);
// Don't handle click if we just finished dragging // Don't handle click if we just finished dragging
if (justFinishedDrag) { if (justFinishedDrag) {
console.log("Skipping because justFinishedDrag");
return; return;
} }
// Close context menu if it's open // Close context menu if it's open
if (contextMenu) { if (contextMenu) {
console.log("Closing context menu");
setContextMenu(null); setContextMenu(null);
return; // Don't handle selection when closing context menu return; // Don't handle selection when closing context menu
} }
console.log("Proceeding to selection logic");
// Only handle deselection here (selection is handled in mouseDown) // Only handle deselection here (selection is handled in mouseDown)
const isSelected = selectedClips.some( const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id (c) => c.trackId === track.id && c.clipId === clip.id
@ -1220,13 +1332,14 @@ function TimelineTrackContent({
// Reset drag flag when drag ends // Reset drag flag when drag ends
useEffect(() => { useEffect(() => {
if (!isDragging && justFinishedDrag) { if (!dragState.isDragging && justFinishedDrag) {
const timer = setTimeout(() => setJustFinishedDrag(false), 50); const timer = setTimeout(() => setJustFinishedDrag(false), 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} else if (isDragging && !justFinishedDrag) { } else if (!dragState.isDragging && dragState.clipId && !justFinishedDrag) {
// Only set justFinishedDrag when a drag actually ends (not when it starts)
setJustFinishedDrag(true); setJustFinishedDrag(true);
} }
}, [isDragging, justFinishedDrag]); }, [dragState.isDragging, justFinishedDrag, dragState.clipId]);
const handleTrackDragOver = (e: React.DragEvent) => { const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -1576,11 +1689,6 @@ function TimelineTrackContent({
onDragEnter={handleTrackDragEnter} onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave} onDragLeave={handleTrackDragLeave}
onDrop={handleTrackDrop} onDrop={handleTrackDrop}
onMouseUp={(e) => {
if (isDragging) {
endDrag(track.id);
}
}}
> >
<div <div
ref={timelineRef} ref={timelineRef}

View File

@ -25,6 +25,10 @@ export function useDragClip(zoomLevel: number) {
}); });
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef(dragState);
// Keep ref in sync with state
dragStateRef.current = dragState;
const startDrag = useCallback( const startDrag = useCallback(
( (
@ -52,7 +56,9 @@ export function useDragClip(zoomLevel: number) {
const updateDrag = useCallback( const updateDrag = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!dragState.isDragging || !timelineRef.current) return; if (!dragState.isDragging || !timelineRef.current) {
return;
}
const timelineRect = timelineRef.current.getBoundingClientRect(); const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left; const mouseX = e.clientX - timelineRect.left;
@ -172,12 +178,17 @@ export function useDragClip(zoomLevel: number) {
const getDraggedClipPosition = useCallback( const getDraggedClipPosition = useCallback(
(clipId: string) => { (clipId: string) => {
if (dragState.isDragging && dragState.clipId === clipId) { // Use ref to get current state, not stale closure
return dragState.currentTime; const currentDragState = dragStateRef.current;
const isMatch =
currentDragState.isDragging && currentDragState.clipId === clipId;
if (isMatch) {
return currentDragState.currentTime;
} }
return null; return null;
}, },
[dragState] [] // No dependencies needed since we use ref
); );
const isValidDropTarget = useCallback( const isValidDropTarget = useCallback(
@ -199,6 +210,8 @@ export function useDragClip(zoomLevel: number) {
// State // State
isDragging: dragState.isDragging, isDragging: dragState.isDragging,
draggedClipId: dragState.clipId, draggedClipId: dragState.clipId,
currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
// Methods // Methods
startDrag, startDrag,

View File

@ -30,6 +30,27 @@ interface TimelineStore {
clearSelectedClips: () => void; clearSelectedClips: () => void;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
// Drag state
dragState: {
isDragging: boolean;
clipId: string | null;
trackId: string | null;
startMouseX: number;
startClipTime: number;
clickOffsetTime: number;
currentTime: number;
};
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
startDrag: (
clipId: string,
trackId: string,
startMouseX: number,
startClipTime: number,
clickOffsetTime: number
) => void;
updateDragTime: (currentTime: number) => void;
endDrag: () => void;
// Actions // Actions
addTrack: (type: "video" | "audio" | "effects") => string; addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void; removeTrack: (trackId: string) => void;
@ -73,7 +94,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
// Deep copy tracks // Deep copy tracks
set({ set({
history: [...history, JSON.parse(JSON.stringify(tracks))], history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [] // Clear redo stack when new action is performed redoStack: [], // Clear redo stack when new action is performed
}); });
}, },
@ -84,7 +105,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
set({ set({
tracks: prev, tracks: prev,
history: history.slice(0, -1), history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
}); });
}, },
@ -96,7 +117,11 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (multi) { if (multi) {
// Toggle selection // Toggle selection
return exists return exists
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } ? {
selectedClips: state.selectedClips.filter(
(c) => !(c.trackId === trackId && c.clipId === clipId)
),
}
: { selectedClips: [...state.selectedClips, { trackId, clipId }] }; : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else { } else {
return { selectedClips: [{ trackId, clipId }] }; return { selectedClips: [{ trackId, clipId }] };
@ -105,7 +130,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
deselectClip: (trackId, clipId) => { deselectClip: (trackId, clipId) => {
set((state) => ({ set((state) => ({
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), selectedClips: state.selectedClips.filter(
(c) => !(c.trackId === trackId && c.clipId === clipId)
),
})); }));
}, },
clearSelectedClips: () => { clearSelectedClips: () => {
@ -161,7 +188,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: state.tracks tracks: state.tracks
.map((track) => .map((track) =>
track.id === trackId track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } ? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track : track
) )
// Remove track if it becomes empty // Remove track if it becomes empty
@ -261,4 +291,56 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const next = redoStack[redoStack.length - 1]; const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) }); set({ tracks: next, redoStack: redoStack.slice(0, -1) });
}, },
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
setDragState: (dragState) =>
set((state) => ({
dragState: { ...state.dragState, ...dragState },
})),
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
set({
dragState: {
isDragging: true,
clipId,
trackId,
startMouseX,
startClipTime,
clickOffsetTime,
currentTime: startClipTime,
},
});
},
updateDragTime: (currentTime) => {
set((state) => ({
dragState: {
...state.dragState,
currentTime,
},
}));
},
endDrag: () => {
set({
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
});
},
})); }));