refactor: move to a typed-tracks system and add support for text
This commit is contained in:
@ -3,23 +3,24 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
interface DragState {
|
||||
isDragging: boolean;
|
||||
clipId: string | null;
|
||||
elementId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startClipTime: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export function useDragClip(zoomLevel: number) {
|
||||
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
|
||||
const { tracks, updateElementStartTime, moveElementToTrack } =
|
||||
useTimelineStore();
|
||||
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
elementId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
@ -33,9 +34,9 @@ export function useDragClip(zoomLevel: number) {
|
||||
const startDrag = useCallback(
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
clipId: string,
|
||||
elementId: string,
|
||||
trackId: string,
|
||||
clipStartTime: number,
|
||||
elementStartTime: number,
|
||||
clickOffsetTime: number
|
||||
) => {
|
||||
e.preventDefault();
|
||||
@ -43,12 +44,12 @@ export function useDragClip(zoomLevel: number) {
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
clipId,
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX: e.clientX,
|
||||
startClipTime: clipStartTime,
|
||||
startElementTime: elementStartTime,
|
||||
clickOffsetTime,
|
||||
currentTime: clipStartTime,
|
||||
currentTime: elementStartTime,
|
||||
});
|
||||
},
|
||||
[]
|
||||
@ -76,7 +77,7 @@ export function useDragClip(zoomLevel: number) {
|
||||
|
||||
const endDrag = useCallback(
|
||||
(targetTrackId?: string) => {
|
||||
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
|
||||
if (!dragState.isDragging || !dragState.elementId || !dragState.trackId)
|
||||
return;
|
||||
|
||||
const finalTrackId = targetTrackId || dragState.trackId;
|
||||
@ -85,71 +86,81 @@ export function useDragClip(zoomLevel: number) {
|
||||
// Check for overlaps
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const targetTrack = tracks.find((t) => t.id === finalTrackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(e) => e.id === dragState.elementId
|
||||
);
|
||||
|
||||
if (!movingClip || !targetTrack) {
|
||||
if (!movingElement || !targetTrack) {
|
||||
setDragState((prev) => ({ ...prev, isDragging: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
// Skip the element being moved if it's on the same track
|
||||
if (
|
||||
dragState.trackId === finalTrackId &&
|
||||
existingClip.id === dragState.clipId
|
||||
existingElement.id === dragState.elementId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
return finalTime < existingEnd && movingClipEnd > existingStart;
|
||||
return finalTime < existingEnd && movingElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === finalTrackId) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
updateElementStartTime(finalTrackId, dragState.elementId!, finalTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
|
||||
moveElementToTrack(
|
||||
dragState.trackId!,
|
||||
finalTrackId,
|
||||
dragState.elementId!
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
updateElementStartTime(
|
||||
finalTrackId,
|
||||
dragState.elementId!,
|
||||
finalTime
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
elementId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
},
|
||||
[dragState, tracks, updateClipStartTime, moveClipToTrack]
|
||||
[dragState, tracks, updateElementStartTime, moveElementToTrack]
|
||||
);
|
||||
|
||||
const cancelDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
elementId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
@ -176,12 +187,12 @@ export function useDragClip(zoomLevel: number) {
|
||||
};
|
||||
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
|
||||
|
||||
const getDraggedClipPosition = useCallback(
|
||||
(clipId: string) => {
|
||||
const getDraggedElementPosition = useCallback(
|
||||
(elementId: string) => {
|
||||
// Use ref to get current state, not stale closure
|
||||
const currentDragState = dragStateRef.current;
|
||||
const isMatch =
|
||||
currentDragState.isDragging && currentDragState.clipId === clipId;
|
||||
currentDragState.isDragging && currentDragState.elementId === elementId;
|
||||
|
||||
if (isMatch) {
|
||||
return currentDragState.currentTime;
|
||||
@ -209,7 +220,7 @@ export function useDragClip(zoomLevel: number) {
|
||||
return {
|
||||
// State
|
||||
isDragging: dragState.isDragging,
|
||||
draggedClipId: dragState.clipId,
|
||||
draggedElementId: dragState.elementId,
|
||||
currentDragTime: dragState.currentTime,
|
||||
clickOffsetTime: dragState.clickOffsetTime,
|
||||
|
||||
@ -217,7 +228,7 @@ export function useDragClip(zoomLevel: number) {
|
||||
startDrag,
|
||||
endDrag,
|
||||
cancelDrag,
|
||||
getDraggedClipPosition,
|
||||
getDraggedElementPosition,
|
||||
isValidDropTarget,
|
||||
|
||||
// Refs
|
||||
|
@ -7,106 +7,105 @@ export const usePlaybackControls = () => {
|
||||
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
|
||||
|
||||
const {
|
||||
selectedClips,
|
||||
selectedElements,
|
||||
tracks,
|
||||
splitClip,
|
||||
splitElement,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
|
||||
const handleSplitSelectedClip = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip to split");
|
||||
const handleSplitSelectedElement = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!clip) return;
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitClip(trackId, clipId, currentTime);
|
||||
toast.success("Clip split at playhead");
|
||||
}, [selectedClips, tracks, currentTime, splitClip]);
|
||||
splitElement(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitElement]);
|
||||
|
||||
const handleSplitAndKeepLeftCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!clip) return;
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept left portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
|
||||
|
||||
const handleSplitAndKeepRightCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!clip) return;
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept right portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
|
||||
|
||||
const handleSeparateAudioCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one video clip to separate audio");
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!track || track.type !== "video") {
|
||||
toast.error("Select a video clip to separate audio");
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
separateAudio(trackId, clipId);
|
||||
toast.success("Audio separated to audio track");
|
||||
}, [selectedClips, tracks, separateAudio]);
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@ -130,7 +129,7 @@ export const usePlaybackControls = () => {
|
||||
case "s":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitSelectedClip();
|
||||
handleSplitSelectedElement();
|
||||
}
|
||||
break;
|
||||
|
||||
@ -160,7 +159,7 @@ export const usePlaybackControls = () => {
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
handleSplitSelectedClip,
|
||||
handleSplitSelectedElement,
|
||||
handleSplitAndKeepLeftCallback,
|
||||
handleSplitAndKeepRightCallback,
|
||||
handleSeparateAudioCallback,
|
||||
|
Reference in New Issue
Block a user