diff options
Diffstat (limited to 'apps/web/src/hooks/timeline/element')
3 files changed, 1064 insertions, 0 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 new file mode 100644 index 00000000..c92d6722 --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -0,0 +1,619 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { generateUUID } from "@/utils/id"; +import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import type { + DropTarget, + ElementDragState, + TimelineElement, + TimelineTrack, +} from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; + +const DRAG_THRESHOLD_PX = 5; + +interface UseElementInteractionProps { + zoomLevel: number; + timelineRef: RefObject<HTMLDivElement | null>; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +const initialDragState: ElementDragState = { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startMouseY: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + currentMouseY: 0, +}; + +interface PendingDragState { + elementId: string; + trackId: string; + startMouseX: number; + startMouseY: number; + startElementTime: number; + clickOffsetTime: number; +} + +function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + return Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); +} + +function getClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): number { + const clickOffsetX = clientX - elementRect.left; + return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); +} + +function getVerticalDragDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function getDragDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: TimelineTrack[]; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + zoomLevel: number; + snappedTime: number; + verticalDragDirection?: "up" | "down" | null; +}): DropTarget | null { + const containerRect = tracksContainerRef.current?.getBoundingClientRect(); + const scrollContainer = tracksScrollRef.current; + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = tracks.find(({ id }) => id === trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const elementDuration = movingElement.duration; + const scrollLeft = scrollContainer.scrollLeft; + const scrollTop = scrollContainer.scrollTop; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseX = clientX - scrollContainerRect.left + scrollLeft; + const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX, + mouseY, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +interface StartDragParams + extends Omit< + ElementDragState, + "isDragging" | "currentTime" | "currentMouseY" + > { + initialCurrentTime: number; + initialCurrentMouseY: number; +} + +export function useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef, + snappingEnabled, + onSnapPointChange, +}: UseElementInteractionProps) { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const { snapElementEdge } = useTimelineSnapping(); + const { + isElementSelected, + selectElement, + handleElementClick: handleSelectionClick, + } = useElementSelection(); + + const [dragState, setDragState] = + useState<ElementDragState>(initialDragState); + const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef<PendingDragState | null>(null); + const lastMouseXRef = useRef(0); + const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); + + const startDrag = useCallback( + ({ + elementId, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + initialCurrentTime, + initialCurrentMouseY, + }: StartDragParams) => { + setDragState({ + isDragging: true, + elementId, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + currentTime: initialCurrentTime, + currentMouseY: initialCurrentMouseY, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState(initialDragState); + setDragDropTarget(null); + }, []); + + const getDragSnapResult = useCallback( + ({ + frameSnappedTime, + movingElement, + }: { + frameSnappedTime: number; + movingElement: TimelineElement | null | undefined; + }) => { + if (!snappingEnabled || !movingElement) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const elementDuration = movingElement.duration; + const playheadTime = editor.playback.getCurrentTime(); + + const startSnap = snapElementEdge({ + targetTime: frameSnappedTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId: movingElement.id, + snapToStart: true, + }); + + const endSnap = snapElementEdge({ + targetTime: frameSnappedTime, + elementDuration, + tracks, + 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, + }; + }, + [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + let startedDragThisEvent = false; + const timeline = timelineRef.current; + const scrollContainer = tracksScrollRef.current; + if (!timeline || !scrollContainer) return; + lastMouseXRef.current = clientX; + + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); + if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { + const activeProject = editor.project.getActive(); + if (!activeProject) return; + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = snapTimeToFrame({ + time: adjustedTime, + fps: activeProject.settings.fps, + }); + startDrag({ + ...pendingDragRef.current, + initialCurrentTime: snappedTime, + initialCurrentMouseY: clientY, + }); + startedDragThisEvent = true; + pendingDragRef.current = null; + setIsPendingDrag(false); + } else { + return; + } + } + + if (startedDragThisEvent) { + return; + } + + if (dragState.elementId && dragState.trackId) { + const alreadySelected = isElementSelected({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + if (!alreadySelected) { + selectElement({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + } + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const fps = activeProject.settings.fps; + const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps }); + + const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === dragState.elementId, + ); + const { snappedTime, snapPoint } = getDragSnapResult({ + frameSnappedTime, + movingElement, + }); + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snappedTime, + currentMouseY: clientY, + })); + onSnapPointChange?.(snapPoint); + + if (dragState.elementId && dragState.trackId) { + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }); + const dropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, + }); + setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.startMouseY, + dragState.trackId, + zoomLevel, + isElementSelected, + selectElement, + editor.project, + timelineRef, + tracksScrollRef, + tracksContainerRef, + headerRef, + tracks, + isPendingDrag, + startDrag, + getDragSnapResult, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); + if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { + mouseDownLocationRef.current = null; + endDrag(); + onSnapPointChange?.(null); + return; + } + } + + const dropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime: dragState.currentTime, + verticalDragDirection: getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }), + }); + if (!dropTarget) { + endDrag(); + onSnapPointChange?.(null); + return; + } + const snappedTime = dragState.currentTime; + + const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); + if (!sourceTrack) { + 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 }, + }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: targetTrack.id, + elementId: dragState.elementId, + newStartTime: snappedTime, + }); + } + } + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.elementId, + dragState.startMouseY, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + endDrag, + onSnapPointChange, + editor.timeline, + tracksContainerRef, + tracksScrollRef, + headerRef, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleElementMouseDown = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + const isRightClick = event.button === 2; + + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: false, + }); + } + return; + } + + // left-click: stop propagation for drag operations + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + + // multi-select: toggle selection + if (isMultiSelect) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: true, + }); + } + + // start drag + const clickOffsetTime = getClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel, + }); + pendingDragRef.current = { + elementId: element.id, + trackId: track.id, + startMouseX: event.clientX, + startMouseY: event.clientY, + startElementTime: element.startTime, + clickOffsetTime, + }; + setIsPendingDrag(true); + }, + [zoomLevel, isElementSelected, handleSelectionClick], + ); + + const handleElementClick = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + event.stopPropagation(); + + // was it a drag or a click? + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); + if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { + mouseDownLocationRef.current = null; + return; + } + } + + // modifier keys already handled in mousedown + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + // single click: select if not selected + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected) { + selectElement({ trackId: track.id, elementId: element.id }); + } + }, + [isElementSelected, selectElement], + ); + + return { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + }; +} 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, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts new file mode 100644 index 00000000..38694c8b --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -0,0 +1,114 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/hooks/use-editor"; + +type ElementRef = { trackId: string; elementId: string }; + +export function useElementSelection() { + const editor = useEditor(); + const selectedElements = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedElements(), + ); + + const isElementSelected = useCallback( + ({ trackId, elementId }: ElementRef) => + selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ), + [selectedElements], + ); + + const selectElement = useCallback( + ({ trackId, elementId }: ElementRef) => { + editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] }); + }, + [editor], + ); + + const addElementToSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + const alreadySelected = selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ); + if (alreadySelected) return; + + editor.selection.setSelectedElements({ + elements: [...selectedElements, { trackId, elementId }], + }); + }, + [selectedElements, editor], + ); + + const removeElementFromSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + editor.selection.setSelectedElements({ + elements: selectedElements.filter( + (element) => + !(element.trackId === trackId && element.elementId === elementId), + ), + }); + }, + [selectedElements, editor], + ); + + const toggleElementSelection = useCallback( + ({ trackId, elementId }: ElementRef) => { + const alreadySelected = selectedElements.some( + (element) => + element.trackId === trackId && element.elementId === elementId, + ); + + if (alreadySelected) { + removeElementFromSelection({ trackId, elementId }); + } else { + addElementToSelection({ trackId, elementId }); + } + }, + [selectedElements, addElementToSelection, removeElementFromSelection], + ); + + const clearElementSelection = useCallback(() => { + editor.selection.clearSelection(); + }, [editor]); + + const setElementSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + editor.selection.setSelectedElements({ elements }); + }, + [editor], + ); + + /** + * Handles click interaction on an element. + * - Regular click: select only this element + * - Multi-key click (Ctrl/Cmd): toggle this element in selection + */ + const handleElementClick = useCallback( + ({ + trackId, + elementId, + isMultiKey, + }: ElementRef & { isMultiKey: boolean }) => { + if (isMultiKey) { + toggleElementSelection({ trackId, elementId }); + } else { + selectElement({ trackId, elementId }); + } + }, + [toggleElementSelection, selectElement], + ); + + return { + selectedElements, + isElementSelected, + selectElement, + setElementSelection, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, + clearElementSelection, + handleElementClick, + }; +} |
