mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: move/resize multiple elements
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user