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,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
const { updateClipTrim, addClipToTrack, removeClipFromTrack } =
const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
useTimelineStore();
const { currentTime } = usePlaybackStore();
const { draggedClipId, getDraggedClipPosition } =
useDragClip(zoomLevel);
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false);
@ -34,12 +32,13 @@ export function TimelineClip({
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position
const dragPosition = getDraggedClipPosition(clip.id);
const clipStartTime = dragPosition !== null ? dragPosition : clip.startTime;
const isBeingDragged = dragState.clipId === clip.id;
const clipStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel;
const isBeingDragged = draggedClipId === clip.id;
const getTrackColor = (type: string) => {
switch (type) {
case "video":
@ -210,7 +209,7 @@ export function TimelineClip({
return (
<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` }}
onMouseDown={(e) => onClipMouseDown(e, clip)}
onClick={(e) => onClipClick(e, clip)}

View File

@ -60,6 +60,14 @@ export function Timeline() {
updateClipTrim,
undo,
redo,
moveClipToTrack,
updateClipStartTime,
selectClip,
deselectClip,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const {
@ -941,7 +949,7 @@ export function Timeline() {
{/* Playhead for tracks area (scrubbable) */}
{tracks.length > 0 && (
<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={{
left: `${playheadPosition * 50 * zoomLevel}px`,
height: `${tracks.length * 60}px`,
@ -1143,11 +1151,13 @@ function TimelineTrackContent({
selectedClips,
selectClip,
deselectClip,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
} = useTimelineStore();
// Mouse-based drag hook
const { isDragging, startDrag, endDrag, timelineRef } =
useDragClip(zoomLevel);
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
const [wouldOverlap, setWouldOverlap] = useState(false);
@ -1155,6 +1165,92 @@ function TimelineTrackContent({
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) => {
// Handle selection first
if (!justFinishedDrag) {
@ -1178,23 +1274,39 @@ function TimelineTrackContent({
const clickOffsetX = e.clientX - clipRect.left;
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) => {
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
if (justFinishedDrag) {
console.log("Skipping because justFinishedDrag");
return;
}
// Close context menu if it's open
if (contextMenu) {
console.log("Closing context menu");
setContextMenu(null);
return; // Don't handle selection when closing context menu
}
console.log("Proceeding to selection logic");
// Only handle deselection here (selection is handled in mouseDown)
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
@ -1220,13 +1332,14 @@ function TimelineTrackContent({
// Reset drag flag when drag ends
useEffect(() => {
if (!isDragging && justFinishedDrag) {
if (!dragState.isDragging && justFinishedDrag) {
const timer = setTimeout(() => setJustFinishedDrag(false), 50);
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);
}
}, [isDragging, justFinishedDrag]);
}, [dragState.isDragging, justFinishedDrag, dragState.clipId]);
const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault();
@ -1576,11 +1689,6 @@ function TimelineTrackContent({
onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave}
onDrop={handleTrackDrop}
onMouseUp={(e) => {
if (isDragging) {
endDrag(track.id);
}
}}
>
<div
ref={timelineRef}

View File

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

View File

@ -30,6 +30,27 @@ interface TimelineStore {
clearSelectedClips: () => 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
addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
@ -73,7 +94,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
// Deep copy tracks
set({
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({
tracks: prev,
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) {
// Toggle selection
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 }] };
} else {
return { selectedClips: [{ trackId, clipId }] };
@ -105,7 +130,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
},
deselectClip: (trackId, clipId) => {
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: () => {
@ -161,7 +188,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: state.tracks
.map((track) =>
track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track
)
// Remove track if it becomes empty
@ -261,4 +291,56 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const next = redoStack[redoStack.length - 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,
},
});
},
}));