fix: some timeline issues
This commit is contained in:
@ -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)}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
Reference in New Issue
Block a user