mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
yes
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user