mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
codebase overhaul (#697)
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type {
|
||||
DropTarget,
|
||||
ElementDragState,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
snappingEnabled: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startMouseY: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
currentMouseY: 0,
|
||||
};
|
||||
|
||||
interface PendingDragState {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
}
|
||||
|
||||
function getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect,
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
}: {
|
||||
clientX: number;
|
||||
containerRect: DOMRect;
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
}): number {
|
||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||
return Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
}
|
||||
|
||||
function getClickOffsetTime({
|
||||
clientX,
|
||||
elementRect,
|
||||
zoomLevel,
|
||||
}: {
|
||||
clientX: number;
|
||||
elementRect: DOMRect;
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
function getVerticalDragDirection({
|
||||
startMouseY,
|
||||
currentMouseY,
|
||||
}: {
|
||||
startMouseY: number;
|
||||
currentMouseY: number;
|
||||
}): "up" | "down" | null {
|
||||
if (currentMouseY < startMouseY) return "up";
|
||||
if (currentMouseY > startMouseY) return "down";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId,
|
||||
trackId,
|
||||
tracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
tracks: TimelineTrack[];
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
zoomLevel: number;
|
||||
snappedTime: number;
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): DropTarget | null {
|
||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||
const scrollContainer = tracksScrollRef.current;
|
||||
if (!containerRect || !scrollContainer) return null;
|
||||
|
||||
const sourceTrack = tracks.find(({ id }) => id === trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
({ id }) => id === elementId,
|
||||
);
|
||||
if (!movingElement) return null;
|
||||
|
||||
const elementDuration = movingElement.duration;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
|
||||
const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
|
||||
|
||||
return computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: snappedTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
startTimeOverride: snappedTime,
|
||||
excludeElementId: movingElement.id,
|
||||
verticalDragDirection,
|
||||
});
|
||||
}
|
||||
|
||||
interface StartDragParams
|
||||
extends Omit<
|
||||
ElementDragState,
|
||||
"isDragging" | "currentTime" | "currentMouseY"
|
||||
> {
|
||||
initialCurrentTime: number;
|
||||
initialCurrentMouseY: number;
|
||||
}
|
||||
|
||||
export function useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const { snapElementEdge } = useTimelineSnapping();
|
||||
const {
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
handleElementClick: handleSelectionClick,
|
||||
} = useElementSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<ElementDragState>(initialDragState);
|
||||
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingDragState | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
initialCurrentTime,
|
||||
initialCurrentMouseY,
|
||||
}: StartDragParams) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
currentTime: initialCurrentTime,
|
||||
currentMouseY: initialCurrentMouseY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
setDragDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const getDragSnapResult = useCallback(
|
||||
({
|
||||
frameSnappedTime,
|
||||
movingElement,
|
||||
}: {
|
||||
frameSnappedTime: number;
|
||||
movingElement: TimelineElement | null | undefined;
|
||||
}) => {
|
||||
if (!snappingEnabled || !movingElement) {
|
||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const elementDuration = movingElement.duration;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
|
||||
const startSnap = snapElementEdge({
|
||||
targetTime: frameSnappedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId: movingElement.id,
|
||||
snapToStart: true,
|
||||
});
|
||||
|
||||
const endSnap = snapElementEdge({
|
||||
targetTime: frameSnappedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId: movingElement.id,
|
||||
snapToStart: false,
|
||||
});
|
||||
|
||||
const snapResult =
|
||||
startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
|
||||
if (!snapResult.snapPoint) {
|
||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||
}
|
||||
|
||||
return {
|
||||
snappedTime: snapResult.snappedTime,
|
||||
snapPoint: snapResult.snapPoint,
|
||||
};
|
||||
},
|
||||
[snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
let startedDragThisEvent = false;
|
||||
const timeline = timelineRef.current;
|
||||
const scrollContainer = tracksScrollRef.current;
|
||||
if (!timeline || !scrollContainer) return;
|
||||
lastMouseXRef.current = clientX;
|
||||
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const adjustedTime = Math.max(
|
||||
0,
|
||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||
);
|
||||
const snappedTime = snapTimeToFrame({
|
||||
time: adjustedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
startDrag({
|
||||
...pendingDragRef.current,
|
||||
initialCurrentTime: snappedTime,
|
||||
initialCurrentMouseY: clientY,
|
||||
});
|
||||
startedDragThisEvent = true;
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (startedDragThisEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
selectElement({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
|
||||
|
||||
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
({ id }) => id === dragState.elementId,
|
||||
);
|
||||
const { snappedTime, snapPoint } = getDragSnapResult({
|
||||
frameSnappedTime,
|
||||
movingElement,
|
||||
});
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snappedTime,
|
||||
currentMouseY: clientY,
|
||||
}));
|
||||
onSnapPointChange?.(snapPoint);
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const verticalDragDirection = getVerticalDragDirection({
|
||||
startMouseY: dragState.startMouseY,
|
||||
currentMouseY: clientY,
|
||||
});
|
||||
const dropTarget = getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId: dragState.elementId,
|
||||
trackId: dragState.trackId,
|
||||
tracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection,
|
||||
});
|
||||
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.elementId,
|
||||
dragState.startMouseY,
|
||||
dragState.trackId,
|
||||
zoomLevel,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
editor.project,
|
||||
timelineRef,
|
||||
tracksScrollRef,
|
||||
tracksContainerRef,
|
||||
headerRef,
|
||||
tracks,
|
||||
isPendingDrag,
|
||||
startDrag,
|
||||
getDragSnapResult,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dropTarget = getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId: dragState.elementId,
|
||||
trackId: dragState.trackId,
|
||||
tracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime: dragState.currentTime,
|
||||
verticalDragDirection: getVerticalDragDirection({
|
||||
startMouseY: dragState.startMouseY,
|
||||
currentMouseY: clientY,
|
||||
}),
|
||||
});
|
||||
if (!dropTarget) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
const snappedTime = dragState.currentTime;
|
||||
|
||||
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||
if (!sourceTrack) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = generateUUID();
|
||||
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: newTrackId,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
|
||||
});
|
||||
} else {
|
||||
const targetTrack = tracks[dropTarget.trackIndex];
|
||||
if (targetTrack) {
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: targetTrack.id,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.elementId,
|
||||
dragState.startMouseY,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.timeline,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag, onSnapPointChange]);
|
||||
|
||||
const handleElementMouseDown = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
const isRightClick = event.button === 2;
|
||||
|
||||
// right-click: don't stop propagation so ContextMenu can open
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// left-click: stop propagation for drag operations
|
||||
event.stopPropagation();
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
|
||||
// multi-select: toggle selection
|
||||
if (isMultiSelect) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: true,
|
||||
});
|
||||
}
|
||||
|
||||
// start drag
|
||||
const clickOffsetTime = getClickOffsetTime({
|
||||
clientX: event.clientX,
|
||||
elementRect: event.currentTarget.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
});
|
||||
pendingDragRef.current = {
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
startMouseX: event.clientX,
|
||||
startMouseY: event.clientY,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[zoomLevel, isElementSelected, handleSelectionClick],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
// was it a drag or a click?
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// modifier keys already handled in mousedown
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
// single click: select if not selected
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
selectElement({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
},
|
||||
[isElementSelected, selectElement],
|
||||
);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
dragDropTarget,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
type SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
initialStartTime: number;
|
||||
initialDuration: number;
|
||||
}
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
onResizeStateChange,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
|
||||
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
|
||||
const [currentDuration, setCurrentDuration] = useState(element.duration);
|
||||
const currentTrimStartRef = useRef(element.trimStart);
|
||||
const currentTrimEndRef = useRef(element.trimEnd);
|
||||
const currentStartTimeRef = useRef(element.startTime);
|
||||
const currentDurationRef = useRef(element.duration);
|
||||
|
||||
const handleResizeStart = ({
|
||||
e,
|
||||
elementId,
|
||||
side,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
initialStartTime: element.startTime,
|
||||
initialDuration: element.duration,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(element.trimStart);
|
||||
setCurrentTrimEnd(element.trimEnd);
|
||||
setCurrentStartTime(element.startTime);
|
||||
setCurrentDuration(element.duration);
|
||||
currentTrimStartRef.current = element.trimStart;
|
||||
currentTrimEndRef.current = element.trimEnd;
|
||||
currentStartTimeRef.current = element.startTime;
|
||||
currentDurationRef.current = element.duration;
|
||||
onResizeStateChange?.({ isResizing: true });
|
||||
};
|
||||
|
||||
const canExtendElementDuration = useCallback(() => {
|
||||
if (element.type === "text" || element.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [element.type]);
|
||||
|
||||
const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime = deltaX / (50 * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
|
||||
const projectFps = activeProject.settings.fps;
|
||||
const minDurationSeconds = 1 / projectFps;
|
||||
const canSnap = snappingEnabled;
|
||||
if (canSnap) {
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId: element.id,
|
||||
});
|
||||
if (resizing.side === "left") {
|
||||
const targetStartTime = resizing.initialStartTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetStartTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
|
||||
}
|
||||
} else {
|
||||
const baseEndTime =
|
||||
resizing.initialStartTime + resizing.initialDuration;
|
||||
const targetEndTime = baseEndTime + deltaTime;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: targetEndTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
resizeSnapPoint = snapResult.snapPoint;
|
||||
if (snapResult.snapPoint) {
|
||||
deltaTime = snapResult.snappedTime - baseEndTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSnapPointChange?.(resizeSnapPoint);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const maxAllowed =
|
||||
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = newTrimStart;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else {
|
||||
const trimDelta = 0 - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const sourceDuration =
|
||||
resizing.initialTrimStart +
|
||||
resizing.initialDuration +
|
||||
resizing.initialTrimEnd;
|
||||
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: baseDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
} else {
|
||||
const extensionToLimit = resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionToLimit,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
}
|
||||
} else {
|
||||
const maxTrimEnd =
|
||||
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
|
||||
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration - trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimEndRef.current = finalTrimEnd;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
}, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const finalTrimStart = currentTrimStartRef.current;
|
||||
const finalTrimEnd = currentTrimEndRef.current;
|
||||
const finalStartTime = currentStartTimeRef.current;
|
||||
const finalDuration = currentDurationRef.current;
|
||||
const trimStartChanged = finalTrimStart !== resizing.initialTrimStart;
|
||||
const trimEndChanged = finalTrimEnd !== resizing.initialTrimEnd;
|
||||
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
|
||||
const durationChanged = finalDuration !== resizing.initialDuration;
|
||||
|
||||
if (trimStartChanged || trimEndChanged) {
|
||||
editor.timeline.updateElementTrim({
|
||||
elementId: element.id,
|
||||
trimStart: finalTrimStart,
|
||||
trimEnd: finalTrimEnd,
|
||||
});
|
||||
}
|
||||
|
||||
if (startTimeChanged) {
|
||||
editor.timeline.updateElementStartTime({
|
||||
elements: [{ trackId: track.id, elementId: element.id }],
|
||||
startTime: finalStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (durationChanged) {
|
||||
editor.timeline.updateElementDuration({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
duration: finalDuration,
|
||||
});
|
||||
}
|
||||
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
}, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = ({ clientX }: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing, handleResizeEnd, updateTrimFromMouseMove]);
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
currentTrimStart,
|
||||
currentTrimEnd,
|
||||
currentStartTime,
|
||||
currentDuration,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
type ElementRef = { trackId: string; elementId: string };
|
||||
|
||||
export function useElementSelection() {
|
||||
const editor = useEditor();
|
||||
const selectedElements = useSyncExternalStore(
|
||||
(listener) => editor.selection.subscribe(listener),
|
||||
() => editor.selection.getSelectedElements(),
|
||||
);
|
||||
|
||||
const isElementSelected = useCallback(
|
||||
({ trackId, elementId }: ElementRef) =>
|
||||
selectedElements.some(
|
||||
(element) =>
|
||||
element.trackId === trackId && element.elementId === elementId,
|
||||
),
|
||||
[selectedElements],
|
||||
);
|
||||
|
||||
const selectElement = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addElementToSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
const alreadySelected = selectedElements.some(
|
||||
(element) =>
|
||||
element.trackId === trackId && element.elementId === elementId,
|
||||
);
|
||||
if (alreadySelected) return;
|
||||
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [...selectedElements, { trackId, elementId }],
|
||||
});
|
||||
},
|
||||
[selectedElements, editor],
|
||||
);
|
||||
|
||||
const removeElementFromSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: selectedElements.filter(
|
||||
(element) =>
|
||||
!(element.trackId === trackId && element.elementId === elementId),
|
||||
),
|
||||
});
|
||||
},
|
||||
[selectedElements, editor],
|
||||
);
|
||||
|
||||
const toggleElementSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
const alreadySelected = selectedElements.some(
|
||||
(element) =>
|
||||
element.trackId === trackId && element.elementId === elementId,
|
||||
);
|
||||
|
||||
if (alreadySelected) {
|
||||
removeElementFromSelection({ trackId, elementId });
|
||||
} else {
|
||||
addElementToSelection({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[selectedElements, addElementToSelection, removeElementFromSelection],
|
||||
);
|
||||
|
||||
const clearElementSelection = useCallback(() => {
|
||||
editor.selection.clearSelection();
|
||||
}, [editor]);
|
||||
|
||||
const setElementSelection = useCallback(
|
||||
({ elements }: { elements: ElementRef[] }) => {
|
||||
editor.selection.setSelectedElements({ elements });
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles click interaction on an element.
|
||||
* - Regular click: select only this element
|
||||
* - Multi-key click (Ctrl/Cmd): toggle this element in selection
|
||||
*/
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
trackId,
|
||||
elementId,
|
||||
isMultiKey,
|
||||
}: ElementRef & { isMultiKey: boolean }) => {
|
||||
if (isMultiKey) {
|
||||
toggleElementSelection({ trackId, elementId });
|
||||
} else {
|
||||
selectElement({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[toggleElementSelection, selectElement],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedElements,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
setElementSelection,
|
||||
addElementToSelection,
|
||||
removeElementFromSelection,
|
||||
toggleElementSelection,
|
||||
clearElementSelection,
|
||||
handleElementClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseEdgeAutoScrollParams {
|
||||
isActive: boolean;
|
||||
getMouseClientX: () => number;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
contentWidth: number;
|
||||
edgeThreshold?: number;
|
||||
maxScrollSpeed?: number;
|
||||
}
|
||||
|
||||
export function useEdgeAutoScroll({
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold = 100,
|
||||
maxScrollSpeed = 15,
|
||||
}: UseEdgeAutoScrollParams): void {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) {
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportRect = rulerViewport.getBoundingClientRect();
|
||||
const mouseX = getMouseClientX();
|
||||
const mouseXRelative = mouseX - viewportRect.left;
|
||||
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const intrinsicContentWidth = rulerViewport.scrollWidth;
|
||||
const effectiveContentWidth = Math.max(
|
||||
contentWidth,
|
||||
intrinsicContentWidth,
|
||||
);
|
||||
const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
|
||||
|
||||
let scrollSpeed = 0;
|
||||
|
||||
if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
|
||||
const edgeDistance = Math.max(0, mouseXRelative);
|
||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||
scrollSpeed = -maxScrollSpeed * intensity;
|
||||
} else if (
|
||||
mouseXRelative > viewportWidth - edgeThreshold &&
|
||||
rulerViewport.scrollLeft < scrollMax
|
||||
) {
|
||||
const edgeDistance = Math.max(
|
||||
0,
|
||||
viewportWidth - edgeThreshold - mouseXRelative,
|
||||
);
|
||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||
scrollSpeed = maxScrollSpeed * intensity;
|
||||
}
|
||||
|
||||
if (scrollSpeed !== 0) {
|
||||
const newScrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
|
||||
);
|
||||
rulerViewport.scrollLeft = newScrollLeft;
|
||||
tracksViewport.scrollLeft = newScrollLeft;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
isActive,
|
||||
getMouseClientX,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth,
|
||||
edgeThreshold,
|
||||
maxScrollSpeed,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseScrollSyncProps {
|
||||
rulerScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
bookmarksScrollRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useScrollSync({
|
||||
tracksScrollRef,
|
||||
rulerScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
}: UseScrollSyncProps) {
|
||||
const isUpdatingRef = useRef(false);
|
||||
const lastRulerSync = useRef(0);
|
||||
const lastTracksSync = useRef(0);
|
||||
const lastVerticalSync = useRef(0);
|
||||
const lastBookmarksSync = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef?.current ?? null;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const trackLabelsViewport = trackLabelsScrollRef?.current;
|
||||
const bookmarksViewport = bookmarksScrollRef?.current;
|
||||
let handleBookmarksScroll: (() => void) | null = null;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const syncScrollLeft = ({
|
||||
target,
|
||||
scrollLeft,
|
||||
}: {
|
||||
target: HTMLDivElement | null | undefined;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
if (target) {
|
||||
target.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRulerScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||
lastRulerSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
|
||||
syncScrollLeft({
|
||||
target: bookmarksViewport,
|
||||
scrollLeft: rulerViewport?.scrollLeft ?? 0,
|
||||
});
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTracksScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
|
||||
lastTracksSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
syncScrollLeft({
|
||||
target: rulerViewport,
|
||||
scrollLeft: tracksViewport.scrollLeft,
|
||||
});
|
||||
syncScrollLeft({
|
||||
target: bookmarksViewport,
|
||||
scrollLeft: tracksViewport.scrollLeft,
|
||||
});
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||
}
|
||||
if (tracksViewport !== rulerViewport) {
|
||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||
}
|
||||
|
||||
if (bookmarksViewport) {
|
||||
handleBookmarksScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
|
||||
return;
|
||||
lastBookmarksSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
const nextScrollLeft = bookmarksViewport.scrollLeft;
|
||||
tracksViewport.scrollLeft = nextScrollLeft;
|
||||
syncScrollLeft({
|
||||
target: rulerViewport,
|
||||
scrollLeft: nextScrollLeft,
|
||||
});
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
if (bookmarksViewport !== tracksViewport) {
|
||||
bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
|
||||
}
|
||||
}
|
||||
|
||||
if (trackLabelsViewport) {
|
||||
const handleTrackLabelsScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTracksVerticalScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
||||
|
||||
return () => {
|
||||
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
}
|
||||
if (tracksViewport !== rulerViewport) {
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
}
|
||||
if (
|
||||
bookmarksViewport &&
|
||||
bookmarksViewport !== tracksViewport &&
|
||||
handleBookmarksScroll
|
||||
) {
|
||||
bookmarksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleBookmarksScroll,
|
||||
);
|
||||
}
|
||||
trackLabelsViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTrackLabelsScroll,
|
||||
);
|
||||
tracksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTracksVerticalScroll,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rulerViewport && rulerViewport !== tracksViewport) {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
}
|
||||
if (tracksViewport !== rulerViewport) {
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
}
|
||||
if (
|
||||
bookmarksViewport &&
|
||||
bookmarksViewport !== tracksViewport &&
|
||||
handleBookmarksScroll
|
||||
) {
|
||||
bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[],
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - containerRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - containerRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseSelectionBoxProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null,
|
||||
);
|
||||
const justFinishedSelectingRef = useRef(false);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: clientX, y: clientY },
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: false,
|
||||
});
|
||||
},
|
||||
[isEnabled],
|
||||
);
|
||||
|
||||
const selectElementsInBox = useCallback(
|
||||
({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer: tracksScrollRef.current,
|
||||
startPos,
|
||||
endPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = trackTop;
|
||||
const elementBottom = trackTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
const intersects = isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
});
|
||||
|
||||
if (intersects) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox({
|
||||
startPos: newSelectionBox.startPos,
|
||||
endPos: newSelectionBox.currentPos,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (selectionBox?.isActive) {
|
||||
justFinishedSelectingRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
justFinishedSelectingRef.current = false;
|
||||
});
|
||||
}
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const previousBodyUserSelect = document.body.style.userSelect;
|
||||
const container = containerRef.current;
|
||||
const previousContainerUserSelect = container?.style.userSelect ?? "";
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
if (container) container.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (container) container.style.userSelect = previousContainerUserSelect;
|
||||
};
|
||||
}, [selectionBox, containerRef]);
|
||||
|
||||
const shouldIgnoreClick = useCallback(() => {
|
||||
return justFinishedSelectingRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
shouldIgnoreClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
snapPoint: { time: number } | null;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface SnapIndicatorPosition {
|
||||
leftPosition: number;
|
||||
topPosition: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
|
||||
const timelinePosition =
|
||||
(snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
leftPosition,
|
||||
topPosition: 0,
|
||||
height: totalHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useState, useCallback, type RefObject } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import { toast } from "sonner";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildStickerElement,
|
||||
buildUploadAudioElement,
|
||||
buildVideoElement,
|
||||
buildImageElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
|
||||
import type { MediaDragData, StickerDragData } from "@/types/drag";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
containerRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: number }) => {
|
||||
const projectFps = activeProject.settings.fps;
|
||||
return snapTimeToFrame({ time, fps: projectFps });
|
||||
},
|
||||
[activeProject.settings.fps],
|
||||
);
|
||||
|
||||
const getElementType = useCallback(
|
||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
||||
const dragData = getDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text" || elementType === "sticker") {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
},
|
||||
[mediaAssets],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
|
||||
const elementType = getElementType({ dataTransfer: e.dataTransfer });
|
||||
|
||||
if (!elementType && hasFiles && isExternal) {
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elementType) return;
|
||||
|
||||
setElementType(elementType);
|
||||
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
|
||||
setDropTarget(target);
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
getElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientX, clientY } = e;
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const executeTextDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeStickerDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "sticker",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
iconName: dragData.iconName,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildUploadAudioElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
} else if (dragData.mediaType === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildVideoElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildImageElement({
|
||||
mediaId: mediaAsset.id,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
mouseX,
|
||||
mouseY,
|
||||
}: {
|
||||
files: File[];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}) => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
|
||||
const added = editor.media
|
||||
.getAssets()
|
||||
.find((m) => m.name === asset.name && m.url === asset.url);
|
||||
|
||||
if (added) {
|
||||
const duration =
|
||||
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: added.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: currentTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const trackType: TrackType =
|
||||
added.type === "audio" ? "audio" : "video";
|
||||
const trackId = dropTarget.isNewTrack
|
||||
? editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: dropTarget.trackIndex,
|
||||
})
|
||||
: currentTracks[dropTarget.trackIndex]?.id;
|
||||
|
||||
if (!trackId) return;
|
||||
|
||||
if (added.type === "audio") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildUploadAudioElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
}),
|
||||
});
|
||||
} else if (added.type === "video") {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildVideoElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element: buildImageElement({
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
|
||||
try {
|
||||
if (hasAsset) {
|
||||
if (!currentTarget) return;
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
if (!dragData) return;
|
||||
|
||||
if (dragData.type === "text") {
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
mouseY,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process drop:", err);
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[
|
||||
dropTarget,
|
||||
executeTextDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
||||
[editor.playback],
|
||||
);
|
||||
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
const handleScrub = useCallback(
|
||||
({ event }: { event: MouseEvent | React.MouseEvent }) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rulerRect = ruler.getBoundingClientRect();
|
||||
const relativeMouseX = event.clientX - rulerRect.left;
|
||||
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const clampedMouseX = Math.max(
|
||||
0,
|
||||
Math.min(timelineContentWidth, relativeMouseX),
|
||||
);
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
const framesPerSecond = activeProject.settings.fps;
|
||||
const time = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
|
||||
setScrubTime(time);
|
||||
seek({ time });
|
||||
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsScrubbing(true);
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub],
|
||||
);
|
||||
|
||||
const handleRulerMouseDown = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
setIsScrubbing(true);
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub, playheadRef],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDownEvent = useCallback(
|
||||
(event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
|
||||
[handlePlayheadMouseDown],
|
||||
);
|
||||
|
||||
const handleRulerMouseDownEvent = useCallback(
|
||||
(event: React.MouseEvent) => handleRulerMouseDown({ event }),
|
||||
[handleRulerMouseDown],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
|
||||
handleScrub({ event });
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) {
|
||||
seek({ time: scrubTime });
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
|
||||
playheadTime: scrubTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
setScrubTime(null);
|
||||
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub({ event });
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
|
||||
const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
editor,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || isScrubbing) return;
|
||||
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMinimum = 0;
|
||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
const needsScroll =
|
||||
playheadPixels < rulerViewport.scrollLeft ||
|
||||
playheadPixels > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
const desiredScroll = Math.max(
|
||||
scrollMinimum,
|
||||
Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [
|
||||
playheadPosition,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
isScrubbing,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
|
||||
handleRulerMouseDown: handleRulerMouseDownEvent,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
function resetMouseTracking({
|
||||
mouseTrackingRef,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setMouseTracking({
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
event: React.MouseEvent;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.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?.settings.fps || 30;
|
||||
const time = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: projectFps,
|
||||
});
|
||||
seek(time);
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
editor,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback } from "react";
|
||||
import type { 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;
|
||||
enableElementSnapping?: boolean;
|
||||
enablePlayheadSnapping?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineSnapping({
|
||||
snapThreshold = 10,
|
||||
enableElementSnapping = true,
|
||||
enablePlayheadSnapping = true,
|
||||
}: UseTimelineSnappingOptions = {}) {
|
||||
const findSnapPoints = useCallback(
|
||||
({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
excludeElementId?: string;
|
||||
}): SnapPoint[] => {
|
||||
const snapPoints: SnapPoint[] = [];
|
||||
|
||||
if (enableElementSnapping) {
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.id === excludeElementId) continue;
|
||||
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
|
||||
snapPoints.push(
|
||||
{
|
||||
time: elementStart,
|
||||
type: "element-start",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: elementEnd,
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePlayheadSnapping) {
|
||||
snapPoints.push({
|
||||
time: playheadTime,
|
||||
type: "playhead",
|
||||
});
|
||||
}
|
||||
|
||||
return snapPoints;
|
||||
},
|
||||
[enableElementSnapping, enablePlayheadSnapping],
|
||||
);
|
||||
|
||||
const snapToNearestPoint = useCallback(
|
||||
({
|
||||
targetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
}: {
|
||||
targetTime: number;
|
||||
snapPoints: Array<SnapPoint>;
|
||||
zoomLevel: number;
|
||||
}): SnapResult => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const snapPoint of snapPoints) {
|
||||
const distance = Math.abs(targetTime - snapPoint.time);
|
||||
if (distance < thresholdInSeconds && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestSnapPoint = snapPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
|
||||
snapPoint: closestSnapPoint,
|
||||
snapDistance: closestDistance,
|
||||
};
|
||||
},
|
||||
[snapThreshold],
|
||||
);
|
||||
|
||||
const snapElementEdge = useCallback(
|
||||
({
|
||||
targetTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
playheadTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
snapToStart = true,
|
||||
}: {
|
||||
targetTime: number;
|
||||
elementDuration: number;
|
||||
tracks: Array<TimelineTrack>;
|
||||
playheadTime: number;
|
||||
zoomLevel: number;
|
||||
excludeElementId?: string;
|
||||
snapToStart?: boolean;
|
||||
}): SnapResult => {
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
const effectiveTargetTime = snapToStart
|
||||
? targetTime
|
||||
: targetTime + elementDuration;
|
||||
const snapResult = snapToNearestPoint({
|
||||
targetTime: effectiveTargetTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
if (!snapToStart && snapResult.snapPoint) {
|
||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||
}
|
||||
|
||||
return snapResult;
|
||||
},
|
||||
[findSnapPoints, snapToNearestPoint],
|
||||
);
|
||||
|
||||
return {
|
||||
snapElementEdge,
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
minZoom?: number;
|
||||
initialZoom?: number;
|
||||
initialScrollLeft?: number;
|
||||
initialPlayheadTime?: number;
|
||||
tracksScrollRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (event: ReactWheelEvent) => void;
|
||||
saveScrollPosition: () => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
initialZoom,
|
||||
initialScrollLeft,
|
||||
initialPlayheadTime,
|
||||
tracksScrollRef,
|
||||
rulerScrollRef,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const editor = useEditor();
|
||||
const hasInitializedRef = useRef(false);
|
||||
const hasRestoredPlayheadRef = useRef(false);
|
||||
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [zoomLevel, setZoomLevel] = useState(() => {
|
||||
if (initialZoom !== undefined) {
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
|
||||
);
|
||||
}
|
||||
return minZoom;
|
||||
});
|
||||
const previousZoomRef = useRef(zoomLevel);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
// for horizontal scrolling (when shift is held or horizontal wheel movement),
|
||||
// let the event bubble up to allow ScrollArea to handle it
|
||||
return;
|
||||
}
|
||||
},
|
||||
[minZoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setZoomLevel(
|
||||
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setZoomLevel((prev) => {
|
||||
if (prev < minZoom) {
|
||||
return minZoom;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [minZoom, initialZoom]);
|
||||
|
||||
const wrappedSetZoomLevel = useCallback(
|
||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom =
|
||||
typeof zoomLevelOrUpdater === "function"
|
||||
? zoomLevelOrUpdater(prev)
|
||||
: zoomLevelOrUpdater;
|
||||
const clampedZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
|
||||
);
|
||||
return clampedZoom;
|
||||
});
|
||||
},
|
||||
[minZoom],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousZoom = previousZoomRef.current;
|
||||
if (previousZoom === zoomLevel) return;
|
||||
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime: editor.playback.getCurrentTime(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
previousZoomRef.current = zoomLevel;
|
||||
}, [zoomLevel, editor, tracksScrollRef]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
||||
const saveScrollPosition = useCallback(() => {
|
||||
if (scrollSaveTimeoutRef.current) {
|
||||
clearTimeout(scrollSaveTimeoutRef.current);
|
||||
}
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime: editor.playback.getCurrentTime(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}, [zoomLevel, editor]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
|
||||
useEffect(() => {
|
||||
if (initialScrollLeft === undefined) return;
|
||||
if (hasRestoredScrollRef.current) return;
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const restoreScroll = () => {
|
||||
scrollElement.scrollLeft = initialScrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = initialScrollLeft;
|
||||
}
|
||||
hasRestoredScrollRef.current = true;
|
||||
};
|
||||
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
} else {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
hasRestoredScrollRef.current = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(scrollElement);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [initialScrollLeft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
|
||||
hasRestoredPlayheadRef.current = true;
|
||||
editor.playback.seek({ time: initialPlayheadTime });
|
||||
}
|
||||
}, [initialPlayheadTime, editor]);
|
||||
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
// only check isInContainer, not isInTimeline state - the state check
|
||||
// causes race conditions where the closure captures stale state
|
||||
if (isZoomKeyPressed && isInContainer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel: wrappedSetZoomLevel,
|
||||
handleWheel,
|
||||
saveScrollPosition,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user