diff options
Diffstat (limited to 'apps/web/src/hooks/timeline/element/use-element-resize.ts')
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-resize.ts | 331 |
1 files changed, 331 insertions, 0 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts new file mode 100644 index 00000000..803d9788 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -0,0 +1,331 @@ +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, + }; +} |
