refactor: move to a typed-tracks system and add support for text

This commit is contained in:
Maze Winther
2025-07-06 20:45:29 +02:00
parent 0e32c732dd
commit 40c7fbb4f8
20 changed files with 1799 additions and 1469 deletions

View File

@ -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

View File

@ -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,