summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/element
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/timeline/element')
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts299
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts503
2 files changed, 192 insertions, 610 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 854c4f4e..98d21d9b 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -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) {
+ const moveGroup = moveGroupRef.current;
+ if (!moveGroup) {
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 groupMoveResult = resolveGroupDragMove({
+ group: moveGroup,
+ snappedTime,
+ dropTarget,
+ });
+ if (!groupMoveResult) {
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 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(
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
deleted file mode 100644
index bb0d880c..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ /dev/null
@@ -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,
- };
-}