2026-01-31 00:20:04 +01:00
|
|
|
import {
|
|
|
|
|
useState,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
type MouseEvent as ReactMouseEvent,
|
|
|
|
|
type RefObject,
|
|
|
|
|
} from "react";
|
2026-04-20 11:31:17 +02:00
|
|
|
import { useEditor } from "@/editor/use-editor";
|
2026-02-23 03:24:02 +01:00
|
|
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
2026-04-20 11:31:17 +02:00
|
|
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
2026-04-15 05:43:29 +02:00
|
|
|
import {
|
|
|
|
|
buildMoveGroup,
|
|
|
|
|
resolveGroupMove,
|
|
|
|
|
snapGroupEdges,
|
|
|
|
|
type GroupMoveResult,
|
|
|
|
|
type MoveGroup,
|
2026-04-20 11:31:17 +02:00
|
|
|
} from "@/timeline/group-move";
|
|
|
|
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
2026-04-26 02:50:04 +02:00
|
|
|
import {
|
|
|
|
|
addMediaTime,
|
|
|
|
|
type MediaTime,
|
|
|
|
|
mediaTime,
|
|
|
|
|
subMediaTime,
|
|
|
|
|
TICKS_PER_SECOND,
|
|
|
|
|
ZERO_MEDIA_TIME,
|
|
|
|
|
} from "@/wasm";
|
2026-04-20 11:31:17 +02:00
|
|
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
2026-04-07 01:09:13 +02:00
|
|
|
import { roundToFrame } from "opencut-wasm";
|
2026-04-20 11:31:17 +02:00
|
|
|
import { computeDropTarget } from "@/timeline/components/drop-target";
|
|
|
|
|
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
2026-01-31 00:20:04 +01:00
|
|
|
import { generateUUID } from "@/utils/id";
|
2026-04-20 11:31:17 +02:00
|
|
|
import type { SnapPoint } from "@/timeline/snapping";
|
|
|
|
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
2026-01-31 00:20:04 +01:00
|
|
|
import type {
|
|
|
|
|
DropTarget,
|
2026-04-15 05:43:29 +02:00
|
|
|
ElementRef,
|
2026-01-31 00:20:04 +01:00
|
|
|
ElementDragState,
|
2026-04-07 04:41:27 +02:00
|
|
|
SceneTracks,
|
2026-01-31 00:20:04 +01:00
|
|
|
TimelineElement,
|
|
|
|
|
TimelineTrack,
|
2026-04-20 11:31:17 +02:00
|
|
|
} from "@/timeline";
|
2026-01-31 00:20:04 +01:00
|
|
|
|
|
|
|
|
interface UseElementInteractionProps {
|
|
|
|
|
zoomLevel: number;
|
2026-02-01 11:06:20 +01:00
|
|
|
timelineRef: RefObject<HTMLDivElement | null>;
|
|
|
|
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
|
|
|
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
|
|
|
|
headerRef?: RefObject<HTMLElement | null>;
|
2026-01-31 00:20:04 +01:00
|
|
|
snappingEnabled: boolean;
|
|
|
|
|
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 13:13:07 +01:00
|
|
|
const MOUSE_BUTTON_RIGHT = 2;
|
|
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const initialDragState: ElementDragState = {
|
|
|
|
|
isDragging: false,
|
|
|
|
|
elementId: null,
|
2026-04-13 04:41:10 +02:00
|
|
|
dragElementIds: [],
|
|
|
|
|
dragTimeOffsets: {},
|
2026-01-31 00:20:04 +01:00
|
|
|
trackId: null,
|
|
|
|
|
startMouseX: 0,
|
|
|
|
|
startMouseY: 0,
|
2026-04-26 02:50:04 +02:00
|
|
|
startElementTime: ZERO_MEDIA_TIME,
|
|
|
|
|
clickOffsetTime: ZERO_MEDIA_TIME,
|
|
|
|
|
currentTime: ZERO_MEDIA_TIME,
|
2026-01-31 00:20:04 +01:00
|
|
|
currentMouseY: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface PendingDragState {
|
|
|
|
|
elementId: string;
|
|
|
|
|
trackId: string;
|
2026-04-15 05:43:29 +02:00
|
|
|
selectedElements: ElementRef[];
|
2026-01-31 00:20:04 +01:00
|
|
|
startMouseX: number;
|
|
|
|
|
startMouseY: number;
|
2026-04-26 02:50:04 +02:00
|
|
|
startElementTime: MediaTime;
|
|
|
|
|
clickOffsetTime: MediaTime;
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getClickOffsetTime({
|
|
|
|
|
clientX,
|
|
|
|
|
elementRect,
|
|
|
|
|
zoomLevel,
|
|
|
|
|
}: {
|
|
|
|
|
clientX: number;
|
|
|
|
|
elementRect: DOMRect;
|
|
|
|
|
zoomLevel: number;
|
2026-04-26 02:50:04 +02:00
|
|
|
}): MediaTime {
|
2026-01-31 00:20:04 +01:00
|
|
|
const clickOffsetX = clientX - elementRect.left;
|
2026-04-07 01:09:13 +02:00
|
|
|
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
2026-04-26 02:50:04 +02:00
|
|
|
return mediaTime({
|
|
|
|
|
ticks: Math.round(seconds * TICKS_PER_SECOND),
|
|
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-07 04:41:27 +02:00
|
|
|
tracks: SceneTracks;
|
2026-02-01 11:06:20 +01:00
|
|
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
|
|
|
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
|
|
|
|
headerRef?: RefObject<HTMLElement | null>;
|
2026-01-31 00:20:04 +01:00
|
|
|
zoomLevel: number;
|
2026-04-26 02:50:04 +02:00
|
|
|
snappedTime: MediaTime;
|
2026-01-31 00:20:04 +01:00
|
|
|
verticalDragDirection?: "up" | "down" | null;
|
|
|
|
|
}): DropTarget | null {
|
|
|
|
|
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
|
|
|
|
const scrollContainer = tracksScrollRef.current;
|
|
|
|
|
if (!containerRect || !scrollContainer) return null;
|
|
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
|
|
|
|
|
({ id }) => id === trackId,
|
|
|
|
|
);
|
2026-01-31 00:20:04 +01:00
|
|
|
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,
|
2026-04-01 19:27:49 +02:00
|
|
|
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
2026-01-31 00:20:04 +01:00
|
|
|
zoomLevel,
|
|
|
|
|
startTimeOverride: snappedTime,
|
|
|
|
|
excludeElementId: movingElement.id,
|
|
|
|
|
verticalDragDirection,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StartDragParams
|
|
|
|
|
extends Omit<
|
|
|
|
|
ElementDragState,
|
2026-04-15 05:43:29 +02:00
|
|
|
"isDragging" | "currentTime" | "currentMouseY"
|
2026-01-31 00:20:04 +01:00
|
|
|
> {
|
2026-04-26 02:50:04 +02:00
|
|
|
initialCurrentTime: MediaTime;
|
2026-01-31 00:20:04 +01:00
|
|
|
initialCurrentMouseY: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useElementInteraction({
|
|
|
|
|
zoomLevel,
|
|
|
|
|
timelineRef,
|
|
|
|
|
tracksContainerRef,
|
|
|
|
|
tracksScrollRef,
|
|
|
|
|
headerRef,
|
|
|
|
|
snappingEnabled,
|
|
|
|
|
onSnapPointChange,
|
|
|
|
|
}: UseElementInteractionProps) {
|
|
|
|
|
const editor = useEditor();
|
2026-02-23 03:24:02 +01:00
|
|
|
const isShiftHeldRef = useShiftKey();
|
2026-04-07 04:41:27 +02:00
|
|
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
2026-01-31 00:20:04 +01:00
|
|
|
const {
|
2026-04-13 22:00:29 +02:00
|
|
|
selectedElements,
|
2026-01-31 00:20:04 +01:00
|
|
|
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);
|
2026-04-15 05:43:29 +02:00
|
|
|
const moveGroupRef = useRef<MoveGroup | null>(null);
|
2026-04-15 05:52:33 +02:00
|
|
|
const newTrackIdsRef = useRef<string[]>([]);
|
2026-04-15 05:43:29 +02:00
|
|
|
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
|
2026-01-31 00:20:04 +01:00
|
|
|
const lastMouseXRef = useRef(0);
|
|
|
|
|
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
|
|
|
|
|
|
|
|
|
const startDrag = useCallback(
|
|
|
|
|
({
|
|
|
|
|
elementId,
|
2026-04-15 05:43:29 +02:00
|
|
|
dragElementIds,
|
|
|
|
|
dragTimeOffsets,
|
2026-01-31 00:20:04 +01:00
|
|
|
trackId,
|
|
|
|
|
startMouseX,
|
|
|
|
|
startMouseY,
|
|
|
|
|
startElementTime,
|
|
|
|
|
clickOffsetTime,
|
|
|
|
|
initialCurrentTime,
|
|
|
|
|
initialCurrentMouseY,
|
|
|
|
|
}: StartDragParams) => {
|
|
|
|
|
setDragState({
|
|
|
|
|
isDragging: true,
|
|
|
|
|
elementId,
|
2026-04-15 05:43:29 +02:00
|
|
|
dragElementIds,
|
|
|
|
|
dragTimeOffsets,
|
2026-01-31 00:20:04 +01:00
|
|
|
trackId,
|
|
|
|
|
startMouseX,
|
|
|
|
|
startMouseY,
|
|
|
|
|
startElementTime,
|
|
|
|
|
clickOffsetTime,
|
|
|
|
|
currentTime: initialCurrentTime,
|
|
|
|
|
currentMouseY: initialCurrentMouseY,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const endDrag = useCallback(() => {
|
2026-04-15 05:43:29 +02:00
|
|
|
moveGroupRef.current = null;
|
2026-04-15 05:52:33 +02:00
|
|
|
newTrackIdsRef.current = [];
|
2026-04-15 05:43:29 +02:00
|
|
|
groupMoveResultRef.current = null;
|
2026-01-31 00:20:04 +01:00
|
|
|
setDragState(initialDragState);
|
|
|
|
|
setDragDropTarget(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
const cancelCurrentDrag = useCallback(() => {
|
|
|
|
|
pendingDragRef.current = null;
|
|
|
|
|
mouseDownLocationRef.current = null;
|
|
|
|
|
setIsPendingDrag(false);
|
|
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
}, [endDrag, onSnapPointChange]);
|
|
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const resolveGroupDragMove = useCallback(
|
|
|
|
|
({
|
|
|
|
|
group,
|
|
|
|
|
snappedTime,
|
|
|
|
|
dropTarget,
|
|
|
|
|
}: {
|
|
|
|
|
group: MoveGroup;
|
2026-04-26 02:50:04 +02:00
|
|
|
snappedTime: MediaTime;
|
2026-04-15 05:43:29 +02:00
|
|
|
dropTarget: DropTarget | null;
|
|
|
|
|
}): GroupMoveResult | null => {
|
|
|
|
|
if (!dropTarget) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dropTarget.isNewTrack) {
|
|
|
|
|
return resolveGroupMove({
|
|
|
|
|
group,
|
|
|
|
|
tracks: sceneTracks,
|
|
|
|
|
anchorStartTime: snappedTime,
|
|
|
|
|
target: {
|
|
|
|
|
kind: "newTracks",
|
|
|
|
|
anchorInsertIndex: dropTarget.trackIndex,
|
2026-04-15 05:52:33 +02:00
|
|
|
newTrackIds: newTrackIdsRef.current,
|
2026-04-15 05:43:29 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const orderedTracks = [
|
|
|
|
|
...sceneTracks.overlay,
|
|
|
|
|
sceneTracks.main,
|
|
|
|
|
...sceneTracks.audio,
|
|
|
|
|
];
|
|
|
|
|
const targetTrack = orderedTracks[dropTarget.trackIndex];
|
|
|
|
|
if (!targetTrack) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingTrackResult = resolveGroupMove({
|
|
|
|
|
group,
|
|
|
|
|
tracks: sceneTracks,
|
|
|
|
|
anchorStartTime: snappedTime,
|
|
|
|
|
target: {
|
|
|
|
|
kind: "existingTrack",
|
|
|
|
|
anchorTargetTrackId: targetTrack.id,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (existingTrackResult) {
|
|
|
|
|
return existingTrackResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolveGroupMove({
|
|
|
|
|
group,
|
|
|
|
|
tracks: sceneTracks,
|
|
|
|
|
anchorStartTime: snappedTime,
|
|
|
|
|
target: {
|
|
|
|
|
kind: "newTracks",
|
|
|
|
|
anchorInsertIndex: dropTarget.trackIndex,
|
2026-04-15 05:52:33 +02:00
|
|
|
newTrackIds: newTrackIdsRef.current,
|
2026-04-15 05:43:29 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[sceneTracks],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!dragState.isDragging && !isPendingDrag) return;
|
|
|
|
|
|
|
|
|
|
return registerCanceller({ fn: cancelCurrentDrag });
|
|
|
|
|
}, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
|
|
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const getDragSnapResult = useCallback(
|
|
|
|
|
({
|
|
|
|
|
frameSnappedTime,
|
2026-04-15 05:43:29 +02:00
|
|
|
group,
|
2026-01-31 00:20:04 +01:00
|
|
|
}: {
|
2026-04-26 02:50:04 +02:00
|
|
|
frameSnappedTime: MediaTime;
|
2026-04-15 05:43:29 +02:00
|
|
|
group: MoveGroup | null;
|
2026-01-31 00:20:04 +01:00
|
|
|
}) => {
|
2026-04-15 05:43:29 +02:00
|
|
|
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
2026-01-31 00:20:04 +01:00
|
|
|
return { snappedTime: frameSnappedTime, snapPoint: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const groupSnap = snapGroupEdges({
|
|
|
|
|
group,
|
|
|
|
|
anchorStartTime: frameSnappedTime,
|
2026-04-07 04:41:27 +02:00
|
|
|
tracks: sceneTracks,
|
2026-04-15 05:43:29 +02:00
|
|
|
playheadTime: editor.playback.getCurrentTime(),
|
2026-01-31 00:20:04 +01:00
|
|
|
zoomLevel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-15 05:43:29 +02:00
|
|
|
snappedTime: groupSnap.snappedAnchorStartTime,
|
|
|
|
|
snapPoint: groupSnap.snapPoint,
|
2026-01-31 00:20:04 +01:00
|
|
|
};
|
|
|
|
|
},
|
2026-04-15 05:43:29 +02:00
|
|
|
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
|
2026-01-31 00:20:04 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-01 19:27:49 +02:00
|
|
|
if (
|
|
|
|
|
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
|
|
|
|
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
|
|
|
|
) {
|
2026-01-31 00:20:04 +01:00
|
|
|
const activeProject = editor.project.getActive();
|
|
|
|
|
if (!activeProject) return;
|
|
|
|
|
const scrollLeft = scrollContainer.scrollLeft;
|
|
|
|
|
const mouseTime = getMouseTimeFromClientX({
|
|
|
|
|
clientX,
|
|
|
|
|
containerRect: scrollContainer.getBoundingClientRect(),
|
|
|
|
|
zoomLevel,
|
|
|
|
|
scrollLeft,
|
|
|
|
|
});
|
2026-04-26 02:50:04 +02:00
|
|
|
const adjustedTime =
|
|
|
|
|
mouseTime > pendingDragRef.current.clickOffsetTime
|
|
|
|
|
? subMediaTime({
|
|
|
|
|
a: mouseTime,
|
|
|
|
|
b: pendingDragRef.current.clickOffsetTime,
|
|
|
|
|
})
|
|
|
|
|
: ZERO_MEDIA_TIME;
|
|
|
|
|
const snappedTime = (
|
2026-04-15 05:43:29 +02:00
|
|
|
roundToFrame({
|
|
|
|
|
time: adjustedTime,
|
|
|
|
|
rate: activeProject.settings.fps,
|
2026-04-26 02:50:04 +02:00
|
|
|
}) ?? adjustedTime
|
|
|
|
|
) as MediaTime;
|
2026-04-15 05:43:29 +02:00
|
|
|
const moveGroup = buildMoveGroup({
|
|
|
|
|
anchorRef: {
|
|
|
|
|
trackId: pendingDragRef.current.trackId,
|
|
|
|
|
elementId: pendingDragRef.current.elementId,
|
|
|
|
|
},
|
|
|
|
|
selectedElements: pendingDragRef.current.selectedElements,
|
|
|
|
|
tracks: sceneTracks,
|
|
|
|
|
});
|
|
|
|
|
if (!moveGroup) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
moveGroupRef.current = moveGroup;
|
2026-04-15 05:52:33 +02:00
|
|
|
newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
|
2026-04-26 02:50:04 +02:00
|
|
|
const dragTimeOffsets: Record<string, MediaTime> = {};
|
2026-04-15 05:43:29 +02:00
|
|
|
for (const member of moveGroup.members) {
|
|
|
|
|
dragTimeOffsets[member.elementId] = member.timeOffset;
|
|
|
|
|
}
|
2026-04-15 05:52:33 +02:00
|
|
|
const {
|
|
|
|
|
snappedTime: initialSnappedTime,
|
|
|
|
|
snapPoint: initialSnapPoint,
|
|
|
|
|
} = getDragSnapResult({
|
|
|
|
|
frameSnappedTime: snappedTime,
|
|
|
|
|
group: moveGroup,
|
|
|
|
|
});
|
|
|
|
|
const verticalDragDirection = getVerticalDragDirection({
|
|
|
|
|
startMouseY: pendingDragRef.current.startMouseY,
|
|
|
|
|
currentMouseY: clientY,
|
|
|
|
|
});
|
|
|
|
|
const anchorDropTarget = getDragDropTarget({
|
|
|
|
|
clientX,
|
|
|
|
|
clientY,
|
|
|
|
|
elementId: pendingDragRef.current.elementId,
|
|
|
|
|
trackId: pendingDragRef.current.trackId,
|
|
|
|
|
tracks: sceneTracks,
|
|
|
|
|
tracksContainerRef,
|
|
|
|
|
tracksScrollRef,
|
|
|
|
|
headerRef,
|
|
|
|
|
zoomLevel,
|
|
|
|
|
snappedTime: initialSnappedTime,
|
|
|
|
|
verticalDragDirection,
|
|
|
|
|
});
|
|
|
|
|
const nextGroupMoveResult =
|
|
|
|
|
anchorDropTarget != null
|
|
|
|
|
? resolveGroupDragMove({
|
|
|
|
|
group: moveGroup,
|
|
|
|
|
snappedTime: initialSnappedTime,
|
|
|
|
|
dropTarget: anchorDropTarget,
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
groupMoveResultRef.current = nextGroupMoveResult;
|
|
|
|
|
setDragDropTarget(
|
|
|
|
|
anchorDropTarget &&
|
|
|
|
|
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
|
|
|
|
? {
|
|
|
|
|
...anchorDropTarget,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
);
|
2026-04-15 05:43:29 +02:00
|
|
|
startDrag({
|
|
|
|
|
...pendingDragRef.current,
|
|
|
|
|
dragElementIds: moveGroup.members.map((member) => member.elementId),
|
|
|
|
|
dragTimeOffsets,
|
2026-04-15 05:52:33 +02:00
|
|
|
initialCurrentTime: initialSnappedTime,
|
2026-01-31 00:20:04 +01:00
|
|
|
initialCurrentMouseY: clientY,
|
|
|
|
|
});
|
2026-04-15 05:52:33 +02:00
|
|
|
onSnapPointChange?.(initialSnapPoint);
|
2026-01-31 00:20:04 +01:00
|
|
|
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,
|
|
|
|
|
});
|
2026-04-26 02:50:04 +02:00
|
|
|
const adjustedTime =
|
|
|
|
|
mouseTime > dragState.clickOffsetTime
|
|
|
|
|
? subMediaTime({
|
|
|
|
|
a: mouseTime,
|
|
|
|
|
b: dragState.clickOffsetTime,
|
|
|
|
|
})
|
|
|
|
|
: ZERO_MEDIA_TIME;
|
2026-01-31 00:20:04 +01:00
|
|
|
const fps = activeProject.settings.fps;
|
2026-04-26 02:50:04 +02:00
|
|
|
const frameSnappedTime = (
|
|
|
|
|
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime
|
|
|
|
|
) as MediaTime;
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const moveGroup = moveGroupRef.current;
|
2026-01-31 00:20:04 +01:00
|
|
|
const { snappedTime, snapPoint } = getDragSnapResult({
|
|
|
|
|
frameSnappedTime,
|
2026-04-15 05:43:29 +02:00
|
|
|
group: moveGroup,
|
2026-01-31 00:20:04 +01:00
|
|
|
});
|
|
|
|
|
setDragState((previousDragState) => ({
|
|
|
|
|
...previousDragState,
|
|
|
|
|
currentTime: snappedTime,
|
|
|
|
|
currentMouseY: clientY,
|
|
|
|
|
}));
|
|
|
|
|
onSnapPointChange?.(snapPoint);
|
|
|
|
|
|
|
|
|
|
if (dragState.elementId && dragState.trackId) {
|
|
|
|
|
const verticalDragDirection = getVerticalDragDirection({
|
|
|
|
|
startMouseY: dragState.startMouseY,
|
|
|
|
|
currentMouseY: clientY,
|
|
|
|
|
});
|
2026-04-15 05:43:29 +02:00
|
|
|
const anchorDropTarget = getDragDropTarget({
|
2026-01-31 00:20:04 +01:00
|
|
|
clientX,
|
|
|
|
|
clientY,
|
|
|
|
|
elementId: dragState.elementId,
|
|
|
|
|
trackId: dragState.trackId,
|
2026-04-07 04:41:27 +02:00
|
|
|
tracks: sceneTracks,
|
2026-01-31 00:20:04 +01:00
|
|
|
tracksContainerRef,
|
|
|
|
|
tracksScrollRef,
|
|
|
|
|
headerRef,
|
|
|
|
|
zoomLevel,
|
|
|
|
|
snappedTime,
|
|
|
|
|
verticalDragDirection,
|
|
|
|
|
});
|
2026-04-15 05:43:29 +02:00
|
|
|
const nextGroupMoveResult =
|
|
|
|
|
moveGroup && anchorDropTarget
|
|
|
|
|
? resolveGroupDragMove({
|
|
|
|
|
group: moveGroup,
|
|
|
|
|
snappedTime,
|
|
|
|
|
dropTarget: anchorDropTarget,
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
groupMoveResultRef.current = nextGroupMoveResult;
|
|
|
|
|
setDragDropTarget(
|
|
|
|
|
anchorDropTarget &&
|
|
|
|
|
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
|
|
|
|
? {
|
|
|
|
|
...anchorDropTarget,
|
|
|
|
|
isNewTrack: true,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
);
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
isPendingDrag,
|
|
|
|
|
startDrag,
|
|
|
|
|
getDragSnapResult,
|
2026-04-15 05:43:29 +02:00
|
|
|
resolveGroupDragMove,
|
|
|
|
|
sceneTracks,
|
2026-01-31 00:20:04 +01:00
|
|
|
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);
|
2026-04-01 19:27:49 +02:00
|
|
|
if (
|
|
|
|
|
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
|
|
|
|
|
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
|
|
|
|
|
) {
|
2026-01-31 00:20:04 +01:00
|
|
|
mouseDownLocationRef.current = null;
|
|
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const moveGroup = moveGroupRef.current;
|
|
|
|
|
if (!moveGroup) {
|
2026-04-06 00:57:54 +02:00
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-04-15 05:52:33 +02:00
|
|
|
const groupMoveResult = groupMoveResultRef.current;
|
2026-04-15 05:43:29 +02:00
|
|
|
if (!groupMoveResult) {
|
|
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
return;
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 05:43:29 +02:00
|
|
|
const didMove = groupMoveResult.moves.some((move) => {
|
|
|
|
|
const currentMember = moveGroup.members.find(
|
|
|
|
|
(member) => member.elementId === move.elementId,
|
|
|
|
|
);
|
2026-04-26 02:50:04 +02:00
|
|
|
const originalStartTime = addMediaTime({
|
|
|
|
|
a: dragState.startElementTime,
|
|
|
|
|
b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME,
|
|
|
|
|
});
|
2026-04-15 05:43:29 +02:00
|
|
|
return (
|
|
|
|
|
currentMember?.trackId !== move.targetTrackId ||
|
|
|
|
|
originalStartTime !== move.newStartTime
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
if (!didMove && groupMoveResult.createTracks.length === 0) {
|
|
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editor.timeline.moveElements({
|
|
|
|
|
moves: groupMoveResult.moves,
|
|
|
|
|
createTracks: groupMoveResult.createTracks,
|
|
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
endDrag();
|
|
|
|
|
onSnapPointChange?.(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
return () => document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
}, [
|
|
|
|
|
dragState.isDragging,
|
|
|
|
|
dragState.elementId,
|
2026-04-13 04:41:10 +02:00
|
|
|
dragState.startElementTime,
|
2026-01-31 00:20:04 +01:00
|
|
|
dragState.trackId,
|
|
|
|
|
endDrag,
|
|
|
|
|
onSnapPointChange,
|
|
|
|
|
editor.timeline,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}) => {
|
2026-03-25 16:47:22 +01:00
|
|
|
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
// right-click: don't stop propagation so ContextMenu can open
|
|
|
|
|
if (isRightClick) {
|
2026-01-31 00:20:04 +01:00
|
|
|
const alreadySelected = isElementSelected({
|
|
|
|
|
trackId: track.id,
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
});
|
|
|
|
|
if (!alreadySelected) {
|
|
|
|
|
handleSelectionClick({
|
|
|
|
|
trackId: track.id,
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
isMultiKey: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
event.stopPropagation();
|
|
|
|
|
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
if (isMultiSelect) {
|
2026-01-31 00:20:04 +01:00
|
|
|
handleSelectionClick({
|
|
|
|
|
trackId: track.id,
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
isMultiKey: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
const clickOffsetTime = getClickOffsetTime({
|
2026-01-31 00:20:04 +01:00
|
|
|
clientX: event.clientX,
|
|
|
|
|
elementRect: event.currentTarget.getBoundingClientRect(),
|
|
|
|
|
zoomLevel,
|
|
|
|
|
});
|
2026-04-15 05:43:29 +02:00
|
|
|
const elementRef = {
|
|
|
|
|
trackId: track.id,
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
};
|
|
|
|
|
const pendingSelectedElements = isElementSelected(elementRef)
|
|
|
|
|
? selectedElements
|
|
|
|
|
: [elementRef];
|
2026-01-31 00:20:04 +01:00
|
|
|
pendingDragRef.current = {
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
trackId: track.id,
|
2026-04-15 05:43:29 +02:00
|
|
|
selectedElements: pendingSelectedElements,
|
2026-01-31 00:20:04 +01:00
|
|
|
startMouseX: event.clientX,
|
|
|
|
|
startMouseY: event.clientY,
|
|
|
|
|
startElementTime: element.startTime,
|
|
|
|
|
clickOffsetTime,
|
|
|
|
|
};
|
|
|
|
|
setIsPendingDrag(true);
|
|
|
|
|
},
|
2026-04-15 05:43:29 +02:00
|
|
|
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
|
2026-01-31 00:20:04 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleElementClick = useCallback(
|
|
|
|
|
({
|
|
|
|
|
event,
|
|
|
|
|
element,
|
|
|
|
|
track,
|
|
|
|
|
}: {
|
|
|
|
|
event: ReactMouseEvent;
|
|
|
|
|
element: TimelineElement;
|
|
|
|
|
track: TimelineTrack;
|
|
|
|
|
}) => {
|
2026-03-25 16:47:22 +01:00
|
|
|
event.stopPropagation();
|
2026-01-31 00:20:04 +01:00
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
if (mouseDownLocationRef.current) {
|
2026-01-31 00:20:04 +01:00
|
|
|
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
|
|
|
|
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
2026-04-01 19:27:49 +02:00
|
|
|
if (
|
|
|
|
|
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
|
|
|
|
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
|
|
|
|
) {
|
2026-01-31 00:20:04 +01:00
|
|
|
mouseDownLocationRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// modifier keys already handled in mousedown
|
|
|
|
|
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
|
|
|
|
2026-03-25 16:47:22 +01:00
|
|
|
const alreadySelected = isElementSelected({
|
2026-01-31 00:20:04 +01:00
|
|
|
trackId: track.id,
|
|
|
|
|
elementId: element.id,
|
|
|
|
|
});
|
2026-04-07 12:38:08 +02:00
|
|
|
if (!alreadySelected || selectedElements.length > 1) {
|
2026-01-31 00:20:04 +01:00
|
|
|
selectElement({ trackId: track.id, elementId: element.id });
|
2026-02-27 16:33:57 +01:00
|
|
|
return;
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
2026-02-27 16:33:57 +01:00
|
|
|
|
|
|
|
|
editor.selection.clearKeyframeSelection();
|
2026-01-31 00:20:04 +01:00
|
|
|
},
|
2026-04-07 12:38:08 +02:00
|
|
|
[editor.selection, isElementSelected, selectElement, selectedElements],
|
2026-01-31 00:20:04 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dragState,
|
|
|
|
|
dragDropTarget,
|
|
|
|
|
handleElementMouseDown,
|
|
|
|
|
handleElementClick,
|
|
|
|
|
lastMouseXRef,
|
|
|
|
|
};
|
|
|
|
|
}
|