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