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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
updateElementStartTime,
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
pushHistory,
|
||||
} = useTimelineStore();
|
||||
|
||||
// Set up document-level mouse listeners during resize (like proper drag behavior)
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
// Add document-level listeners for proper drag behavior
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]); // Re-run when resizing state changes
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
side: "left" | "right",
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Push history once at the start of the resize operation
|
||||
pushHistory();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
// Text elements can always be extended
|
||||
if (element.type === "text") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media elements - check the media type
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
// Images can be extended (static content)
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Videos and audio cannot be extended beyond their natural duration
|
||||
// (no additional content exists)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
// Reasonable sensitivity for resize operations - similar to timeline scale
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
// Get project FPS for frame snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - different behavior for media vs text/image elements
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
// Normal trimming within available content
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Trying to extend beyond trimStart = 0
|
||||
if (canExtendElementDuration()) {
|
||||
// Text/Image: extend element to the left by moving startTime and increasing duration
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = element.startTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Keep trimStart at 0 and extend the element
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
0,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Right resize - can extend duration for supported element types
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
// We're trying to extend beyond original duration
|
||||
if (canExtendElementDuration()) {
|
||||
// Extend the duration instead of reducing trimEnd further
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Can't extend - just set trimEnd to 0 (maximum possible extension)
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
// Calculate the desired end time based on mouse movement
|
||||
const currentEndTime =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
// Snap the desired end time to frame
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Calculate what trimEnd should be to achieve this snapped end time
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
(snappedEndTime - element.startTime),
|
||||
);
|
||||
|
||||
// Ensure we don't trim more than available content (leave at least 0.1s visible)
|
||||
const maxTrimEnd = element.duration - element.trimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
element.trimStart,
|
||||
finalTrimEnd,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
// Return empty handlers since we use document listeners now
|
||||
handleResizeMove: () => {}, // Not used anymore
|
||||
handleResizeEnd: () => {}, // Not used anymore
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import type { RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
|
||||
interface UseTimelineInteractionsProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
justFinishedSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
export function useTimelineInteractions({
|
||||
playheadRef,
|
||||
tracksContainerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const isTimelineBackground =
|
||||
!target.closest(".timeline-element") &&
|
||||
!playheadRef.current?.contains(target) &&
|
||||
!target.closest("[data-track-labels]");
|
||||
|
||||
if (isTimelineBackground) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: e.clientX,
|
||||
downY: e.clientY,
|
||||
downTime: e.timeStamp,
|
||||
};
|
||||
}
|
||||
},
|
||||
[playheadRef],
|
||||
);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
|
||||
const deltaX = Math.abs(e.clientX - downX);
|
||||
const deltaY = Math.abs(e.clientY - downY);
|
||||
const deltaTime = e.timeStamp - downTime;
|
||||
|
||||
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
|
||||
|
||||
if (isSelecting || justFinishedSelecting) return false;
|
||||
|
||||
if (target.closest(".timeline-element")) return false;
|
||||
|
||||
if (playheadRef.current?.contains(target)) return false;
|
||||
|
||||
if (target.closest("[data-track-labels]")) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, justFinishedSelecting, clearSelectedElements, playheadRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const isRulerClick = (e.target as HTMLElement).closest(
|
||||
"[data-ruler-area]",
|
||||
);
|
||||
const scrollContainer = isRulerClick
|
||||
? rulerScrollRef.current
|
||||
: tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
activeProject?.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
|
||||
if (shouldProcessTimelineClick(e)) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek(e);
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTimelineMouseDown,
|
||||
handleTimelineContentClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(e.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel],
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const rawX = e.clientX - rect.left;
|
||||
|
||||
// Get the timeline content width based on duration and zoom
|
||||
const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
|
||||
// Constrain x to be within the timeline content bounds
|
||||
const x = Math.max(0, Math.min(timelineContentWidth, rawX));
|
||||
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const time = snapTimeToFrame({ time: rawTime, fps: projectFps });
|
||||
|
||||
// Debug logging
|
||||
if (rawX < 0 || x !== rawX) {
|
||||
console.log(
|
||||
"PLAYHEAD DEBUG:",
|
||||
JSON.stringify({
|
||||
mouseX: e.clientX,
|
||||
rulerLeft: rect.left,
|
||||
rawX,
|
||||
constrainedX: x,
|
||||
timelineContentWidth,
|
||||
rawTime,
|
||||
finalTime: time,
|
||||
duration,
|
||||
zoomLevel,
|
||||
playheadPx: time * 50 * zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
|
||||
// Store mouse position for auto-scrolling
|
||||
lastMouseXRef.current = e.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * 50 * zoomLevel,
|
||||
});
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
// Edge auto-scroll is handled by useEdgeAutoScroll
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
// nothing to cleanup for edge auto scroll
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
// edge auto scroll hook is independent
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect (only during playback) ---
|
||||
useEffect(() => {
|
||||
const { isPlaying } = usePlaybackStore.getState();
|
||||
|
||||
// Only auto-scroll during playback, not during manual interactions
|
||||
if (!isPlaying || isScrubbing) return;
|
||||
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
// Only auto-scroll if playhead is completely out of view (no buffer)
|
||||
const needsScroll =
|
||||
playheadPx < rulerViewport.scrollLeft ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
// Center the playhead in the viewport
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [
|
||||
playheadPosition,
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
isScrubbing,
|
||||
]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
type: "element-start" | "element-end" | "playhead";
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
}
|
||||
|
||||
export interface UseTimelineSnappingOptions {
|
||||
snapThreshold?: number; // Distance in pixels to trigger snapping
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
(
|
||||
tracks: TimelineTrack[],
|
||||
currentTime: number,
|
||||
playheadTime: number,
|
||||
zoomLevel: number,
|
||||
excludeElementId?: string,
|
||||
): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
// Add element snap points
|
||||
if (enableElementSnapping) {
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
// Skip the element being dragged
|
||||
if (element.id === excludeElementId) return;
|
||||
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
snapPoints.push(
|
||||
{
|
||||
time: elementStart,
|
||||
type: "element-start",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: elementEnd,
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add playhead snap point
|
||||
if (enablePlayheadSnapping) {
|
||||
snapPoints.push({
|
||||
time: playheadTime,
|
||||
type: "playhead",
|
||||
});
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
);
|
||||
|
||||
const snapToNearestPoint = useCallback(
|
||||
(
|
||||
targetTime: number,
|
||||
snapPoints: SnapPoint[],
|
||||
zoomLevel: number,
|
||||
): SnapResult => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
snapPoints.forEach((snapPoint) => {
|
||||
const distance = Math.abs(targetTime - snapPoint.time);
|
||||
if (distance < thresholdInSeconds && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestSnapPoint = snapPoint;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
snappedTime: closestSnapPoint
|
||||
? (closestSnapPoint as SnapPoint).time
|
||||
: targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
};
|
||||
},
|
||||
[snapThreshold],
|
||||
);
|
||||
|
||||
const snapElementEdge = useCallback(
|
||||
(
|
||||
targetTime: number,
|
||||
elementDuration: number,
|
||||
tracks: TimelineTrack[],
|
||||
playheadTime: number,
|
||||
zoomLevel: number,
|
||||
excludeElementId?: string,
|
||||
snapToStart = true, // true for start edge, false for end edge
|
||||
): SnapResult => {
|
||||
const snapPoints = findSnapPoints(
|
||||
tracks,
|
||||
targetTime,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
);
|
||||
|
||||
// For end edge snapping, we need to account for element duration
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint(
|
||||
effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
);
|
||||
|
||||
// Adjust the snapped time back for end edge
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// For horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
// Don't prevent default - let ScrollArea handle horizontal scrolling
|
||||
return;
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user