summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
commitac43a5a73fa905cdac905bc956763b6093d6738e (patch)
tree8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web/src/hooks
parent6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff)
downloadopencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz
feat: move/resize multiple elements
Diffstat (limited to 'apps/web/src/hooks')
-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
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-resize.ts322
3 files changed, 514 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,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts
new file mode 100644
index 00000000..f246261a
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-timeline-resize.ts
@@ -0,0 +1,322 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { EditorCore } from "@/core";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useTimelineStore } from "@/stores/timeline-store";
+import {
+ computeGroupResize,
+ type GroupResizeMember,
+ type GroupResizeResult,
+ type ResizeSide,
+} from "@/lib/timeline/group-resize";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
+import type {
+ SceneTracks,
+ TimelineElement,
+ TimelineTrack,
+} from "@/lib/timeline";
+import { isRetimableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface ResizeInteractionState {
+ side: ResizeSide;
+ startX: number;
+ members: GroupResizeMember[];
+}
+
+export function useTimelineResize({
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
+ const { selectedElements } = useElementSelection();
+ const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
+ null,
+ );
+ const latestResultRef = useRef<GroupResizeResult | null>(null);
+
+ const cancelResize = useCallback(() => {
+ editor.timeline.discardPreview();
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ }, [editor.timeline, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ return registerCanceller({ fn: cancelResize });
+ }, [resizeState, cancelResize]);
+
+ const handleResizeStart = useCallback(
+ ({
+ event,
+ element,
+ track,
+ side,
+ }: {
+ event: React.MouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ side: ResizeSide;
+ }) => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const elementRef = {
+ trackId: track.id,
+ elementId: element.id,
+ };
+ const activeSelection = selectedElements.some(
+ (selectedElement) =>
+ selectedElement.trackId === track.id &&
+ selectedElement.elementId === element.id,
+ )
+ ? selectedElements
+ : [elementRef];
+ const members = buildResizeMembers({
+ tracks: editor.scenes.getActiveScene().tracks,
+ selectedElements: activeSelection,
+ });
+ if (members.length === 0) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ latestResultRef.current = null;
+ setResizeState({
+ side,
+ startX: event.clientX,
+ members,
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ const deltaX = clientX - resizeState.startX;
+ const rawDeltaTime = Math.round(
+ (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
+ TICKS_PER_SECOND,
+ );
+ const snappedDeltaTime = getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap: snappingEnabled && !isShiftHeldRef.current,
+ onSnapPointChange,
+ });
+ const fps = editor.project.getActive().settings.fps;
+ const result = computeGroupResize({
+ members: resizeState.members,
+ side: resizeState.side,
+ deltaTime: snappedDeltaTime.deltaTime,
+ fps,
+ });
+
+ latestResultRef.current = result;
+ editor.timeline.previewElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ updates: patch,
+ })),
+ });
+ };
+
+ const handleMouseUp = () => {
+ const result = latestResultRef.current;
+ editor.timeline.discardPreview();
+ if (
+ result &&
+ hasResizeChanges({ members: resizeState.members, result })
+ ) {
+ editor.timeline.updateElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ patch,
+ })),
+ });
+ }
+
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ resizeState,
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeldRef,
+ editor,
+ onSnapPointChange,
+ ]);
+
+ return {
+ isResizing: resizeState !== null,
+ handleResizeStart,
+ };
+}
+
+function buildResizeMembers({
+ tracks,
+ selectedElements,
+}: {
+ tracks: SceneTracks;
+ selectedElements: Array<{ trackId: string; elementId: string }>;
+}): GroupResizeMember[] {
+ const selectedElementIds = new Set(
+ selectedElements.map((selectedElement) => selectedElement.elementId),
+ );
+ const trackMap = new Map(
+ [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
+ track.id,
+ track,
+ ]),
+ );
+
+ return selectedElements.flatMap(({ trackId, elementId }) => {
+ const track = trackMap.get(trackId);
+ const element = track?.elements.find(
+ (trackElement) => trackElement.id === elementId,
+ );
+ if (!track || !element) {
+ return [];
+ }
+
+ const otherElements = track.elements.filter(
+ (trackElement) => !selectedElementIds.has(trackElement.id),
+ );
+ const leftNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime + trackElement.duration <= element.startTime,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.max(bound, trackElement.startTime + trackElement.duration);
+ }, -Infinity);
+ const rightNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime >= element.startTime + element.duration,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.min(bound, trackElement.startTime);
+ }, Infinity);
+
+ return [
+ {
+ trackId,
+ elementId,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ sourceDuration: element.sourceDuration,
+ retime: isRetimableElement(element) ? element.retime : undefined,
+ leftNeighborBound,
+ rightNeighborBound,
+ },
+ ];
+ });
+}
+
+function getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap,
+ onSnapPointChange,
+}: {
+ editor: EditorCore;
+ resizeState: ResizeInteractionState;
+ rawDeltaTime: number;
+ zoomLevel: number;
+ shouldSnap: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}): { deltaTime: number } {
+ if (!shouldSnap) {
+ onSnapPointChange?.(null);
+ return { deltaTime: rawDeltaTime };
+ }
+
+ const snapPoints = findSnapPoints({
+ tracks: editor.scenes.getActiveScene().tracks,
+ playheadTime: editor.playback.getCurrentTime(),
+ excludeElementIds: new Set(
+ resizeState.members.map((member) => member.elementId),
+ ),
+ });
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestSnapDistance = Infinity;
+ let deltaTime = rawDeltaTime;
+
+ for (const member of resizeState.members) {
+ const baseEdgeTime =
+ resizeState.side === "left"
+ ? member.startTime
+ : member.startTime + member.duration;
+ const snapResult = snapToNearestPoint({
+ targetTime: baseEdgeTime + rawDeltaTime,
+ snapPoints,
+ zoomLevel,
+ });
+ if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
+ closestSnapDistance = snapResult.snapDistance;
+ closestSnapPoint = snapResult.snapPoint;
+ deltaTime = snapResult.snappedTime - baseEdgeTime;
+ }
+ }
+
+ onSnapPointChange?.(closestSnapPoint);
+ return { deltaTime };
+}
+
+function hasResizeChanges({
+ members,
+ result,
+}: {
+ members: GroupResizeMember[];
+ result: GroupResizeResult;
+}): boolean {
+ return result.updates.some((update) => {
+ const member = members.find(
+ (candidateMember) => candidateMember.elementId === update.elementId,
+ );
+ return (
+ member?.trimStart !== update.patch.trimStart ||
+ member?.trimEnd !== update.patch.trimEnd ||
+ member?.startTime !== update.patch.startTime ||
+ member?.duration !== update.patch.duration
+ );
+ });
+}