This commit is contained in:
Maze Winther
2025-12-25 21:15:36 +01:00
parent 2da84e7132
commit d5ca991501
102 changed files with 16028 additions and 6675 deletions
@@ -0,0 +1,414 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
useTimelineSnapping,
SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
interface UseTimelineDragDropProps {
track?: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useTimelineDragDrop({
track,
zoomLevel,
}: UseTimelineDragDropProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
const [dropPositionIndicator, setDropPositionIndicator] = useState<
number | null
>(null);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const {
tracks,
addElementToTrack,
insertTrackAt,
addTrack,
snappingEnabled,
} = useTimelineStore();
const dragCounterRef = useRef(0);
const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
const getDropSnappedTime = useCallback(
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
const projectFps = activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
if (snappingEnabled) {
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true,
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false,
);
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
}
return finalTime;
},
[
activeProject?.fps,
snappingEnabled,
snapElementEdge,
tracks,
currentTime,
zoomLevel,
],
);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasMediaItem && !hasFiles) return;
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
},
[isDragOver],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasMediaItem) return;
if (track) {
const trackContainer =
(e.currentTarget as HTMLElement).closest(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let overlap = false;
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
const duration =
dragData.type === "text"
? 5
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
const snappedTime = getDropSnappedTime(dropTime, duration);
const endTime = snappedTime + duration;
overlap = track.elements.some((el) => {
const elEnd =
el.startTime + (el.duration - el.trimStart - el.trimEnd);
return snappedTime < elEnd && endTime > el.startTime;
});
}
} catch (f) {}
setWouldOverlap(overlap);
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
}
},
[track, zoomLevel, mediaFiles, getDropSnappedTime],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
}
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
dragCounterRef.current = 0;
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasMediaItem && !hasFiles) return;
const trackContainer =
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top;
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
let dropPos: "above" | "on" | "below" = "on";
if (track) {
if (mouseY < 20) dropPos = "above";
else if (mouseY > 40) dropPos = "below";
}
try {
if (hasMediaItem) {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
let targetTrackId = track?.id;
let targetTrack = track;
if (!track || track.type !== "text" || dropPos !== "on") {
const mainTrack = getMainTrack({ tracks });
let insertIndex = 0;
if (track) {
const currentIdx = tracks.findIndex((t) => t.id === track.id);
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
} else if (mainTrack) {
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
}
targetTrackId = insertTrackAt("text", insertIndex);
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: finalStart,
});
} else {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
let targetTrackId = track?.id;
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = track
? isVideoOrImage
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: isAudio
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: false
: false;
let targetTrack = track;
if (!track || !isCompatible || dropPos !== "on") {
if (isVideoOrImage) {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
targetTrackId = addTrack("media");
} else if (
mainTrack.elements.length === 0 &&
(!track || dropPos === "on")
) {
targetTrackId = mainTrack.id;
} else {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: 0;
if (track) idx = dropPos === "above" ? idx : idx + 1;
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
targetTrackId = insertTrackAt("media", idx);
}
} else if (isAudio) {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: tracks.length;
if (track) idx = dropPos === "above" ? idx : idx + 1;
targetTrackId = insertTrackAt("audio", idx);
}
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = mediaItem.duration || 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: finalStart,
trimStart: 0,
trimEnd: 0,
});
}
} else if (hasFiles) {
if (!activeProject) return;
const processedItems = await processMediaFiles({
files: Array.from(e.dataTransfer.files),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const type: TrackType =
added.type === "audio" ? "audio" : "media";
const tid = insertTrackAt(type, 0);
addElementToTrack(tid, {
type: "media",
mediaId: added.id,
name: added.name,
duration: added.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
}
} catch (err) {
console.error(err);
toast.error("Failed to process drop");
}
},
[
track,
zoomLevel,
activeProject,
tracks,
mediaFiles,
currentTime,
getDropSnappedTime,
addElementToTrack,
insertTrackAt,
addTrack,
addMediaFile,
],
);
return {
isDragOver,
wouldOverlap,
dropPositionIndicator,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
-85
View File
@@ -1,85 +0,0 @@
import { useState, useRef } from "react";
interface UseDragDropOptions {
onDrop?: (files: FileList) => void;
}
// Helper function to check if drag contains files from external sources (not internal app drags)
const containsFiles = (dataTransfer: DataTransfer): boolean => {
// Check if this is an internal app drag (media item)
if (dataTransfer.types.includes("application/x-media-item")) {
return false;
}
// Only show overlay for external file drags
return dataTransfer.types.includes("Files");
};
export function useDragDrop(options: UseDragDropOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Only handle external file drags, not internal app element drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Only handle file drops
if (
options.onDrop &&
e.dataTransfer.files &&
containsFiles(e.dataTransfer)
) {
options.onDrop(e.dataTransfer.files);
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return {
isDragOver,
dragProps,
};
}
-109
View File
@@ -1,109 +0,0 @@
import { useEffect, useCallback } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export const usePlaybackControls = () => {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
selectedElements,
tracks,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const handleSplitSelectedElement = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element to split");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!element) return;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected element");
return;
}
splitSelected(currentTime, trackId, elementId);
}, [selectedElements, tracks, currentTime, splitSelected]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!element) return;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected element");
return;
}
splitAndKeepLeft(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (!element) return;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected element");
return;
}
splitAndKeepRight(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one media element to separate audio");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
if (!track || track.type !== "media") {
toast.error("Select a media element to separate audio");
return;
}
separateAudio(trackId, elementId);
}, [selectedElements, tracks, separateAudio]);
};
@@ -0,0 +1,103 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useProjectStore } from "@/stores/project-store";
export function useProjectInitialization({ projectId }: { projectId: string }) {
const {
activeProject,
loadProject,
createNewProject,
isInvalidProjectId,
markProjectIdAsInvalid,
} = useProjectStore();
const router = useRouter();
const handledProjectIds = useRef<Set<string>>(new Set());
const isInitializingRef = useRef<boolean>(false);
useEffect(() => {
let isCancelled = false;
const initProject = async () => {
if (!projectId) {
return;
}
if (isInitializingRef.current) {
return;
}
if (activeProject?.id === projectId) {
return;
}
if (isInvalidProjectId(projectId)) {
return;
}
if (handledProjectIds.current.has(projectId)) {
return;
}
isInitializingRef.current = true;
handledProjectIds.current.add(projectId);
try {
await loadProject(projectId);
if (isCancelled) {
return;
}
isInitializingRef.current = false;
} catch (error) {
if (isCancelled) {
return;
}
const isProjectNotFound =
error instanceof Error &&
(error.message.includes("not found") ||
error.message.includes("does not exist") ||
error.message.includes("Project not found"));
if (isProjectNotFound) {
markProjectIdAsInvalid(projectId);
try {
const newProjectId = await createNewProject("Untitled Project");
if (isCancelled) {
return;
}
router.replace(`/editor/${newProjectId}`);
} catch (createError) {
console.error("Failed to create new project:", createError);
}
} else {
console.error(
"Project loading failed with recoverable error:",
error,
);
handledProjectIds.current.delete(projectId);
}
isInitializingRef.current = false;
}
};
initProject();
return () => {
isCancelled = true;
isInitializingRef.current = false;
};
}, [
projectId,
loadProject,
createNewProject,
router,
isInvalidProjectId,
markProjectIdAsInvalid,
]);
}
@@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from "react";
export function useHighlightScroll(
export function useRevealItem(
highlightId: string | null,
onClearHighlight: () => void,
highlightDuration = 1000
highlightDuration = 1000,
) {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
@@ -1,141 +0,0 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import type { DragData } from "@/types/timeline";
interface UseTimelineDragDropProps {
addElementToNewTrack: (data: any) => void;
}
export function useTimelineDragDrop({ addElementToNewTrack }: UseTimelineDragDropProps) {
const [isDragOver, setIsDragOver] = useState(false);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const dragCounterRef = useRef(0);
const handleInternalMediaDrop = useCallback(async (dragData: DragData) => {
if (dragData.type === "text") {
addElementToNewTrack(dragData);
} else {
const mediaItem = mediaFiles.find((item: any) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
addElementToNewTrack(mediaItem);
}
}, [mediaFiles, addElementToNewTrack]);
const handleExternalFileDrop = useCallback(async (files: FileList) => {
if (!activeProject) {
toast.error("No active project");
return;
}
try {
const processedItems = await processMediaFiles({
files,
});
for (const processedItem of processedItems) {
await addMediaFile(activeProject.id, processedItem);
const addedItem = mediaFiles.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url,
);
if (addedItem) {
const trackType: "audio" | "media" =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = useTimelineStore
.getState()
.insertTrackAt(trackType, 0);
useTimelineStore.getState().addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
} catch (error) {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
}
}, [activeProject, mediaFiles, addMediaFile, currentTime]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
}, [isDragOver]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
try {
const itemData = e.dataTransfer.getData("application/x-media-item");
if (itemData) {
const dragData: DragData = JSON.parse(itemData);
await handleInternalMediaDrop(dragData);
return;
}
if (e.dataTransfer.files?.length > 0) {
await handleExternalFileDrop(e.dataTransfer.files);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
toast.error("Failed to add item to timeline");
}
}, [handleInternalMediaDrop, handleExternalFileDrop]);
return {
isDragOver,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}