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(
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
type SnapPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import { isRetimableElement } from "@/lib/timeline";
|
||||
import {
|
||||
getSourceSpanAtClipTime,
|
||||
getTimelineDurationForSourceSpan,
|
||||
} from "@/lib/retime";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
|
||||
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 = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
|
||||
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 = ({
|
||||
event,
|
||||
elementId,
|
||||
side,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: event.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(() => {
|
||||
return element.sourceDuration == null;
|
||||
}, [element.sourceDuration]);
|
||||
|
||||
const getSourceDeltaForClipDelta = useCallback(
|
||||
(clipDelta: number) => {
|
||||
if (!isRetimableElement(element)) {
|
||||
return clipDelta;
|
||||
}
|
||||
|
||||
return clipDelta >= 0
|
||||
? getSourceSpanAtClipTime({
|
||||
clipTime: clipDelta,
|
||||
retime: element.retime,
|
||||
})
|
||||
: -getSourceSpanAtClipTime({
|
||||
clipTime: Math.abs(clipDelta),
|
||||
retime: element.retime,
|
||||
});
|
||||
},
|
||||
[element],
|
||||
);
|
||||
|
||||
const getVisibleSourceSpanForDuration = useCallback(
|
||||
(duration: number) => {
|
||||
if (!isRetimableElement(element)) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
return getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime: element.retime,
|
||||
});
|
||||
},
|
||||
[element],
|
||||
);
|
||||
|
||||
const getDurationForVisibleSourceSpan = useCallback(
|
||||
(sourceSpan: number) => {
|
||||
if (!isRetimableElement(element)) {
|
||||
return sourceSpan;
|
||||
}
|
||||
|
||||
return getTimelineDurationForSourceSpan({
|
||||
sourceSpan,
|
||||
retime: element.retime,
|
||||
});
|
||||
},
|
||||
[element],
|
||||
);
|
||||
|
||||
const getSourceDuration = useCallback(
|
||||
({
|
||||
trimStart,
|
||||
duration,
|
||||
trimEnd,
|
||||
}: {
|
||||
trimStart: number;
|
||||
duration: number;
|
||||
trimEnd: number;
|
||||
}) => {
|
||||
if (typeof element.sourceDuration === "number") {
|
||||
return element.sourceDuration;
|
||||
}
|
||||
|
||||
return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
|
||||
},
|
||||
[element.sourceDuration, getVisibleSourceSpanForDuration],
|
||||
);
|
||||
|
||||
const cancelResize = useCallback(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
setCurrentTrimStart(resizing.initialTrimStart);
|
||||
setCurrentTrimEnd(resizing.initialTrimEnd);
|
||||
setCurrentStartTime(resizing.initialStartTime);
|
||||
setCurrentDuration(resizing.initialDuration);
|
||||
currentTrimStartRef.current = resizing.initialTrimStart;
|
||||
currentTrimEndRef.current = resizing.initialTrimEnd;
|
||||
currentStartTimeRef.current = resizing.initialStartTime;
|
||||
currentDurationRef.current = resizing.initialDuration;
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
}, [resizing, onResizeStateChange, onSnapPointChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
return registerCanceller({ fn: cancelResize });
|
||||
}, [resizing, cancelResize]);
|
||||
|
||||
const updateTrimFromMouseMove = useCallback(
|
||||
({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime = Math.round(
|
||||
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
|
||||
);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
|
||||
const projectFps = editor.project.getActive().settings.fps;
|
||||
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (shouldSnap) {
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
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);
|
||||
|
||||
const otherElements = track.elements.filter(
|
||||
({ id }) => id !== element.id,
|
||||
);
|
||||
const initialEndTime =
|
||||
resizing.initialStartTime + resizing.initialDuration;
|
||||
|
||||
const rightNeighborBound =
|
||||
resizing.side === "right"
|
||||
? otherElements
|
||||
.filter(({ startTime }) => startTime >= initialEndTime)
|
||||
.reduce(
|
||||
(min, { startTime }) => Math.min(min, startTime),
|
||||
Infinity,
|
||||
)
|
||||
: Infinity;
|
||||
|
||||
const leftNeighborBound =
|
||||
resizing.side === "left"
|
||||
? otherElements
|
||||
.filter(
|
||||
({ startTime, duration }) =>
|
||||
startTime + duration <= resizing.initialStartTime,
|
||||
)
|
||||
.reduce(
|
||||
(max, { startTime, duration }) =>
|
||||
Math.max(max, startTime + duration),
|
||||
-Infinity,
|
||||
)
|
||||
: -Infinity;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const sourceDuration = getSourceDuration({
|
||||
trimStart: resizing.initialTrimStart,
|
||||
duration: resizing.initialDuration,
|
||||
trimEnd: resizing.initialTrimEnd,
|
||||
});
|
||||
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
|
||||
? Math.max(
|
||||
0,
|
||||
resizing.initialTrimStart +
|
||||
getSourceDeltaForClipDelta(
|
||||
leftNeighborBound - resizing.initialStartTime,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const maxAllowed =
|
||||
sourceDuration -
|
||||
resizing.initialTrimEnd -
|
||||
getVisibleSourceSpanForDuration(minDuration);
|
||||
const calculated =
|
||||
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
||||
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||
);
|
||||
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||
const trimDelta = resizing.initialDuration - newDuration;
|
||||
const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
|
||||
|
||||
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.max(
|
||||
0,
|
||||
Number.isFinite(leftNeighborBound)
|
||||
? Math.min(
|
||||
extensionAmount,
|
||||
maxExtension,
|
||||
resizing.initialStartTime - leftNeighborBound,
|
||||
)
|
||||
: Math.min(extensionAmount, maxExtension),
|
||||
);
|
||||
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
|
||||
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = 0;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
} else {
|
||||
const leftBound = Number.isFinite(leftNeighborBound)
|
||||
? leftNeighborBound
|
||||
: 0;
|
||||
const trimDeltaFromTrimStart =
|
||||
minTrimStartForNeighbor - resizing.initialTrimStart;
|
||||
const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
|
||||
leftBound - resizing.initialStartTime,
|
||||
);
|
||||
const trimDelta = Math.max(
|
||||
trimDeltaFromTrimStart,
|
||||
trimDeltaFromStartTime,
|
||||
);
|
||||
const newTrimStart = resizing.initialTrimStart + trimDelta;
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||
);
|
||||
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimStartRef.current = newTrimStart;
|
||||
currentStartTimeRef.current = newStartTime;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const sourceDuration = getSourceDuration({
|
||||
trimStart: resizing.initialTrimStart,
|
||||
duration: resizing.initialDuration,
|
||||
trimEnd: resizing.initialTrimEnd,
|
||||
});
|
||||
const newTrimEnd =
|
||||
resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
|
||||
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
|
||||
? rightNeighborBound - resizing.initialStartTime
|
||||
: Infinity;
|
||||
|
||||
if (newTrimEnd < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
} else {
|
||||
const unclampedDuration = getDurationForVisibleSourceSpan(
|
||||
Math.max(0, sourceDuration - resizing.initialTrimStart),
|
||||
);
|
||||
const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
currentDurationRef.current = newDuration;
|
||||
currentTrimEndRef.current = 0;
|
||||
}
|
||||
} else {
|
||||
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
|
||||
? Math.max(
|
||||
0,
|
||||
sourceDuration -
|
||||
resizing.initialTrimStart -
|
||||
getVisibleSourceSpanForDuration(maxAllowedDuration),
|
||||
)
|
||||
: 0;
|
||||
const maxTrimEnd =
|
||||
sourceDuration -
|
||||
resizing.initialTrimStart -
|
||||
getVisibleSourceSpanForDuration(minDuration);
|
||||
const clampedTrimEnd = Math.min(
|
||||
maxTrimEnd,
|
||||
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
||||
);
|
||||
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
||||
);
|
||||
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
currentTrimEndRef.current = finalTrimEnd;
|
||||
currentDurationRef.current = newDuration;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
resizing,
|
||||
zoomLevel,
|
||||
snappingEnabled,
|
||||
editor,
|
||||
element.id,
|
||||
track.elements,
|
||||
onSnapPointChange,
|
||||
canExtendElementDuration,
|
||||
getDurationForVisibleSourceSpan,
|
||||
getSourceDeltaForClipDelta,
|
||||
getSourceDuration,
|
||||
getVisibleSourceSpanForDuration,
|
||||
isShiftHeldRef,
|
||||
],
|
||||
);
|
||||
|
||||
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 ||
|
||||
startTimeChanged ||
|
||||
durationChanged
|
||||
) {
|
||||
editor.timeline.updateElementTrim({
|
||||
elementId: element.id,
|
||||
trimStart: finalTrimStart,
|
||||
trimEnd: finalTrimEnd,
|
||||
startTime: startTimeChanged ? finalStartTime : undefined,
|
||||
duration: durationChanged ? finalDuration : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setResizing(null);
|
||||
onResizeStateChange?.({ isResizing: false });
|
||||
onSnapPointChange?.(null);
|
||||
}, [
|
||||
resizing,
|
||||
editor.timeline,
|
||||
element.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user