import { useState, useEffect, useRef, useCallback } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import type { TimelineElement, TimelineTrack } from "@/types/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 { 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 = useEditor(); const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const rippleEditingEnabled = useTimelineStore( (state) => state.rippleEditingEnabled, ); const [resizing, setResizing] = useState(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 updateTrimFromMouseMove = useCallback( ({ clientX }: { clientX: number }) => { if (!resizing) return; const deltaX = clientX - resizing.startX; let deltaTime = deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); let resizeSnapPoint: SnapPoint | null = null; const projectFps = activeProject.settings.fps; const minDurationSeconds = 1 / projectFps; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (shouldSnap) { 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); 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 = resizing.initialTrimStart + resizing.initialDuration + resizing.initialTrimEnd; const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) ? Math.max( 0, resizing.initialTrimStart + (leftNeighborBound - resizing.initialStartTime), ) : 0; const maxAllowed = sourceDuration - resizing.initialTrimEnd - minDurationSeconds; const calculated = resizing.initialTrimStart + deltaTime; if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ time: Math.min( maxAllowed, Math.max(minTrimStartForNeighbor, 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.max( 0, Number.isFinite(leftNeighborBound) ? Math.min( extensionAmount, maxExtension, resizing.initialStartTime - leftNeighborBound, ) : 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 = minTrimStartForNeighbor - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, }); const newDuration = snapTimeToFrame({ time: resizing.initialDuration - trimDelta, fps: projectFps, }); setCurrentTrimStart(minTrimStartForNeighbor); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); currentTrimStartRef.current = minTrimStartForNeighbor; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } } } else { const sourceDuration = resizing.initialTrimStart + resizing.initialDuration + resizing.initialTrimEnd; const newTrimEnd = resizing.initialTrimEnd - 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 = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps, }); setCurrentDuration(newDuration); setCurrentTrimEnd(0); currentDurationRef.current = newDuration; currentTrimEndRef.current = 0; } else { const extensionToLimit = resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ time: Math.min( resizing.initialDuration + extensionToLimit, maxAllowedDuration, ), fps: projectFps, }); setCurrentDuration(newDuration); setCurrentTrimEnd(0); currentDurationRef.current = newDuration; currentTrimEndRef.current = 0; } } else { const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) ? Math.max( 0, resizing.initialDuration + resizing.initialTrimEnd - maxAllowedDuration, ) : 0; const maxTrimEnd = sourceDuration - resizing.initialTrimStart - minDurationSeconds; const clampedTrimEnd = Math.min( maxTrimEnd, Math.max(minTrimEndForNeighbor, 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, element.id, track.elements, onSnapPointChange, canExtendElementDuration, 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, rippleEnabled: rippleEditingEnabled, }); } setResizing(null); onResizeStateChange?.({ isResizing: false }); onSnapPointChange?.(null); }, [ resizing, editor.timeline, element.id, onResizeStateChange, onSnapPointChange, rippleEditingEnabled, ]); 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, }; }