feat: move/resize multiple elements

This commit is contained in:
Maze Winther
2026-04-15 05:43:29 +02:00
parent 6312fc5b07
commit ac43a5a73f
21 changed files with 2223 additions and 962 deletions
@@ -9,6 +9,13 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
buildMoveGroup,
resolveGroupMove,
snapGroupEdges,
type GroupMoveResult,
type MoveGroup,
} from "@/lib/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
@@ -16,10 +23,11 @@ import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementRef,
ElementDragState,
SceneTracks,
TimelineElement,
@@ -55,6 +63,7 @@ const initialDragState: ElementDragState = {
interface PendingDragState {
elementId: string;
trackId: string;
selectedElements: ElementRef[];
startMouseX: number;
startMouseY: number;
startElementTime: number;
@@ -116,11 +125,9 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
const sourceTrack = [
...tracks.overlay,
tracks.main,
...tracks.audio,
].find(({ id }) => id === trackId);
const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
({ id }) => id === trackId,
);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId,
);
@@ -153,7 +160,7 @@ function getDragDropTarget({
interface StartDragParams
extends Omit<
ElementDragState,
"isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets"
"isDragging" | "currentTime" | "currentMouseY"
> {
initialCurrentTime: number;
initialCurrentMouseY: number;
@@ -171,11 +178,6 @@ export function useElementInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const sceneTracks = editor.scenes.getActiveScene().tracks;
const tracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const {
selectedElements,
isElementSelected,
@@ -188,12 +190,16 @@ export function useElementInteraction({
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const moveGroupRef = useRef<MoveGroup | null>(null);
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
const lastMouseXRef = useRef(0);
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
const startDrag = useCallback(
({
elementId,
dragElementIds,
dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@@ -205,8 +211,8 @@ export function useElementInteraction({
setDragState({
isDragging: true,
elementId,
dragElementIds: elementId ? [elementId] : [],
dragTimeOffsets: {},
dragElementIds,
dragTimeOffsets,
trackId,
startMouseX,
startMouseY,
@@ -220,6 +226,8 @@ export function useElementInteraction({
);
const endDrag = useCallback(() => {
moveGroupRef.current = null;
groupMoveResultRef.current = null;
setDragState(initialDragState);
setDragDropTarget(null);
}, []);
@@ -232,6 +240,70 @@ export function useElementInteraction({
onSnapPointChange?.(null);
}, [endDrag, onSnapPointChange]);
const resolveGroupDragMove = useCallback(
({
group,
snappedTime,
dropTarget,
}: {
group: MoveGroup;
snappedTime: number;
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,
newTrackIds: group.members.map(() => generateUUID()),
},
});
}
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,
newTrackIds: group.members.map(() => generateUUID()),
},
});
},
[sceneTracks],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@@ -241,51 +313,29 @@ export function useElementInteraction({
const getDragSnapResult = useCallback(
({
frameSnappedTime,
movingElement,
group,
}: {
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
group: MoveGroup | null;
}) => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap || !movingElement) {
if (!group || !snappingEnabled || isShiftHeldRef.current) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
const elementDuration = movingElement.duration;
const playheadTime = editor.playback.getCurrentTime();
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
const groupSnap = snapGroupEdges({
group,
anchorStartTime: frameSnappedTime,
tracks: sceneTracks,
playheadTime,
playheadTime: editor.playback.getCurrentTime(),
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: true,
});
const endSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks: sceneTracks,
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,
snappedTime: groupSnap.snappedAnchorStartTime,
snapPoint: groupSnap.snapPoint,
};
},
[snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
@@ -314,17 +364,38 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime =
roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
const moveGroup = buildMoveGroup({
anchorRef: {
trackId: pendingDragRef.current.trackId,
elementId: pendingDragRef.current.elementId,
},
selectedElements: pendingDragRef.current.selectedElements,
tracks: sceneTracks,
});
if (!moveGroup) {
return;
}
moveGroupRef.current = moveGroup;
groupMoveResultRef.current = null;
const dragTimeOffsets: Record<string, number> = {};
for (const member of moveGroup.members) {
dragTimeOffsets[member.elementId] = member.timeOffset;
}
startDrag({
...pendingDragRef.current,
dragElementIds: moveGroup.members.map((member) => member.elementId),
dragTimeOffsets,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@@ -364,15 +435,13 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const frameSnappedTime =
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
const moveGroup = moveGroupRef.current;
const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime,
movingElement,
group: moveGroup,
});
setDragState((previousDragState) => ({
...previousDragState,
@@ -386,7 +455,7 @@ export function useElementInteraction({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
});
const dropTarget = getDragDropTarget({
const anchorDropTarget = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
@@ -399,7 +468,24 @@ export function useElementInteraction({
snappedTime,
verticalDragDirection,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
const nextGroupMoveResult =
moveGroup && anchorDropTarget
? resolveGroupDragMove({
group: moveGroup,
snappedTime,
dropTarget: anchorDropTarget,
})
: null;
groupMoveResultRef.current = nextGroupMoveResult;
setDragDropTarget(
anchorDropTarget &&
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
? {
...anchorDropTarget,
isNewTrack: true,
}
: null,
);
}
};
@@ -419,10 +505,11 @@ export function useElementInteraction({
tracksScrollRef,
tracksContainerRef,
headerRef,
tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
resolveGroupDragMove,
sceneTracks,
onSnapPointChange,
]);
@@ -468,55 +555,45 @@ export function useElementInteraction({
return;
}
const snappedTime = dragState.currentTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
const movingElement =
sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null;
if (
movingElement &&
!dropTarget.isNewTrack &&
tracks[dropTarget.trackIndex]?.id === dragState.trackId &&
snappedTime === movingElement.startTime
) {
const moveGroup = moveGroupRef.current;
if (!moveGroup) {
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 },
});
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({
trackId: targetTrack.id,
elementId: dragState.elementId,
});
}
}
const groupMoveResult = resolveGroupDragMove({
group: moveGroup,
snappedTime,
dropTarget,
});
if (!groupMoveResult) {
endDrag();
onSnapPointChange?.(null);
return;
}
const didMove = groupMoveResult.moves.some((move) => {
const currentMember = moveGroup.members.find(
(member) => member.elementId === move.elementId,
);
const originalStartTime =
dragState.startElementTime + (currentMember?.timeOffset ?? 0);
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,
});
endDrag();
onSnapPointChange?.(null);
};
@@ -531,14 +608,14 @@ export function useElementInteraction({
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
endDrag,
onSnapPointChange,
editor.timeline,
tracksContainerRef,
tracksScrollRef,
headerRef,
selectElement,
resolveGroupDragMove,
sceneTracks,
]);
useEffect(() => {
@@ -600,9 +677,17 @@ export function useElementInteraction({
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
});
const elementRef = {
trackId: track.id,
elementId: element.id,
};
const pendingSelectedElements = isElementSelected(elementRef)
? selectedElements
: [elementRef];
pendingDragRef.current = {
elementId: element.id,
trackId: track.id,
selectedElements: pendingSelectedElements,
startMouseX: event.clientX,
startMouseY: event.clientY,
startElementTime: element.startTime,
@@ -610,7 +695,7 @@ export function useElementInteraction({
};
setIsPendingDrag(true);
},
[zoomLevel, isElementSelected, handleSelectionClick],
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
);
const handleElementClick = useCallback(