OpenCut/apps/web/src/hooks/timeline/element/use-element-resize.ts

332 lines
10 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { snapTimeToFrame } from "@/lib/time";
import { EditorCore } from "@/core";
import {
useTimelineSnapping,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import { useTimelineStore } from "@/stores/timeline-store";
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 = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
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 = ({
e,
elementId,
side,
}: {
e: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
e.stopPropagation();
e.preventDefault();
setResizing({
elementId,
side,
startX: e.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(() => {
if (element.type === "text" || element.type === "image") {
return true;
}
return false;
}, [element.type]);
const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime = deltaX / (50 * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = activeProject.settings.fps;
const minDurationSeconds = 1 / projectFps;
const canSnap = snappingEnabled;
if (canSnap) {
const tracks = editor.timeline.getTracks();
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);
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
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.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: baseDuration + extensionNeeded,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
}
}
}, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
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) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
});
}
if (startTimeChanged) {
editor.timeline.updateElementStartTime({
elements: [{ trackId: track.id, elementId: element.id }],
startTime: finalStartTime,
});
}
if (durationChanged) {
editor.timeline.updateElementDuration({
trackId: track.id,
elementId: element.id,
duration: finalDuration,
});
}
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [resizing, editor.timeline, element.id, track.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,
};
}