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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user