diff options
Diffstat (limited to 'apps/web/src/hooks')
41 files changed, 3813 insertions, 2674 deletions
diff --git a/apps/web/src/hooks/actions/use-action-handler.ts b/apps/web/src/hooks/actions/use-action-handler.ts new file mode 100644 index 00000000..3f6dbd21 --- /dev/null +++ b/apps/web/src/hooks/actions/use-action-handler.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { + TAction, + TActionFunc, + TActionHandlerOptions, + TArgOfAction, + TInvocationTrigger, +} from "@/lib/actions"; +import { bindAction, unbindAction } from "@/lib/actions"; + +export function useActionHandler<A extends TAction>( + action: A, + handler: TActionFunc<A>, + isActive: TActionHandlerOptions, +) { + const handlerRef = useRef<TActionFunc<A>>(handler); + const isBoundRef = useRef(false); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + const stableHandler = useCallback( + (...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => { + ( + handlerRef.current as ( + ...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?] + ) => void + )(...parameters); + }, + [], + ) as TActionFunc<A>; + + useEffect(() => { + const shouldBind = + isActive === undefined || + (typeof isActive === "boolean" ? isActive : isActive.current); + + if (shouldBind && !isBoundRef.current) { + bindAction(action, stableHandler); + isBoundRef.current = true; + } else if (!shouldBind && isBoundRef.current) { + unbindAction(action, stableHandler); + isBoundRef.current = false; + } + + return () => { + unbindAction(action, stableHandler); + isBoundRef.current = false; + }; + }, [action, stableHandler, isActive]); +} diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts new file mode 100644 index 00000000..d1ff6c92 --- /dev/null +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -0,0 +1,312 @@ +"use client"; + +import { useTimelineStore } from "@/stores/timeline-store"; +import { useActionHandler } from "@/hooks/actions/use-action-handler"; +import { useEditor } from "../use-editor"; +import { useElementSelection } from "../timeline/element/use-element-selection"; +import { getElementsAtTime } from "@/lib/timeline"; + +export function useEditorActions() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { selectedElements, setElementSelection } = useElementSelection(); + const { clipboard, setClipboard, toggleSnapping } = useTimelineStore(); + + useActionHandler( + "toggle-play", + () => { + editor.playback.toggle(); + }, + undefined, + ); + + useActionHandler( + "stop-playback", + () => { + if (editor.playback.getIsPlaying()) { + editor.playback.toggle(); + } + editor.playback.seek({ time: 0 }); + }, + undefined, + ); + + useActionHandler( + "seek-forward", + (args) => { + const seconds = args?.seconds ?? 1; + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + seconds, + ), + }); + }, + undefined, + ); + + useActionHandler( + "seek-backward", + (args) => { + const seconds = args?.seconds ?? 1; + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - seconds), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-forward", + () => { + const fps = activeProject.settings.fps; + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + 1 / fps, + ), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-backward", + () => { + const fps = activeProject.settings.fps; + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps), + }); + }, + undefined, + ); + + useActionHandler( + "jump-forward", + (args) => { + const seconds = args?.seconds ?? 5; + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + seconds, + ), + }); + }, + undefined, + ); + + useActionHandler( + "jump-backward", + (args) => { + const seconds = args?.seconds ?? 5; + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - seconds), + }); + }, + undefined, + ); + + useActionHandler( + "goto-start", + () => { + editor.playback.seek({ time: 0 }); + }, + undefined, + ); + + useActionHandler( + "goto-end", + () => { + editor.playback.seek({ time: editor.timeline.getTotalDuration() }); + }, + undefined, + ); + + useActionHandler( + "split", + () => { + const currentTime = editor.playback.getCurrentTime(); + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks: editor.timeline.getTracks(), + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + }); + }, + undefined, + ); + + useActionHandler( + "split-left", + () => { + const currentTime = editor.playback.getCurrentTime(); + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks: editor.timeline.getTracks(), + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "right", + }); + }, + undefined, + ); + + useActionHandler( + "split-right", + () => { + const currentTime = editor.playback.getCurrentTime(); + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks: editor.timeline.getTracks(), + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "left", + }); + }, + undefined, + ); + + useActionHandler( + "delete-selected", + () => { + if (selectedElements.length === 0) { + return; + } + editor.timeline.deleteElements({ + elements: selectedElements, + }); + }, + undefined, + ); + + useActionHandler( + "select-all", + () => { + const allElements = editor.timeline.getTracks().flatMap((track) => + track.elements.map((element) => ({ + trackId: track.id, + elementId: element.id, + })), + ); + setElementSelection({ elements: allElements }); + }, + undefined, + ); + + useActionHandler( + "duplicate-selected", + () => { + editor.timeline.duplicateElements({ + elements: selectedElements, + }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-muted-selected", + () => { + editor.timeline.toggleElementsMuted({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-visibility-selected", + () => { + editor.timeline.toggleElementsVisibility({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-bookmark", + () => { + editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() }); + }, + undefined, + ); + + useActionHandler( + "copy-selected", + () => { + if (selectedElements.length === 0) return; + + const results = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + const items = results.map(({ track, element }) => { + const { ...elementWithoutId } = element; + return { + trackId: track.id, + trackType: track.type, + element: elementWithoutId, + }; + }); + + setClipboard({ items }); + }, + undefined, + ); + + useActionHandler( + "paste-copied", + () => { + if (!clipboard?.items.length) return; + + editor.timeline.pasteAtTime({ + time: editor.playback.getCurrentTime(), + clipboardItems: clipboard.items, + }); + }, + undefined, + ); + + useActionHandler( + "toggle-snapping", + () => { + toggleSnapping(); + }, + undefined, + ); + + useActionHandler( + "undo", + () => { + editor.command.undo(); + }, + undefined, + ); + + useActionHandler( + "redo", + () => { + editor.command.redo(); + }, + undefined, + ); +} diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts deleted file mode 100644 index b2c5ef98..00000000 --- a/apps/web/src/hooks/auth/useLogin.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback, useState } from "react"; -import { useRouter } from "next/navigation"; -import { signIn } from "@opencut/auth/client"; - -export function useLogin() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - - const handleLogin = useCallback(async () => { - setError(null); - setIsEmailLoading(true); - - const { error } = await signIn.email({ - email, - password, - }); - - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } - - router.push("/projects"); - }, [router, email, password]); - - const handleGoogleLogin = async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await signIn.social({ - provider: "google", - callbackURL: "/projects", - }); - } catch (error) { - setError("Failed to sign in with Google. Please try again."); - setIsGoogleLoading(false); - } - }; - - const isAnyLoading = isEmailLoading || isGoogleLoading; - - return { - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleLogin, - handleGoogleLogin, - }; -} diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts deleted file mode 100644 index b6039f85..00000000 --- a/apps/web/src/hooks/auth/useSignUp.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import { signUp, signIn } from "@opencut/auth/client"; - -export function useSignUp() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - - const handleSignUp = useCallback(async () => { - setError(null); - setIsEmailLoading(true); - - const { error } = await signUp.email({ - name, - email, - password, - }); - - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } - - router.push("/login"); - }, [name, email, password, router]); - - const handleGoogleSignUp = useCallback(async () => { - setError(null); - setIsGoogleLoading(true); - - try { - await signIn.social({ - provider: "google", - }); - - router.push("/editor"); - } catch (error) { - setError("Failed to sign up with Google. Please try again."); - setIsGoogleLoading(false); - } - }, [router]); - - const isAnyLoading = isEmailLoading || isGoogleLoading; - - return { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleSignUp, - handleGoogleSignUp, - }; -} diff --git a/apps/web/src/hooks/storage/use-local-storage.ts b/apps/web/src/hooks/storage/use-local-storage.ts new file mode 100644 index 00000000..d0188ab3 --- /dev/null +++ b/apps/web/src/hooks/storage/use-local-storage.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback, useRef } from "react"; + +export function useLocalStorage<T>({ + key, + defaultValue, +}: { + key: string; + defaultValue: T; +}): [ + T, + ({ value }: { value: T | ((previousValue: T) => T) }) => void, + boolean, +] { + const [value, setValue] = useState<T>(defaultValue); + const [isReady, setIsReady] = useState(false); + const valueRef = useRef(defaultValue); + + // avoid hydration mismatch by reading after mount + useEffect(() => { + try { + const storedValue = localStorage.getItem(key); + if (storedValue !== null) { + const parsedValue = JSON.parse(storedValue) as T; + valueRef.current = parsedValue; + setValue(parsedValue); + } + } catch { + // localstorage might be unavailable + } + setIsReady(true); + }, [key]); + + // sync to localstorage after hydration + useEffect(() => { + if (!isReady) return; + + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // localstorage might be full or disabled + } + }, [key, value, isReady]); + + const setValueWithCallback = useCallback( + ({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => { + const resolvedValue = + typeof nextValue === "function" + ? (nextValue as (previousValue: T) => T)(valueRef.current) + : nextValue; + + valueRef.current = resolvedValue; + setValue(resolvedValue); + }, + [], + ); + + return [value, setValueWithCallback, isReady]; +} 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, + }; +} diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts new file mode 100644 index 00000000..0ddab654 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef } from "react"; + +interface UseEdgeAutoScrollParams { + isActive: boolean; + getMouseClientX: () => number; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + contentWidth: number; + edgeThreshold?: number; + maxScrollSpeed?: number; +} + +export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, +}: UseEdgeAutoScrollParams): void { + const rafRef = useRef<number | null>(null); + + useEffect(() => { + if (!isActive) { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + const step = () => { + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) { + rafRef.current = requestAnimationFrame(step); + return; + } + + const viewportRect = rulerViewport.getBoundingClientRect(); + const mouseX = getMouseClientX(); + const mouseXRelative = mouseX - viewportRect.left; + + const viewportWidth = rulerViewport.clientWidth; + const intrinsicContentWidth = rulerViewport.scrollWidth; + const effectiveContentWidth = Math.max( + contentWidth, + intrinsicContentWidth, + ); + const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth); + + let scrollSpeed = 0; + + if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) { + const edgeDistance = Math.max(0, mouseXRelative); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = -maxScrollSpeed * intensity; + } else if ( + mouseXRelative > viewportWidth - edgeThreshold && + rulerViewport.scrollLeft < scrollMax + ) { + const edgeDistance = Math.max( + 0, + viewportWidth - edgeThreshold - mouseXRelative, + ); + const intensity = 1 - edgeDistance / edgeThreshold; + scrollSpeed = maxScrollSpeed * intensity; + } + + if (scrollSpeed !== 0) { + const newScrollLeft = Math.max( + 0, + Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed), + ); + rulerViewport.scrollLeft = newScrollLeft; + tracksViewport.scrollLeft = newScrollLeft; + } + + rafRef.current = requestAnimationFrame(step); + }; + + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold, + maxScrollSpeed, + ]); +} diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts new file mode 100644 index 00000000..cdd915b0 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react"; + +interface UseScrollSyncProps { + rulerScrollRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + trackLabelsScrollRef?: React.RefObject<HTMLDivElement>; + bookmarksScrollRef?: React.RefObject<HTMLDivElement>; +} + +export function useScrollSync({ + tracksScrollRef, + rulerScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, +}: UseScrollSyncProps) { + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); + const lastBookmarksSync = useRef(0); + + useEffect(() => { + const rulerViewport = rulerScrollRef?.current ?? null; + const tracksViewport = tracksScrollRef.current; + const trackLabelsViewport = trackLabelsScrollRef?.current; + const bookmarksViewport = bookmarksScrollRef?.current; + let handleBookmarksScroll: (() => void) | null = null; + + if (!tracksViewport) return; + + const syncScrollLeft = ({ + target, + scrollLeft, + }: { + target: HTMLDivElement | null | undefined; + scrollLeft: number; + }) => { + if (target) { + target.scrollLeft = scrollLeft; + } + }; + + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0; + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: rulerViewport?.scrollLeft ?? 0, + }); + isUpdatingRef.current = false; + }; + + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + syncScrollLeft({ + target: rulerViewport, + scrollLeft: tracksViewport.scrollLeft, + }); + syncScrollLeft({ + target: bookmarksViewport, + scrollLeft: tracksViewport.scrollLeft, + }); + isUpdatingRef.current = false; + }; + + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.addEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.addEventListener("scroll", handleTracksScroll); + } + + if (bookmarksViewport) { + handleBookmarksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastBookmarksSync.current < 16) + return; + lastBookmarksSync.current = now; + isUpdatingRef.current = true; + const nextScrollLeft = bookmarksViewport.scrollLeft; + tracksViewport.scrollLeft = nextScrollLeft; + syncScrollLeft({ + target: rulerViewport, + scrollLeft: nextScrollLeft, + }); + isUpdatingRef.current = false; + }; + + if (bookmarksViewport !== tracksViewport) { + bookmarksViewport.addEventListener("scroll", handleBookmarksScroll); + } + } + + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { + bookmarksViewport.removeEventListener( + "scroll", + handleBookmarksScroll, + ); + } + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll, + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll, + ); + }; + } + + return () => { + if (rulerViewport && rulerViewport !== tracksViewport) { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + } + if (tracksViewport !== rulerViewport) { + tracksViewport.removeEventListener("scroll", handleTracksScroll); + } + if ( + bookmarksViewport && + bookmarksViewport !== tracksViewport && + handleBookmarksScroll + ) { + bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll); + } + }; + }, [ + rulerScrollRef, + tracksScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, + ]); +} diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts new file mode 100644 index 00000000..702abad9 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-selection-box.ts @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline"; +import { useEditor } from "../use-editor"; + +interface UseSelectionBoxProps { + containerRef: React.RefObject<HTMLElement>; + onSelectionComplete: ( + elements: { trackId: string; elementId: string }[], + ) => void; + isEnabled?: boolean; + tracksScrollRef: React.RefObject<HTMLDivElement>; + zoomLevel: number; +} + +interface SelectionBoxState { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; +} + +interface SelectionRectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +function getNormalizedRectangle({ + startPos, + endPos, +}: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + return { + left: Math.min(startPos.x, endPos.x), + top: Math.min(startPos.y, endPos.y), + right: Math.max(startPos.x, endPos.x), + bottom: Math.max(startPos.y, endPos.y), + }; +} + +function getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + const containerRect = container.getBoundingClientRect(); + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + + const adjustedStart = { + x: startPos.x - containerRect.left + scrollLeft, + y: startPos.y - containerRect.top + scrollTop, + }; + const adjustedEnd = { + x: endPos.x - containerRect.left + scrollLeft, + y: endPos.y - containerRect.top + scrollTop, + }; + + return getNormalizedRectangle({ + startPos: adjustedStart, + endPos: adjustedEnd, + }); +} + +function isRectangleIntersecting({ + elementRectangle, + selectionRectangle, +}: { + elementRectangle: SelectionRectangle; + selectionRectangle: SelectionRectangle; +}): boolean { + return !( + elementRectangle.right < selectionRectangle.left || + elementRectangle.left > selectionRectangle.right || + elementRectangle.bottom < selectionRectangle.top || + elementRectangle.top > selectionRectangle.bottom + ); +} + +export function useSelectionBox({ + containerRef, + onSelectionComplete, + isEnabled = true, + tracksScrollRef, + zoomLevel, +}: UseSelectionBoxProps) { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>( + null, + ); + const justFinishedSelectingRef = useRef(false); + + const handleMouseDown = useCallback( + ({ clientX, clientY }: React.MouseEvent) => { + if (!isEnabled) return; + + setSelectionBox({ + startPos: { x: clientX, y: clientY }, + currentPos: { x: clientX, y: clientY }, + isActive: false, + }); + }, + [isEnabled], + ); + + const selectElementsInBox = useCallback( + ({ + startPos, + endPos, + }: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; + }) => { + if (!containerRef.current) return; + + const container = containerRef.current; + const selectionRectangle = getSelectionRectangleInContent({ + container, + scrollContainer: tracksScrollRef.current, + startPos, + endPos, + }); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const selectedElements: { trackId: string; elementId: string }[] = []; + + for (const [trackIndex, track] of tracks.entries()) { + const trackTop = getCumulativeHeightBefore({ + tracks, + trackIndex, + }); + const trackHeight = getTrackHeight({ type: track.type }); + const elementTop = trackTop; + const elementBottom = trackTop + trackHeight; + + for (const element of track.elements) { + const elementLeft = element.startTime * pixelsPerSecond; + const elementRight = elementLeft + element.duration * pixelsPerSecond; + + const elementRectangle = { + left: elementLeft, + top: elementTop, + right: elementRight, + bottom: elementBottom, + }; + + const intersects = isRectangleIntersecting({ + elementRectangle, + selectionRectangle, + }); + + if (intersects) { + selectedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + onSelectionComplete(selectedElements); + }, + [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel], + ); + + useEffect(() => { + if (!selectionBox) return; + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const deltaX = Math.abs(clientX - selectionBox.startPos.x); + const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const shouldActivate = deltaX > 5 || deltaY > 5; + + const newSelectionBox = { + ...selectionBox, + currentPos: { x: clientX, y: clientY }, + isActive: shouldActivate || selectionBox.isActive, + }; + + setSelectionBox(newSelectionBox); + + if (newSelectionBox.isActive) { + selectElementsInBox({ + startPos: newSelectionBox.startPos, + endPos: newSelectionBox.currentPos, + }); + } + }; + + const handleMouseUp = () => { + if (selectionBox?.isActive) { + justFinishedSelectingRef.current = true; + requestAnimationFrame(() => { + justFinishedSelectingRef.current = false; + }); + } + setSelectionBox(null); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [selectionBox, selectElementsInBox]); + + useEffect(() => { + if (!selectionBox) return; + + const previousBodyUserSelect = document.body.style.userSelect; + const container = containerRef.current; + const previousContainerUserSelect = container?.style.userSelect ?? ""; + + document.body.style.userSelect = "none"; + if (container) container.style.userSelect = "none"; + + return () => { + document.body.style.userSelect = previousBodyUserSelect; + if (container) container.style.userSelect = previousContainerUserSelect; + }; + }, [selectionBox, containerRef]); + + const shouldIgnoreClick = useCallback(() => { + return justFinishedSelectingRef.current; + }, []); + + return { + selectionBox, + handleMouseDown, + isSelecting: selectionBox?.isActive || false, + shouldIgnoreClick, + }; +} diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts new file mode 100644 index 00000000..c6ecbf7c --- /dev/null +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { TimelineTrack } from "@/types/timeline"; + +interface UseSnapIndicatorPositionParams { + snapPoint: { time: number } | null; + zoomLevel: number; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; +} + +interface SnapIndicatorPosition { + leftPosition: number; + topPosition: number; + height: number; +} + +export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, +}: UseSnapIndicatorPositionParams): SnapIndicatorPosition { + const [scrollLeft, setScrollLeft] = useState(0); + + useEffect(() => { + const tracksViewport = tracksScrollRef.current; + + if (!tracksViewport) return; + + const handleScroll = () => { + setScrollLeft(tracksViewport.scrollLeft); + }; + + setScrollLeft(tracksViewport.scrollLeft); + + tracksViewport.addEventListener("scroll", handleScroll); + return () => tracksViewport.removeEventListener("scroll", handleScroll); + }, [tracksScrollRef]); + + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; + + const timelinePosition = + (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; + + return { + leftPosition, + topPosition: 0, + height: totalHeight, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts new file mode 100644 index 00000000..01af9362 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -0,0 +1,460 @@ +import { useState, useCallback, type RefObject } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { toast } from "sonner"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { + buildTextElement, + buildStickerElement, + buildUploadAudioElement, + buildVideoElement, + buildImageElement, +} from "@/lib/timeline/element-utils"; +import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { getDragData, hasDragData } from "@/lib/drag-data"; +import type { TrackType, DropTarget, ElementType } from "@/types/timeline"; +import type { MediaDragData, StickerDragData } from "@/types/drag"; + +interface UseTimelineDragDropProps { + containerRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + zoomLevel, +}: UseTimelineDragDropProps) { + const editor = useEditor(); + const [isDragOver, setIsDragOver] = useState(false); + const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); + const [dragElementType, setElementType] = useState<ElementType | null>(null); + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const getSnappedTime = useCallback( + ({ time }: { time: number }) => { + const projectFps = activeProject.settings.fps; + return snapTimeToFrame({ time, fps: projectFps }); + }, + [activeProject.settings.fps], + ); + + const getElementType = useCallback( + ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { + const dragData = getDragData({ dataTransfer }); + if (!dragData) return null; + + if (dragData.type === "text") return "text"; + if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "media") { + return dragData.mediaType; + } + return null; + }, + [], + ); + + const getElementDuration = useCallback( + ({ + elementType, + mediaId, + }: { + elementType: ElementType; + mediaId?: string; + }): number => { + if (elementType === "text" || elementType === "sticker") { + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + if (mediaId) { + const media = mediaAssets.find((m) => m.id === mediaId); + return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + }, + [mediaAssets], + ); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); + const hasFiles = e.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + setIsDragOver(true); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const hasFiles = e.dataTransfer.types.includes("Files"); + const isExternal = + hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); + + const elementType = getElementType({ dataTransfer: e.dataTransfer }); + + if (!elementType && hasFiles && isExternal) { + setDropTarget(null); + setElementType(null); + return; + } + + if (!elementType) return; + + setElementType(elementType); + + const dragData = getDragData({ dataTransfer: e.dataTransfer }); + const duration = getElementDuration({ + elementType, + mediaId: dragData?.type === "media" ? dragData.id : undefined, + }); + + const mouseX = e.clientX - rect.left; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + + const target = computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime: currentTime, + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + target.xPosition = getSnappedTime({ time: target.xPosition }); + + setDropTarget(target); + e.dataTransfer.dropEffect = "copy"; + }, + [ + containerRef, + headerRef, + tracks, + currentTime, + zoomLevel, + getElementType, + getElementDuration, + getSnappedTime, + ], + ); + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + const { clientX, clientY } = e; + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + setIsDragOver(false); + setDropTarget(null); + setElementType(null); + } + } + }, + [containerRef], + ); + + const executeTextDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: { name?: string; content?: string }; + }) => { + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "text", + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const element = buildTextElement({ + raw: { + name: dragData.name ?? "", + content: dragData.content ?? "", + }, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor.timeline, tracks], + ); + + const executeStickerDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: StickerDragData; + }) => { + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: "sticker", + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const element = buildStickerElement({ + iconName: dragData.iconName, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor.timeline, tracks], + ); + + const executeMediaDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + let trackId: string; + + if (target.isNewTrack) { + trackId = editor.timeline.addTrack({ + type: trackType, + index: target.trackIndex, + }); + } else { + const track = tracks[target.trackIndex]; + if (!track) return; + trackId = track.id; + } + + const duration = + mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + + if (dragData.mediaType === "audio") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildUploadAudioElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } else if (dragData.mediaType === "video") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildVideoElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } else { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildImageElement({ + mediaId: mediaAsset.id, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }), + }); + } + }, + [editor.timeline, mediaAssets, tracks], + ); + + const executeFileDrop = useCallback( + async ({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }) => { + if (!activeProject) return; + + const processedAssets = await processMediaAssets({ files }); + + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + + const added = editor.media + .getAssets() + .find((m) => m.name === asset.name && m.url === asset.url); + + if (added) { + const duration = + added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const dropTarget = computeDropTarget({ + elementType: added.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + const trackType: TrackType = + added.type === "audio" ? "audio" : "video"; + const trackId = dropTarget.isNewTrack + ? editor.timeline.addTrack({ + type: trackType, + index: dropTarget.trackIndex, + }) + : currentTracks[dropTarget.trackIndex]?.id; + + if (!trackId) return; + + if (added.type === "audio") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildUploadAudioElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }), + }), + }); + } else if (added.type === "video") { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildVideoElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + }), + }); + } else { + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element: buildImageElement({ + mediaId: added.id, + name: added.name, + duration, + startTime: dropTarget.xPosition, + }), + }); + } + } + } + }, + [activeProject, editor.media, editor.timeline, currentTime, zoomLevel], + ); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + + const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); + const hasFiles = e.dataTransfer.files?.length > 0; + + if (!hasAsset && !hasFiles) return; + + const currentTarget = dropTarget; + setIsDragOver(false); + setDropTarget(null); + setElementType(null); + + try { + if (hasAsset) { + if (!currentTarget) return; + const dragData = getDragData({ dataTransfer: e.dataTransfer }); + if (!dragData) return; + + if (dragData.type === "text") { + executeTextDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "sticker") { + executeStickerDrop({ target: currentTarget, dragData }); + } else { + executeMediaDrop({ target: currentTarget, dragData }); + } + } else if (hasFiles) { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const mouseX = e.clientX - rect.left; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseY = Math.max(0, e.clientY - rect.top - headerHeight); + await executeFileDrop({ + files: Array.from(e.dataTransfer.files), + mouseX, + mouseY, + }); + } + } catch (err) { + console.error("Failed to process drop:", err); + toast.error("Failed to process drop"); + } + }, + [ + dropTarget, + executeTextDrop, + executeStickerDrop, + executeMediaDrop, + executeFileDrop, + containerRef, + headerRef, + ], + ); + + return { + isDragOver, + dropTarget, + dragElementType, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts new file mode 100644 index 00000000..55e04a68 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -0,0 +1,218 @@ +import { getSnappedSeekTime } from "@/lib/time"; +import { useState, useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import { useEditor } from "../use-editor"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +interface UseTimelinePlayheadProps { + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; +} + +export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const currentTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + const isPlaying = editor.playback.getIsPlaying(); + + const seek = useCallback( + ({ time }: { time: number }) => editor.playback.seek({ time }), + [editor.playback], + ); + + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState<number | null>(null); + + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + const lastMouseXRef = useRef<number>(0); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + const handleScrub = useCallback( + ({ event }: { event: MouseEvent | React.MouseEvent }) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rulerRect = ruler.getBoundingClientRect(); + const relativeMouseX = event.clientX - rulerRect.left; + + const timelineContentWidth = + duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const clampedMouseX = Math.max( + 0, + Math.min(timelineContentWidth, relativeMouseX), + ); + + const rawTime = Math.max( + 0, + Math.min( + duration, + clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + const framesPerSecond = activeProject.settings.fps; + const time = getSnappedSeekTime({ + rawTime, + duration, + fps: framesPerSecond, + }); + + setScrubTime(time); + seek({ time }); + + lastMouseXRef.current = event.clientX; + }, + [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps], + ); + + const handlePlayheadMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + setIsScrubbing(true); + handleScrub({ event }); + }, + [handleScrub], + ); + + const handleRulerMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + if (event.button !== 0) return; + + if (playheadRef?.current?.contains(event.target as Node)) return; + + event.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + setIsScrubbing(true); + handleScrub({ event }); + }, + [handleScrub, playheadRef], + ); + + const handlePlayheadMouseDownEvent = useCallback( + (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), + [handlePlayheadMouseDown], + ); + + const handleRulerMouseDownEvent = useCallback( + (event: React.MouseEvent) => handleRulerMouseDown({ event }), + [handleRulerMouseDown], + ); + + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => lastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); + + useEffect(() => { + if (!isScrubbing) return; + + const handleMouseMove = ({ event }: { event: MouseEvent }) => { + handleScrub({ event }); + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + + const handleMouseUp = ({ event }: { event: MouseEvent }) => { + setIsScrubbing(false); + if (scrubTime !== null) { + seek({ time: scrubTime }); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, + playheadTime: scrubTime, + }, + }); + } + setScrubTime(null); + + if (isDraggingRuler) { + setIsDraggingRuler(false); + if (!hasDraggedRuler) { + handleScrub({ event }); + } + setHasDraggedRuler(false); + } + }; + + const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); + const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + editor, + tracksScrollRef, + zoomLevel, + ]); + + useEffect(() => { + if (!isPlaying || isScrubbing) return; + + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const viewportWidth = rulerViewport.clientWidth; + const scrollMinimum = 0; + const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; + + const needsScroll = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (needsScroll) { + const desiredScroll = Math.max( + scrollMinimum, + Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [ + playheadPosition, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + isScrubbing, + isPlaying, + ]); + + return { + playheadPosition, + handlePlayheadMouseDown: handlePlayheadMouseDownEvent, + handleRulerMouseDown: handleRulerMouseDownEvent, + isDraggingRuler, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts new file mode 100644 index 00000000..f5dc6ddb --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts @@ -0,0 +1,197 @@ +import { useCallback, useRef } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getSnappedSeekTime } from "@/lib/time"; +import { useEditor } from "../use-editor"; + +interface UseTimelineSeekProps { + playheadRef: RefObject<HTMLDivElement>; + trackLabelsRef: RefObject<HTMLDivElement>; + rulerScrollRef: RefObject<HTMLDivElement>; + tracksScrollRef: RefObject<HTMLDivElement>; + zoomLevel: number; + duration: number; + isSelecting: boolean; + clearSelectedElements: () => void; + seek: (time: number) => void; +} + +function resetMouseTracking({ + mouseTrackingRef, +}: { + mouseTrackingRef: MutableRefObject<{ + isMouseDown: boolean; + downX: number; + downY: number; + downTime: number; + }>; +}) { + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; +} + +function setMouseTracking({ + mouseTrackingRef, + event, +}: { + mouseTrackingRef: MutableRefObject<{ + isMouseDown: boolean; + downX: number; + downY: number; + downTime: number; + }>; + event: React.MouseEvent; +}) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; +} + +export function useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration, + isSelecting, + clearSelectedElements, + seek, +}: UseTimelineSeekProps) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); + + const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { + if (event.button !== 0) return; + setMouseTracking({ mouseTrackingRef, event }); + }, []); + + const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { + if (event.button !== 0) return; + setMouseTracking({ mouseTrackingRef, event }); + }, []); + + const shouldProcessTimelineClick = useCallback( + ({ event }: { event: React.MouseEvent }) => { + const target = event.target as HTMLElement; + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + const deltaX = Math.abs(event.clientX - downX); + const deltaY = Math.abs(event.clientY - downY); + const deltaTime = event.timeStamp - downTime; + const isPlayhead = !!playheadRef.current?.contains(target); + const isTrackLabels = !!trackLabelsRef.current?.contains(target); + const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; + + if (!isMouseDown) return false; + if (shouldBlockForDrag) return false; + if (isSelecting) return false; + if (isPlayhead) return false; + if (isTrackLabels) { + clearSelectedElements(); + return false; + } + + return true; + }, + [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], + ); + + const handleTimelineSeek = useCallback( + ({ + event, + source, + }: { + event: React.MouseEvent; + source: "ruler" | "tracks"; + }) => { + const scrollContainer = + source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; + + if (!scrollContainer) return; + + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + + const projectFps = activeProject?.settings.fps || 30; + const time = getSnappedSeekTime({ + rawTime, + duration, + fps: projectFps, + }); + seek(time); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }, + }); + }, + [ + duration, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + seek, + editor, + activeProject?.settings.fps, + ], + ); + + const handleTracksClick = useCallback( + (event: React.MouseEvent) => { + const shouldProcess = shouldProcessTimelineClick({ event }); + resetMouseTracking({ mouseTrackingRef }); + + if (shouldProcess) { + clearSelectedElements(); + handleTimelineSeek({ event, source: "tracks" }); + } + }, + [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], + ); + + const handleRulerClick = useCallback( + (event: React.MouseEvent) => { + const shouldProcess = shouldProcessTimelineClick({ event }); + resetMouseTracking({ mouseTrackingRef }); + + if (shouldProcess) { + clearSelectedElements(); + handleTimelineSeek({ event, source: "ruler" }); + } + }, + [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], + ); + + return { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts new file mode 100644 index 00000000..4cd6f775 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts @@ -0,0 +1,159 @@ +import { useCallback } from "react"; +import type { TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export interface SnapPoint { + time: number; + type: "element-start" | "element-end" | "playhead"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +export interface UseTimelineSnappingOptions { + snapThreshold?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; +} + +export function useTimelineSnapping({ + snapThreshold = 10, + enableElementSnapping = true, + enablePlayheadSnapping = true, +}: UseTimelineSnappingOptions = {}) { + const findSnapPoints = useCallback( + ({ + tracks, + playheadTime, + excludeElementId, + }: { + tracks: Array<TimelineTrack>; + playheadTime: number; + excludeElementId?: string; + }): SnapPoint[] => { + const snapPoints: SnapPoint[] = []; + + if (enableElementSnapping) { + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + const elementStart = element.startTime; + const elementEnd = element.startTime + element.duration; + + snapPoints.push( + { + time: elementStart, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: elementEnd, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + } + } + + if (enablePlayheadSnapping) { + snapPoints.push({ + time: playheadTime, + type: "playhead", + }); + } + + return snapPoints; + }, + [enableElementSnapping, enablePlayheadSnapping], + ); + + const snapToNearestPoint = useCallback( + ({ + targetTime, + snapPoints, + zoomLevel, + }: { + targetTime: number; + snapPoints: Array<SnapPoint>; + zoomLevel: number; + }): SnapResult => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; + }, + [snapThreshold], + ); + + const snapElementEdge = useCallback( + ({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + }: { + targetTime: number; + elementDuration: number; + tracks: Array<TimelineTrack>; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + }): SnapResult => { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + }); + + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + const snapResult = snapToNearestPoint({ + targetTime: effectiveTargetTime, + snapPoints, + zoomLevel, + }); + + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; + }, + [findSnapPoints, snapToNearestPoint], + ); + + return { + snapElementEdge, + findSnapPoints, + snapToNearestPoint, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts new file mode 100644 index 00000000..96a4be43 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -0,0 +1,224 @@ +import { + type WheelEvent as ReactWheelEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; + +interface UseTimelineZoomProps { + containerRef: RefObject<HTMLDivElement>; + minZoom?: number; + initialZoom?: number; + initialScrollLeft?: number; + initialPlayheadTime?: number; + tracksScrollRef: RefObject<HTMLDivElement>; + rulerScrollRef: RefObject<HTMLDivElement>; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (event: ReactWheelEvent) => void; + saveScrollPosition: () => void; +} + +export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + rulerScrollRef, +}: UseTimelineZoomProps): UseTimelineZoomReturn { + const editor = useEditor(); + const hasInitializedRef = useRef(false); + const hasRestoredPlayheadRef = useRef(false); + const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( + null, + ); + + const [zoomLevel, setZoomLevel] = useState(() => { + if (initialZoom !== undefined) { + hasInitializedRef.current = true; + return Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom), + ); + } + return minZoom; + }); + const previousZoomRef = useRef(zoomLevel); + const hasRestoredScrollRef = useRef(false); + + const handleWheel = useCallback( + (event: ReactWheelEvent) => { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + // pinch-zoom (ctrl/meta + wheel) + if (isZoomGesture) { + const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1; + setZoomLevel((prev) => { + const nextZoom = Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier), + ); + return nextZoom; + }); + // for horizontal scrolling (when shift is held or horizontal wheel movement), + // let the event bubble up to allow ScrollArea to handle it + return; + } + }, + [minZoom], + ); + + useEffect(() => { + if (initialZoom !== undefined && !hasInitializedRef.current) { + hasInitializedRef.current = true; + setZoomLevel( + Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)), + ); + return; + } + setZoomLevel((prev) => { + if (prev < minZoom) { + return minZoom; + } + return prev; + }); + }, [minZoom, initialZoom]); + + const wrappedSetZoomLevel = useCallback( + (zoomLevelOrUpdater: number | ((prev: number) => number)) => { + setZoomLevel((prev) => { + const nextZoom = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(prev) + : zoomLevelOrUpdater; + const clampedZoom = Math.max( + minZoom, + Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom), + ); + return clampedZoom; + }); + }, + [minZoom], + ); + + useLayoutEffect(() => { + const previousZoom = previousZoomRef.current; + if (previousZoom === zoomLevel) return; + + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), + }, + }); + } + + previousZoomRef.current = zoomLevel; + }, [zoomLevel, editor, tracksScrollRef]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref + const saveScrollPosition = useCallback(() => { + if (scrollSaveTimeoutRef.current) { + clearTimeout(scrollSaveTimeoutRef.current); + } + scrollSaveTimeoutRef.current = setTimeout(() => { + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), + }, + }); + } + }, 300); + }, [zoomLevel, editor]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable + useEffect(() => { + if (initialScrollLeft === undefined) return; + if (hasRestoredScrollRef.current) return; + const scrollElement = tracksScrollRef.current; + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = initialScrollLeft; + } + hasRestoredScrollRef.current = true; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + } else { + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + hasRestoredScrollRef.current = true; + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + }, [initialScrollLeft]); + + useEffect(() => { + if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { + hasRestoredPlayheadRef.current = true; + editor.playback.seek({ time: initialPlayheadTime }); + } + }, [initialPlayheadTime, editor]); + + // prevent browser zoom in the timeline + useEffect(() => { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const isInContainer = containerRef.current?.contains( + event.target as Node, + ); + // only check isInContainer, not isInTimeline state - the state check + // causes race conditions where the closure captures stale state + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + }, [containerRef]); + + return { + zoomLevel, + setZoomLevel: wrappedSetZoomLevel, + handleWheel, + saveScrollPosition, + }; +} diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts deleted file mode 100644 index b721b74e..00000000 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useEditorStore } from "@/stores/editor-store"; -import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store"; - -export function useAspectRatio() { - const { canvasPresets } = useEditorStore(); - const { activeProject } = useProjectStore(); - const { mediaFiles } = useMediaStore(); - const { tracks } = useTimelineStore(); - - const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; - const canvasMode = activeProject?.canvasMode || "preset"; - - // Find the current preset based on canvas size - const currentPreset = canvasPresets.find( - (preset) => - preset.width === canvasSize.width && preset.height === canvasSize.height - ); - - // Get the original aspect ratio from the first video/image in timeline - const getOriginalAspectRatio = (): number => { - // Find first video or image in timeline - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "media") { - const mediaFile = mediaFiles.find( - (file) => file.id === element.mediaId - ); - if ( - mediaFile && - (mediaFile.type === "video" || mediaFile.type === "image") - ) { - return getMediaAspectRatio(mediaFile); - } - } - } - } - return 16 / 9; // Default aspect ratio - }; - - // Get current aspect ratio - const getCurrentAspectRatio = (): number => { - return canvasSize.width / canvasSize.height; - }; - - // Format aspect ratio as a readable string - const formatAspectRatio = (aspectRatio: number): string => { - // Check if it matches a common aspect ratio - const ratios = [ - { ratio: 16 / 9, label: "16:9" }, - { ratio: 9 / 16, label: "9:16" }, - { ratio: 1, label: "1:1" }, - { ratio: 4 / 3, label: "4:3" }, - { ratio: 3 / 4, label: "3:4" }, - { ratio: 21 / 9, label: "21:9" }, - ]; - - for (const { ratio, label } of ratios) { - if (Math.abs(aspectRatio - ratio) < 0.01) { - return label; - } - } - - // If not a common ratio, format as decimal - return aspectRatio.toFixed(2); - }; - - // Check if current mode is "Original" - const isOriginal = canvasMode === "original"; - - // Get display name for current aspect ratio - const getDisplayName = (): string => { - // If explicitly set to original mode, always show "Original" - if (canvasMode === "original") { - return "Original"; - } - - if (currentPreset) { - return currentPreset.name; - } - - return formatAspectRatio(getCurrentAspectRatio()); - }; - - return { - currentPreset, - canvasMode, - isOriginal, - getCurrentAspectRatio, - getOriginalAspectRatio, - formatAspectRatio, - getDisplayName, - canvasSize, - canvasPresets, - }; -} diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts deleted file mode 100644 index 0b1d4008..00000000 --- a/apps/web/src/hooks/use-drag-drop.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useState, useRef } from "react"; - -interface UseDragDropOptions { - onDrop?: (files: FileList) => void; -} - -// Helper function to check if drag contains files from external sources (not internal app drags) -const containsFiles = (dataTransfer: DataTransfer): boolean => { - // Check if this is an internal app drag (media item) - if (dataTransfer.types.includes("application/x-media-item")) { - return false; - } - - // Only show overlay for external file drags - return dataTransfer.types.includes("Files"); -}; - -export function useDragDrop(options: UseDragDropOptions = {}) { - const [isDragOver, setIsDragOver] = useState(false); - const dragCounterRef = useRef(0); - - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle external file drags, not internal app element drags - if (!containsFiles(e.dataTransfer)) { - return; - } - - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle file drags - if (!containsFiles(e.dataTransfer)) { - return; - } - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle file drags - if (!containsFiles(e.dataTransfer)) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Only handle file drops - if ( - options.onDrop && - e.dataTransfer.files && - containsFiles(e.dataTransfer) - ) { - options.onDrop(e.dataTransfer.files); - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - return { - isDragOver, - dragProps, - }; -} diff --git a/apps/web/src/hooks/use-edge-auto-scroll.ts b/apps/web/src/hooks/use-edge-auto-scroll.ts deleted file mode 100644 index d3cf8127..00000000 --- a/apps/web/src/hooks/use-edge-auto-scroll.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-// Provides smooth edge auto-scrolling for horizontal timeline interactions.
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef<number | null>(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(
- 0,
- viewportWidth - edgeThreshold - mouseXRelative
- );
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = maxScrollSpeed * intensity;
- }
-
- if (scrollSpeed !== 0) {
- const newScrollLeft = Math.max(
- 0,
- Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed)
- );
- rulerViewport.scrollLeft = newScrollLeft;
- tracksViewport.scrollLeft = newScrollLeft;
- }
-
- rafRef.current = requestAnimationFrame(step);
- };
-
- rafRef.current = requestAnimationFrame(step);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- };
- }, [
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold,
- maxScrollSpeed,
- ]);
-}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts deleted file mode 100644 index 9287d2e0..00000000 --- a/apps/web/src/hooks/use-editor-actions.ts +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useActionHandler } from "@/constants/actions"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; -import { toast } from "sonner"; - -export function useEditorActions() { - const { - tracks, - selectedElements, - clearSelectedElements, - setSelectedElements, - deleteSelected, - splitSelected, - addElementToTrack, - snappingEnabled, - toggleSnapping, - undo, - redo, - } = useTimelineStore(); - - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { activeProject } = useProjectStore(); - - // Playback actions - useActionHandler( - "toggle-play", - () => { - toggle(); - }, - undefined - ); - - useActionHandler( - "stop-playback", - () => { - if (isPlaying) { - toggle(); - } - seek(0); - }, - undefined - ); - - useActionHandler( - "seek-forward", - (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.min(duration, currentTime + seconds)); - }, - undefined - ); - - useActionHandler( - "seek-backward", - (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.max(0, currentTime - seconds)); - }, - undefined - ); - - useActionHandler( - "frame-step-forward", - () => { - const projectFps = activeProject?.fps || DEFAULT_FPS; - seek(Math.min(duration, currentTime + 1 / projectFps)); - }, - undefined - ); - - useActionHandler( - "frame-step-backward", - () => { - const projectFps = activeProject?.fps || DEFAULT_FPS; - seek(Math.max(0, currentTime - 1 / projectFps)); - }, - undefined - ); - - useActionHandler( - "jump-forward", - (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.min(duration, currentTime + seconds)); - }, - undefined - ); - - useActionHandler( - "jump-backward", - (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.max(0, currentTime - seconds)); - }, - undefined - ); - - useActionHandler( - "goto-start", - () => { - seek(0); - }, - undefined - ); - - useActionHandler( - "goto-end", - () => { - seek(duration); - }, - undefined - ); - - // Timeline editing actions - useActionHandler( - "split-element", - () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); - - if (element) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitSelected(currentTime, trackId, elementId); - } else { - toast.error("Playhead must be within selected element"); - } - } - }, - undefined - ); - - useActionHandler( - "delete-selected", - () => { - if (selectedElements.length === 0) { - return; - } - deleteSelected(); - }, - undefined - ); - - useActionHandler( - "select-all", - () => { - const allElements = tracks.flatMap((track: any) => - track.elements.map((element: any) => ({ - trackId: track.id, - elementId: element.id, - })) - ); - setSelectedElements(allElements); - }, - undefined - ); - - useActionHandler( - "duplicate-selected", - () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to duplicate"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }, - undefined - ); - - useActionHandler( - "copy-selected", - () => { - if (selectedElements.length === 0) return; - useTimelineStore.getState().copySelected(); - }, - undefined - ); - - useActionHandler( - "paste-selected", - () => { - useTimelineStore.getState().pasteAtTime(currentTime); - }, - undefined - ); - - useActionHandler( - "toggle-snapping", - () => { - toggleSnapping(); - }, - undefined - ); - - // History actions - useActionHandler( - "undo", - () => { - undo(); - }, - undefined - ); - - useActionHandler( - "redo", - () => { - redo(); - }, - undefined - ); -} diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts new file mode 100644 index 00000000..ff3e2db1 --- /dev/null +++ b/apps/web/src/hooks/use-editor.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import { EditorCore } from "@/core"; + +export function useEditor(): EditorCore { + const editor = useMemo(() => EditorCore.getInstance(), []); + const versionRef = useRef(0); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const handleStoreChange = () => { + versionRef.current += 1; + onStoreChange(); + }; + + const unsubscribers = [ + editor.playback.subscribe(handleStoreChange), + editor.timeline.subscribe(handleStoreChange), + editor.scenes.subscribe(handleStoreChange), + editor.project.subscribe(handleStoreChange), + editor.media.subscribe(handleStoreChange), + editor.renderer.subscribe(handleStoreChange), + editor.selection.subscribe(handleStoreChange), + ]; + + return () => { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; + }, + [editor], + ); + + const getSnapshot = useCallback(() => versionRef.current, []); + + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return editor; +} diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts new file mode 100644 index 00000000..19badd98 --- /dev/null +++ b/apps/web/src/hooks/use-file-upload.ts @@ -0,0 +1,103 @@ +import { useState, useRef } from "react"; +import { hasDragData } from "@/lib/drag-data"; + +interface UseFileUploadOptions { + accept?: string; + multiple?: boolean; + onFilesSelected?: (files: FileList) => void; +} + +function containsFiles(dataTransfer: DataTransfer): boolean { + return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); +} + +export function useFileUpload({ + accept, + multiple, + onFilesSelected, +}: UseFileUploadOptions = {}) { + const [isDragOver, setIsDragOver] = useState(false); + const dragCounterRef = useRef(0); + const inputRef = useRef<HTMLInputElement>(null); + + function openFilePicker() { + if (!inputRef.current) return; + + inputRef.current.accept = accept || "*"; + inputRef.current.multiple = multiple || false; + inputRef.current.click(); + } + + function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const files = event.target.files; + if (files && files.length > 0 && onFilesSelected) { + onFilesSelected(files); + } + + if (event.target) { + event.target.value = ""; + } + } + + function handleDragEnter(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + + dragCounterRef.current += 1; + setIsDragOver(true); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + + if (!containsFiles(e.dataTransfer)) return; + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + if (onFilesSelected && containsFiles(e.dataTransfer)) { + const files = e.dataTransfer.files; + const shouldUseMultiple = multiple ?? false; + + if (shouldUseMultiple) { + onFilesSelected(files); + } else if (files.length > 0) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(files[0]); + onFilesSelected(dataTransfer.files); + } + } + } + + return { + isDragOver, + openFilePicker, + fileInputProps: { + ref: inputRef, + type: "file", + style: { display: "none" }, + onChange: handleFileChange, + }, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts deleted file mode 100644 index cce187f1..00000000 --- a/apps/web/src/hooks/use-frame-cache.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { useRef, useCallback } from "react"; -import { - TimelineTrack, - TimelineElement, - MediaElement, - TextElement, -} from "@/types/timeline"; -import { MediaFile } from "@/types/media"; -import { TProject } from "@/types/project"; - -interface CachedFrame { - imageData: ImageData; - timelineHash: string; - timestamp: number; -} - -interface FrameCacheOptions { - maxCacheSize?: number; // Maximum number of cached frames - cacheResolution?: number; // Frames per second to cache at -} - -// Shared singleton cache across hook instances (HMR-safe) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const __frameCacheGlobal: any = globalThis as any; -const __sharedFrameCache: Map<number, CachedFrame> = - __frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>(); -__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache; - -export function useFrameCache(options: FrameCacheOptions = {}) { - const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps - - const frameCacheRef = useRef(__sharedFrameCache); - - // Generate a hash of the timeline state that affects rendering - const getTimelineHash = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ): string => { - // Get elements that are active at this time - const activeElements: Array<{ - id: string; - type: string; - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - mediaId?: string; - // Text-specific properties - content?: string; - fontSize?: number; - fontFamily?: string; - color?: string; - backgroundColor?: string; - x?: number; - y?: number; - rotation?: number; - opacity?: number; - }> = []; - - for (const track of tracks) { - if (track.muted) continue; - - for (const element of track.elements) { - // Check if element has hidden property (some elements might not have it) - const isHidden = "hidden" in element ? element.hidden : false; - if (isHidden) continue; - - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (time >= elementStart && time < elementEnd) { - if (element.type === "media") { - const mediaElement = element as MediaElement; - activeElements.push({ - id: element.id, - type: element.type, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - mediaId: mediaElement.mediaId, - }); - } else if (element.type === "text") { - const textElement = element as TextElement; - activeElements.push({ - id: element.id, - type: element.type, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - content: textElement.content, - fontSize: textElement.fontSize, - fontFamily: textElement.fontFamily, - color: textElement.color, - backgroundColor: textElement.backgroundColor, - x: textElement.x, - y: textElement.y, - rotation: textElement.rotation, - opacity: textElement.opacity, - }); - } - } - } - } - - // Include project settings that affect rendering - const projectState = { - backgroundColor: activeProject?.backgroundColor, - backgroundType: activeProject?.backgroundType, - blurIntensity: activeProject?.blurIntensity, - canvasSize: activeProject?.canvasSize, - }; - - const hash = { - activeElements, - projectState, - sceneId, - time: Math.floor(time * cacheResolution) / cacheResolution, - }; - return JSON.stringify(hash); - }, - [cacheResolution] - ); - - // Check if a frame is cached and valid - const isFrameCached = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ): boolean => { - const frameKey = Math.floor(time * cacheResolution); - const cached = frameCacheRef.current.get(frameKey); - - if (!cached) return false; - - const currentHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject, - sceneId - ); - return cached.timelineHash === currentHash; - }, - [getTimelineHash, cacheResolution] - ); - - // Get cached frame if available and valid - const getCachedFrame = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ): ImageData | null => { - const frameKey = Math.floor(time * cacheResolution); - const cached = frameCacheRef.current.get(frameKey); - - if (!cached) { - return null; - } - - const currentHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject, - sceneId - ); - console.log(cached.timelineHash === currentHash); - if (cached.timelineHash !== currentHash) { - // Cache is stale, remove it - console.log( - "Cache miss - hash mismatch:", - JSON.stringify({ - cachedHash: cached.timelineHash.slice(0, 100), - currentHash: currentHash.slice(0, 100), - }) - ); - frameCacheRef.current.delete(frameKey); - return null; - } - - return cached.imageData; - }, - [getTimelineHash, cacheResolution] - ); - - // Cache a rendered frame - const cacheFrame = useCallback( - ( - time: number, - imageData: ImageData, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ): void => { - const frameKey = Math.floor(time * cacheResolution); - const timelineHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject, - sceneId - ); - - // Enforce cache size limit (LRU eviction) - if (frameCacheRef.current.size >= maxCacheSize) { - // Remove oldest entries - const entries = Array.from(frameCacheRef.current.entries()); - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); - - // Remove oldest 20% of entries - const toRemove = Math.floor(entries.length * 0.2); - for (let i = 0; i < toRemove; i++) { - frameCacheRef.current.delete(entries[i][0]); - } - } - - frameCacheRef.current.set(frameKey, { - imageData, - timelineHash, - timestamp: Date.now(), - }); - }, - [getTimelineHash, cacheResolution, maxCacheSize] - ); - - // Clear cache when timeline changes significantly - const invalidateCache = useCallback(() => { - frameCacheRef.current.clear(); - }, []); - - // Get render status for timeline indicator - const getRenderStatus = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ): "cached" | "not-cached" => { - return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId) - ? "cached" - : "not-cached"; - }, - [isFrameCached] - ); - - // Pre-render frames around current time - const preRenderNearbyFrames = useCallback( - async ( - currentTime: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - renderFunction: (time: number) => Promise<ImageData>, - sceneId?: string, - range: number = 3 // seconds - ) => { - const framesToPreRender: number[] = []; - - // Calculate frames to pre-render (around current time) - for ( - let offset = -range; - offset <= range; - offset += 1 / cacheResolution - ) { - const time = currentTime + offset; - if (time < 0) continue; - - if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) { - framesToPreRender.push(time); - } - } - - // Expand to full 1-second buckets to avoid fragmented tiny cache regions - const secondsToPreRender = new Set<number>(); - for (const t of framesToPreRender) { - secondsToPreRender.add(Math.floor(t)); - } - - const expandedTimes: number[] = []; - for (const s of secondsToPreRender) { - for (let k = 0; k < cacheResolution; k++) { - const t = s + k / cacheResolution; - if (t < 0) continue; - if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) { - expandedTimes.push(t); - } - } - } - - // Sort forward-first near currentTime to improve perceived responsiveness - expandedTimes.sort((a, b) => { - const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6; - const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6; - return da - db; - }); - - // Cap total scheduled renders to avoid jank (e.g., up to 90 frames) - const CAP = Math.max(30, Math.min(90, cacheResolution * 3)); - const toSchedule = expandedTimes.slice(0, CAP); - - // Pre-render during idle time - for (const time of toSchedule) { - requestIdleCallback(async () => { - try { - const imageData = await renderFunction(time); - cacheFrame( - time, - imageData, - tracks, - mediaFiles, - activeProject, - sceneId - ); - } catch (error) { - console.warn(`Pre-render failed for time ${time}:`, error); - } - }); - } - }, - [isFrameCached, cacheFrame, cacheResolution] - ); - - return { - isFrameCached, - getCachedFrame, - cacheFrame, - invalidateCache, - getRenderStatus, - preRenderNearbyFrames, - cacheSize: frameCacheRef.current.size, - }; -} diff --git a/apps/web/src/hooks/use-highlight-scroll.ts b/apps/web/src/hooks/use-highlight-scroll.ts deleted file mode 100644 index 241bffb2..00000000 --- a/apps/web/src/hooks/use-highlight-scroll.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useState, useRef } from "react";
-
-export function useHighlightScroll(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000
-) {
- const [highlightedId, setHighlightedId] = useState<string | null>(null);
- const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
-
- const registerElement = (id: string, element: HTMLElement | null) => {
- if (element) {
- elementRefs.current.set(id, element);
- } else {
- elementRefs.current.delete(id);
- }
- };
-
- useEffect(() => {
- if (!highlightId) return;
-
- setHighlightedId(highlightId);
-
- const target = elementRefs.current.get(highlightId);
- target?.scrollIntoView({ block: "center" });
-
- const timeout = setTimeout(() => {
- setHighlightedId(null);
- onClearHighlight();
- }, highlightDuration);
-
- return () => clearTimeout(timeout);
- }, [highlightId, onClearHighlight, highlightDuration]);
-
- return { highlightedId, registerElement };
-}
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts index 0f8db198..4f823834 100644 --- a/apps/web/src/hooks/use-infinite-scroll.ts +++ b/apps/web/src/hooks/use-infinite-scroll.ts @@ -1,35 +1,35 @@ import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
}
export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef<HTMLDivElement>(null);
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
- const handleScroll = useCallback(
- (event: React.UIEvent<HTMLDivElement>) => {
- if (!enabled) return;
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled]
- );
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled],
+ );
- return { scrollAreaRef, handleScroll };
+ return { scrollAreaRef, handleScroll };
}
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts deleted file mode 100644 index cc5dd340..00000000 --- a/apps/web/src/hooks/use-keybinding-conflicts.ts +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { ActionWithOptionalArgs } from "@/constants/actions"; - -export interface KeybindingConflictInfo { - key: string; - actions: ActionWithOptionalArgs[]; - isConflict: boolean; -} - -export const useKeybindingConflicts = () => { - const { keybindings } = useKeybindingsStore(); - - const conflicts = useMemo(() => { - const keyToActions: Record<string, ActionWithOptionalArgs[]> = {}; - const conflictList: KeybindingConflictInfo[] = []; - - // Group actions by key - Object.entries(keybindings).forEach(([key, action]) => { - if (!keyToActions[key]) { - keyToActions[key] = []; - } - keyToActions[key].push(action); - }); - - // Find conflicts - Object.entries(keyToActions).forEach(([key, actions]) => { - const uniqueActions = [...new Set(actions)]; - conflictList.push({ - key, - actions: uniqueActions, - isConflict: uniqueActions.length > 1, - }); - }); - - return conflictList.filter((item) => item.isConflict); - }, [keybindings]); - - const hasConflicts = conflicts.length > 0; - - const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { - return conflicts.find((conflict) => conflict.key === key) || null; - }; - - const getConflictsForAction = ( - action: ActionWithOptionalArgs - ): KeybindingConflictInfo[] => { - return conflicts.filter((conflict) => conflict.actions.includes(action)); - }; - - return { - conflicts, - hasConflicts, - getConflictsForKey, - getConflictsForAction, - }; -}; diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 7f6505b0..e59d1eaf 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -1,75 +1,75 @@ import { useEffect } from "react"; -import { invokeAction } from "../constants/actions"; +import { invokeAction } from "@/lib/actions"; import { useKeybindingsStore } from "@/stores/keybindings-store"; /** - * A composable that hooks to the caller component's + * a composable that hooks to the caller component's * lifecycle and hooks to the keyboard events to fire * the appropriate actions based on keybindings */ export function useKeybindingsListener() { - const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } = - useKeybindingsStore(); + const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } = + useKeybindingsStore(); - useEffect(() => { - const handleKeyDown = (ev: KeyboardEvent) => { - // Do not check keybinds if the mode is disabled - if (!keybindingsEnabled) return; - // ignore key events if user is changing keybindings - if (isRecording) return; + useEffect(() => { + const eventOptions: AddEventListenerOptions = { capture: true }; + const handleKeyDown = (ev: KeyboardEvent) => { + // do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; + // ignore key events if user is changing keybindings + if (isRecording) return; - const binding = getKeybindingString(ev); - if (!binding) return; + const binding = getKeybindingString(ev); + if (!binding) return; - const boundAction = keybindings[binding]; - if (!boundAction) return; + const boundAction = keybindings[binding]; + if (!boundAction) return; - const activeElement = document.activeElement; - const isTextInput = - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - (activeElement as HTMLElement).isContentEditable); + const activeElement = document.activeElement; + const isTextInput = + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + (activeElement as HTMLElement).isContentEditable); - if (isTextInput) return; + if (isTextInput) return; - ev.preventDefault(); + ev.preventDefault(); - // Handle actions with default arguments - let actionArgs: any; + switch (boundAction) { + case "seek-forward": + invokeAction("seek-forward", { seconds: 1 }, "keypress"); + break; + case "seek-backward": + invokeAction("seek-backward", { seconds: 1 }, "keypress"); + break; + case "jump-forward": + invokeAction("jump-forward", { seconds: 5 }, "keypress"); + break; + case "jump-backward": + invokeAction("jump-backward", { seconds: 5 }, "keypress"); + break; + default: + invokeAction(boundAction, undefined, "keypress"); + } + }; - if (boundAction === "seek-forward") { - actionArgs = { seconds: 1 }; - } else if (boundAction === "seek-backward") { - actionArgs = { seconds: 1 }; - } else if (boundAction === "jump-forward") { - actionArgs = { seconds: 5 }; - } else if (boundAction === "jump-backward") { - actionArgs = { seconds: 5 }; - } + document.addEventListener("keydown", handleKeyDown, eventOptions); - invokeAction(boundAction, actionArgs, "keypress"); - }; - - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]); + return () => { + document.removeEventListener("keydown", handleKeyDown, eventOptions); + }; + }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]); } /** - * This composable allows for the UI component to be disabled if the component in question is mounted + * this composable allows for the UI component to be disabled if the component in question is mounted */ export function useKeybindingDisabler() { - const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); - return { - disableKeybindings, - enableKeybindings, - }; + return { + disableKeybindings, + enableKeybindings, + }; } - -// Export the bindings for backward compatibility -export const bindings = {}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index 29e3c09b..80f7f07a 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -2,138 +2,81 @@ import { useMemo } from "react"; import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { Action } from "@/constants/actions"; -import { getPlatformAlternateKey, getPlatformSpecialKey } from "@/lib/utils"; +import { ACTIONS, type TAction } from "@/lib/actions"; +import { + getPlatformAlternateKey, + getPlatformSpecialKey, +} from "@/utils/platform"; export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: Action; - icon?: React.ReactNode; + id: string; + keys: string[]; + description: string; + category: string; + action: TAction; + icon?: React.ReactNode; } -// Map actions to their descriptions and categories -const actionDescriptions: Record< - Action, - { description: string; category: string } -> = { - "toggle-play": { description: "Play/Pause", category: "Playback" }, - "stop-playback": { description: "Stop playback", category: "Playback" }, - "seek-forward": { - description: "Seek forward 1 second", - category: "Playback", - }, - "seek-backward": { - description: "Seek backward 1 second", - category: "Playback", - }, - "frame-step-forward": { - description: "Frame step forward", - category: "Navigation", - }, - "frame-step-backward": { - description: "Frame step backward", - category: "Navigation", - }, - "jump-forward": { - description: "Jump forward 5 seconds", - category: "Navigation", - }, - "jump-backward": { - description: "Jump backward 5 seconds", - category: "Navigation", - }, - "goto-start": { description: "Go to timeline start", category: "Navigation" }, - "goto-end": { description: "Go to timeline end", category: "Navigation" }, - "split-element": { - description: "Split element at playhead", - category: "Editing", - }, - "delete-selected": { - description: "Delete selected elements", - category: "Editing", - }, - "select-all": { description: "Select all elements", category: "Selection" }, - "duplicate-selected": { - description: "Duplicate selected element", - category: "Selection", - }, - "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, - undo: { description: "Undo", category: "History" }, - redo: { description: "Redo", category: "History" }, - "copy-selected": { - description: "Copy selected elements", - category: "Editing", - }, - "paste-selected": { - description: "Paste elements at playhead", - category: "Editing", - }, -}; +function formatKey({ key }: { key: string }): string { + return key + .replace("ctrl", getPlatformSpecialKey()) + .replace("alt", getPlatformAlternateKey()) + .replace("shift", "Shift") + .replace("left", "←") + .replace("right", "→") + .replace("up", "↑") + .replace("down", "↓") + .replace("space", "Space") + .replace("home", "Home") + .replace("enter", "Enter") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); +} -// Convert key binding format to display format -const formatKey = (key: string): string => { - return key - .replace("ctrl", getPlatformSpecialKey()) - .replace("alt", getPlatformAlternateKey()) - .replace("shift", "Shift") - .replace("left", "←") - .replace("right", "→") - .replace("up", "↑") - .replace("down", "↓") - .replace("space", "Space") - .replace("home", "Home") - .replace("enter", "Enter") - .replace("end", "End") - .replace("delete", "Delete") - .replace("backspace", "Backspace") - .replace("-", "+"); -}; +export function useKeyboardShortcutsHelp() { + const { keybindings } = useKeybindingsStore(); -export const useKeyboardShortcutsHelp = () => { - const { keybindings } = useKeybindingsStore(); + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; + const actionToKeys: Record<string, string[]> = {}; - const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; + for (const [key, action] of Object.entries(keybindings)) { + if (action) { + if (!actionToKeys[action]) { + actionToKeys[action] = []; + } + actionToKeys[action].push(formatKey({ key })); + } + } - // Group keybindings by action - const actionToKeys: Record<Action, string[]> = {} as any; + for (const [actionId, keys] of Object.entries(actionToKeys)) { + if (!isAction(actionId)) continue; - Object.entries(keybindings).forEach(([key, action]) => { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey(key)); - } - }); + const actionDef = ACTIONS[actionId]; + result.push({ + id: actionId, + keys, + description: actionDef.description, + category: actionDef.category, + action: actionId, + }); + } - // Convert to shortcuts format - Object.entries(actionToKeys).forEach(([action, keys]) => { - const actionInfo = actionDescriptions[action as Action]; - if (actionInfo) { - result.push({ - id: action, - keys, - description: actionInfo.description, - category: actionInfo.category, - action: action as Action, - }); - } - }); + return result.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.description.localeCompare(b.description); + }); + }, [keybindings]); - // Sort shortcuts by category first, then by description to ensure consistent ordering - return result.sort((a, b) => { - if (a.category !== b.category) { - return a.category.localeCompare(b.category); - } - return a.description.localeCompare(b.description); - }); - }, [keybindings]); + return { + shortcuts, + }; +} - return { - shortcuts, - }; -}; +function isAction(id: string): id is TAction { + return id in ACTIONS; +} diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts new file mode 100644 index 00000000..d6a67b93 --- /dev/null +++ b/apps/web/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx deleted file mode 100644 index 48fab93c..00000000 --- a/apps/web/src/hooks/use-mobile.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState<boolean | undefined>( - undefined - ); - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); - - return !!isMobile; -} diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts deleted file mode 100644 index c27911d2..00000000 --- a/apps/web/src/hooks/use-playback-controls.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useCallback } from "react"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { toast } from "sonner"; - -export const usePlaybackControls = () => { - const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); - - const { - selectedElements, - tracks, - splitSelected, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - } = useTimelineStore(); - - const handleSplitSelectedElement = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (!element) return; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - - splitSelected(currentTime, trackId, elementId); - }, [selectedElements, tracks, currentTime, splitSelected]); - - const handleSplitAndKeepLeftCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (!element) return; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - - splitAndKeepLeft(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitAndKeepLeft]); - - const handleSplitAndKeepRightCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (!element) return; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - - splitAndKeepRight(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitAndKeepRight]); - - const handleSeparateAudioCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - - separateAudio(trackId, elementId); - }, [selectedElements, tracks, separateAudio]); -}; diff --git a/apps/web/src/hooks/use-raf-loop.ts b/apps/web/src/hooks/use-raf-loop.ts new file mode 100644 index 00000000..54b1e662 --- /dev/null +++ b/apps/web/src/hooks/use-raf-loop.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef } from "react"; + +export function useRafLoop(callback: ({ time }: { time: number }) => void) { + const requestRef = useRef<number>(0); + const previousTimeRef = useRef<number | null>(null); + + useEffect(() => { + const loop = ({ time }: { time: number }) => { + if (previousTimeRef.current !== null) { + const deltaTime = time - previousTimeRef.current; + callback({ time: deltaTime }); + } + previousTimeRef.current = time; + requestRef.current = requestAnimationFrame((time) => loop({ time })); + }; + + requestRef.current = requestAnimationFrame((time) => loop({ time })); + return () => cancelAnimationFrame(requestRef.current); + }, [callback]); +} diff --git a/apps/web/src/hooks/use-reveal-item.ts b/apps/web/src/hooks/use-reveal-item.ts new file mode 100644 index 00000000..5004f60c --- /dev/null +++ b/apps/web/src/hooks/use-reveal-item.ts @@ -0,0 +1,36 @@ +import { useEffect, useState, useRef } from "react"; + +export function useRevealItem( + highlightId: string | null, + onClearHighlight: () => void, + highlightDuration = 1000, +) { + const [highlightedId, setHighlightedId] = useState<string | null>(null); + const elementRefs = useRef<Map<string, HTMLElement>>(new Map()); + + const registerElement = (id: string, element: HTMLElement | null) => { + if (element) { + elementRefs.current.set(id, element); + } else { + elementRefs.current.delete(id); + } + }; + + useEffect(() => { + if (!highlightId) return; + + setHighlightedId(highlightId); + + const target = elementRefs.current.get(highlightId); + target?.scrollIntoView({ block: "center" }); + + const timeout = setTimeout(() => { + setHighlightedId(null); + onClearHighlight(); + }, highlightDuration); + + return () => clearTimeout(timeout); + }, [highlightId, onClearHighlight, highlightDuration]); + + return { highlightedId, registerElement }; +} diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts deleted file mode 100644 index 87026500..00000000 --- a/apps/web/src/hooks/use-selection-box.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; - -interface UseSelectionBoxProps { - containerRef: React.RefObject<HTMLElement>; - playheadRef?: React.RefObject<HTMLElement>; - onSelectionComplete: ( - elements: { trackId: string; elementId: string }[] - ) => void; - isEnabled?: boolean; -} - -interface SelectionBoxState { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; -} - -export function useSelectionBox({ - containerRef, - playheadRef, - onSelectionComplete, - isEnabled = true, -}: UseSelectionBoxProps) { - const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>( - null - ); - const [justFinishedSelecting, setJustFinishedSelecting] = useState(false); - - // Mouse down handler to start selection - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if (!isEnabled) return; - - // Only start selection on empty space clicks - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } - if (playheadRef?.current?.contains(e.target as Node)) { - return; - } - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - return; - } - // Don't start selection when clicking in the ruler area - this interferes with playhead dragging - if ((e.target as HTMLElement).closest("[data-ruler-area]")) { - return; - } - - setSelectionBox({ - startPos: { x: e.clientX, y: e.clientY }, - currentPos: { x: e.clientX, y: e.clientY }, - isActive: false, // Will become active when mouse moves - }); - }, - [isEnabled, playheadRef] - ); - - // Function to select elements within the selection box - const selectElementsInBox = useCallback( - (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => { - if (!containerRef.current) return; - - const container = containerRef.current; - const containerRect = container.getBoundingClientRect(); - - // Calculate selection rectangle in container coordinates - const startX = startPos.x - containerRect.left; - const startY = startPos.y - containerRect.top; - const endX = endPos.x - containerRect.left; - const endY = endPos.y - containerRect.top; - - const selectionRect = { - left: Math.min(startX, endX), - top: Math.min(startY, endY), - right: Math.max(startX, endX), - bottom: Math.max(startY, endY), - }; - - // Find all timeline elements within the selection rectangle - const timelineElements = container.querySelectorAll(".timeline-element"); - - const selectedElements: { trackId: string; elementId: string }[] = []; - - timelineElements.forEach((element) => { - const elementRect = element.getBoundingClientRect(); - // Use absolute coordinates for more accurate intersection detection - const elementAbsolute = { - left: elementRect.left, - top: elementRect.top, - right: elementRect.right, - bottom: elementRect.bottom, - }; - - const selectionAbsolute = { - left: startPos.x, - top: startPos.y, - right: endPos.x, - bottom: endPos.y, - }; - - // Normalize selection rectangle (handle dragging in any direction) - const normalizedSelection = { - left: Math.min(selectionAbsolute.left, selectionAbsolute.right), - top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom), - right: Math.max(selectionAbsolute.left, selectionAbsolute.right), - bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom), - }; - - const elementId = element.getAttribute("data-element-id"); - const trackId = element.getAttribute("data-track-id"); - - // Check if element intersects with selection rectangle (any overlap) - // Using absolute coordinates for more precise detection - const intersects = !( - elementAbsolute.right < normalizedSelection.left || - elementAbsolute.left > normalizedSelection.right || - elementAbsolute.bottom < normalizedSelection.top || - elementAbsolute.top > normalizedSelection.bottom - ); - - if (intersects && elementId && trackId) { - selectedElements.push({ trackId, elementId }); - } - }); - - // Always call the callback - with elements or empty array to clear selection - console.log( - JSON.stringify({ selectElementsInBox: selectedElements.length }) - ); - onSelectionComplete(selectedElements); - }, - [containerRef, onSelectionComplete] - ); - - // Effect to track selection box movement - useEffect(() => { - if (!selectionBox) return; - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = Math.abs(e.clientX - selectionBox.startPos.x); - const deltaY = Math.abs(e.clientY - selectionBox.startPos.y); - - // Start selection if mouse moved more than 5px - const shouldActivate = deltaX > 5 || deltaY > 5; - - const newSelectionBox = { - ...selectionBox, - currentPos: { x: e.clientX, y: e.clientY }, - isActive: shouldActivate || selectionBox.isActive, - }; - - setSelectionBox(newSelectionBox); - - // Real-time visual feedback: update selection as we drag - if (newSelectionBox.isActive) { - selectElementsInBox( - newSelectionBox.startPos, - newSelectionBox.currentPos - ); - } - }; - - const handleMouseUp = () => { - console.log( - JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } }) - ); - - // If we had an active selection, mark that we just finished selecting - if (selectionBox?.isActive) { - console.log(JSON.stringify({ settingJustFinishedSelecting: true })); - setJustFinishedSelecting(true); - // Clear the flag after a short delay to allow click events to check it - setTimeout(() => { - console.log(JSON.stringify({ clearingJustFinishedSelecting: true })); - setJustFinishedSelecting(false); - }, 50); - } - - // Don't call selectElementsInBox again - real-time selection already handled it - // Just clean up the selection box visual - setSelectionBox(null); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [selectionBox, selectElementsInBox]); - - useEffect(() => { - if (!selectionBox?.isActive) return; - - const previousBodyUserSelect = document.body.style.userSelect; - const container = containerRef.current; - const previousContainerUserSelect = container?.style.userSelect ?? ""; - - document.body.style.userSelect = "none"; - if (container) container.style.userSelect = "none"; - - return () => { - document.body.style.userSelect = previousBodyUserSelect; - if (container) container.style.userSelect = previousContainerUserSelect; - }; - }, [selectionBox?.isActive, containerRef]); - - return { - selectionBox, - handleMouseDown, - isSelecting: selectionBox?.isActive || false, - justFinishedSelecting, - }; -} diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts index db9a7e8e..263a01e2 100644 --- a/apps/web/src/hooks/use-sound-search.ts +++ b/apps/web/src/hooks/use-sound-search.ts @@ -1,156 +1,154 @@ import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
-/**
- * Custom hook for searching sound effects with race condition protection.
- * Uses global Zustand store to persist search state across tab switches.
- * - Debounced search (300ms)
- * - Race condition protection with cleanup
- * - Proper error handling
- */
+export function useSoundSearch({
+ query,
+ commercialOnly,
+}: {
+ query: string;
+ commercialOnly: boolean;
+}) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
-export function useSoundSearch(query: string, commercialOnly: boolean) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
- // Load more function for infinite scroll
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
+ try {
+ setLoadingMore({ loading: true });
+ const nextPage = currentPage + 1;
- try {
- setLoadingMore(true);
- const nextPage = currentPage + 1;
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
- if (query.trim()) {
- searchParams.set("q", query);
- }
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`,
+ );
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`
- );
+ if (response.ok) {
+ const data = await response.json();
- if (response.ok) {
- const data = await response.json();
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
- // Append to appropriate array based on whether we have a query
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
+ setCurrentPage({ page: nextPage });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount(data.count);
+ } else {
+ setSearchError({ error: `Load more failed: ${response.status}` });
+ }
+ } catch (err) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Load more failed",
+ });
+ } finally {
+ setLoadingMore({ loading: false });
+ }
+ };
- setCurrentPage(nextPage);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- } else {
- setSearchError(`Load more failed: ${response.status}`);
- }
- } catch (err) {
- setSearchError(err instanceof Error ? err.message : "Load more failed");
- } finally {
- setLoadingMore(false);
- }
- };
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults({ results: [] });
+ setSearchError({ error: null });
+ setLastSearchQuery({ query: "" });
+ return;
+ }
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults([]);
- setSearchError(null);
- setLastSearchQuery("");
- // Don't reset pagination here - top sounds pagination is managed by prefetcher
- return;
- }
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
- // If we already searched for this query and have results, don't search again
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
+ let ignore = false;
- let ignore = false;
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching({ searching: true });
+ setSearchError({ error: null });
+ resetPagination();
- const timeoutId = setTimeout(async () => {
- try {
- setSearching(true);
- setSearchError(null);
- resetPagination();
+ const response = await fetch(
+ `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
+ );
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
- );
+ if (!ignore) {
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults({ results: data.results });
+ setLastSearchQuery({ query: query });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ setCurrentPage({ page: 1 });
+ } else {
+ setSearchError({ error: `Search failed: ${response.status}` });
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ }
+ } finally {
+ if (!ignore) {
+ setSearching({ searching: false });
+ }
+ }
+ }, 300);
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults(data.results);
- setLastSearchQuery(query);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- setCurrentPage(1);
- } else {
- setSearchError(`Search failed: ${response.status}`);
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError(err instanceof Error ? err.message : "Search failed");
- }
- } finally {
- if (!ignore) {
- setSearching(false);
- }
- }
- }, 300);
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ query,
+ lastSearchQuery,
+ searchResults.length,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ resetPagination,
+ ]);
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
+ return {
+ results: searchResults,
+ isLoading: isSearching,
+ error: searchError,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ };
}
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts deleted file mode 100644 index 4812459a..00000000 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { useState, useEffect } from "react"; -import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline"; -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; -import { snapTimeToFrame } from "@/constants/timeline-constants"; - -interface UseTimelineElementResizeProps { - element: TimelineElement; - track: TimelineTrack; - zoomLevel: number; -} - -export function useTimelineElementResize({ - element, - track, - zoomLevel, -}: UseTimelineElementResizeProps) { - const [resizing, setResizing] = useState<ResizeState | null>(null); - const { mediaFiles } = useMediaStore(); - const { - updateElementStartTime, - updateElementTrim, - updateElementDuration, - pushHistory, - } = useTimelineStore(); - - // Set up document-level mouse listeners during resize (like proper drag behavior) - useEffect(() => { - if (!resizing) return; - - const handleDocumentMouseMove = (e: MouseEvent) => { - updateTrimFromMouseMove({ clientX: e.clientX }); - }; - - const handleDocumentMouseUp = () => { - handleResizeEnd(); - }; - - // Add document-level listeners for proper drag behavior - document.addEventListener("mousemove", handleDocumentMouseMove); - document.addEventListener("mouseup", handleDocumentMouseUp); - - return () => { - document.removeEventListener("mousemove", handleDocumentMouseMove); - document.removeEventListener("mouseup", handleDocumentMouseUp); - }; - }, [resizing]); // Re-run when resizing state changes - - const handleResizeStart = ( - e: React.MouseEvent, - elementId: string, - side: "left" | "right" - ) => { - e.stopPropagation(); - e.preventDefault(); - - // Push history once at the start of the resize operation - pushHistory(); - - setResizing({ - elementId, - side, - startX: e.clientX, - initialTrimStart: element.trimStart, - initialTrimEnd: element.trimEnd, - }); - }; - - const canExtendElementDuration = () => { - // Text elements can always be extended - if (element.type === "text") { - return true; - } - - // Media elements - check the media type - if (element.type === "media") { - const mediaFile = mediaFiles.find((file) => file.id === element.mediaId); - if (!mediaFile) return false; - - // Images can be extended (static content) - if (mediaFile.type === "image") { - return true; - } - - // Videos and audio cannot be extended beyond their natural duration - // (no additional content exists) - return false; - } - - return false; - }; - - const updateTrimFromMouseMove = (e: { clientX: number }) => { - if (!resizing) return; - - const deltaX = e.clientX - resizing.startX; - // Reasonable sensitivity for resize operations - similar to timeline scale - const deltaTime = deltaX / (50 * zoomLevel); - - // Get project FPS for frame snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - - if (resizing.side === "left") { - // Left resize - different behavior for media vs text/image elements - const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; - const calculated = resizing.initialTrimStart + deltaTime; - - if (calculated >= 0) { - // Normal trimming within available content - const newTrimStart = snapTimeToFrame( - Math.min(maxAllowed, calculated), - projectFps - ); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame( - element.startTime + trimDelta, - projectFps - ); - - updateElementTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd, - false - ); - updateElementStartTime(track.id, element.id, newStartTime, false); - } else { - // Trying to extend beyond trimStart = 0 - if (canExtendElementDuration()) { - // Text/Image: extend element to the left by moving startTime and increasing duration - const extensionAmount = Math.abs(calculated); - const maxExtension = element.startTime; - const actualExtension = Math.min(extensionAmount, maxExtension); - const newStartTime = snapTimeToFrame( - element.startTime - actualExtension, - projectFps - ); - const newDuration = snapTimeToFrame( - element.duration + actualExtension, - projectFps - ); - - // Keep trimStart at 0 and extend the element - updateElementTrim( - track.id, - element.id, - 0, - resizing.initialTrimEnd, - false - ); - updateElementDuration(track.id, element.id, newDuration, false); - updateElementStartTime(track.id, element.id, newStartTime, false); - } else { - // Video/Audio: can't extend beyond original content - limit to trimStart = 0 - const newTrimStart = 0; - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = snapTimeToFrame( - element.startTime + trimDelta, - projectFps - ); - - updateElementTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd, - false - ); - updateElementStartTime(track.id, element.id, newStartTime, false); - } - } - } else { - // Right resize - can extend duration for supported element types - const calculated = resizing.initialTrimEnd - deltaTime; - - if (calculated < 0) { - // We're trying to extend beyond original duration - if (canExtendElementDuration()) { - // Extend the duration instead of reducing trimEnd further - const extensionNeeded = Math.abs(calculated); - const newDuration = snapTimeToFrame( - element.duration + extensionNeeded, - projectFps - ); - const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending - - // Update duration first, then trim - updateElementDuration(track.id, element.id, newDuration, false); - updateElementTrim( - track.id, - element.id, - resizing.initialTrimStart, - newTrimEnd, - false - ); - } else { - // Can't extend - just set trimEnd to 0 (maximum possible extension) - updateElementTrim( - track.id, - element.id, - resizing.initialTrimStart, - 0, - false - ); - } - } else { - // Normal trimming within original duration - // Calculate the desired end time based on mouse movement - const currentEndTime = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; - const desiredEndTime = currentEndTime + deltaTime; - - // Snap the desired end time to frame - const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps); - - // Calculate what trimEnd should be to achieve this snapped end time - const newTrimEnd = Math.max( - 0, - element.duration - - element.trimStart - - (snappedEndTime - element.startTime) - ); - - // Ensure we don't trim more than available content (leave at least 0.1s visible) - const maxTrimEnd = element.duration - element.trimStart - 0.1; - const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd); - - updateElementTrim( - track.id, - element.id, - element.trimStart, - finalTrimEnd, - false - ); - } - } - }; - - const handleResizeEnd = () => { - setResizing(null); - }; - - return { - resizing, - isResizing: resizing !== null, - handleResizeStart, - // Return empty handlers since we use document listeners now - handleResizeMove: () => {}, // Not used anymore - handleResizeEnd: () => {}, // Not used anymore - }; -} diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts deleted file mode 100644 index 57ce085a..00000000 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll"; - -interface UseTimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; -} - -export function useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState<number | null>(null); - - // Ruler drag detection state - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - const lastMouseXRef = useRef<number>(0); - - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - - // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // Prevent ruler drag from triggering - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - // Ruler mouse down handler - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle left mouse button - if (e.button !== 0) return; - - // Don't interfere if clicking on the playhead itself - if (playheadRef?.current?.contains(e.target as Node)) return; - - e.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); - - // Start scrubbing immediately - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel] - ); - - const handleScrub = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rect = ruler.getBoundingClientRect(); - const rawX = e.clientX - rect.left; - - // Get the timeline content width based on duration and zoom - const timelineContentWidth = duration * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 - - // Constrain x to be within the timeline content bounds - const x = Math.max(0, Math.min(timelineContentWidth, rawX)); - - const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - // Use frame snapping for playhead scrubbing - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - const time = snapTimeToFrame(rawTime, projectFps); - - // Debug logging - if (rawX < 0 || x !== rawX) { - console.log( - "PLAYHEAD DEBUG:", - JSON.stringify({ - mouseX: e.clientX, - rulerLeft: rect.left, - rawX, - constrainedX: x, - timelineContentWidth, - rawTime, - finalTime: time, - duration, - zoomLevel, - playheadPx: time * 50 * zoomLevel, - }) - ); - } - - setScrubTime(time); - seek(time); // update video preview in real time - - // Store mouse position for auto-scrolling - lastMouseXRef.current = e.clientX; - }, - [duration, zoomLevel, seek, rulerRef] - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * 50 * zoomLevel, - }); - - // Mouse move/up event handlers - useEffect(() => { - if (!isScrubbing) return; - - const onMouseMove = (e: MouseEvent) => { - handleScrub(e); - // Mark that we've dragged if ruler drag is active - if (isDraggingRuler) { - setHasDraggedRuler(true); - } - }; - - const onMouseUp = (e: MouseEvent) => { - setIsScrubbing(false); - if (scrubTime !== null) seek(scrubTime); // finalize seek - setScrubTime(null); - - // Handle ruler click vs drag - if (isDraggingRuler) { - setIsDraggingRuler(false); - // If we didn't drag, treat it as a click-to-seek - if (!hasDraggedRuler) { - handleScrub(e); - } - setHasDraggedRuler(false); - } - }; - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - // Edge auto-scroll is handled by useEdgeAutoScroll - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - // nothing to cleanup for edge auto scroll - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - // edge auto scroll hook is independent - ]); - - // --- Playhead auto-scroll effect (only during playback) --- - useEffect(() => { - const { isPlaying } = usePlaybackStore.getState(); - - // Only auto-scroll during playback, not during manual interactions - if (!isPlaying || isScrubbing) return; - - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 - const viewportWidth = rulerViewport.clientWidth; - const scrollMin = 0; - const scrollMax = rulerViewport.scrollWidth - viewportWidth; - - // Only auto-scroll if playhead is completely out of view (no buffer) - const needsScroll = - playheadPx < rulerViewport.scrollLeft || - playheadPx > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - // Center the playhead in the viewport - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2) - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [ - playheadPosition, - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - isScrubbing, - ]); - - return { - playheadPosition, - handlePlayheadMouseDown, - handleRulerMouseDown, - isDraggingRuler, - }; -} diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts deleted file mode 100644 index f7f96392..00000000 --- a/apps/web/src/hooks/use-timeline-snapping.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { useCallback } from "react"; -import { TimelineTrack } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -export interface UseTimelineSnappingOptions { - snapThreshold?: number; // Distance in pixels to trigger snapping - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; -} - -export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, -}: UseTimelineSnappingOptions = {}) { - const findSnapPoints = useCallback( - ( - tracks: TimelineTrack[], - currentTime: number, - playheadTime: number, - zoomLevel: number, - excludeElementId?: string - ): SnapPoint[] => { - const snapPoints: SnapPoint[] = []; - - // Add element snap points - if (enableElementSnapping) { - tracks.forEach((track) => { - track.elements.forEach((element) => { - // Skip the element being dragged - if (element.id === excludeElementId) return; - - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - snapPoints.push( - { - time: elementStart, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: elementEnd, - type: "element-end", - elementId: element.id, - trackId: track.id, - } - ); - }); - }); - } - - // Add playhead snap point - if (enablePlayheadSnapping) { - snapPoints.push({ - time: playheadTime, - type: "playhead", - }); - } - - return snapPoints; - }, - [enableElementSnapping, enablePlayheadSnapping] - ); - - const snapToNearestPoint = useCallback( - ( - targetTime: number, - snapPoints: SnapPoint[], - zoomLevel: number - ): SnapResult => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - snapPoints.forEach((snapPoint) => { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - }); - - return { - snappedTime: closestSnapPoint - ? (closestSnapPoint as SnapPoint).time - : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; - }, - [snapThreshold] - ); - - const snapElementPosition = useCallback( - ( - targetTime: number, - tracks: TimelineTrack[], - playheadTime: number, - zoomLevel: number, - excludeElementId?: string - ): SnapResult => { - const snapPoints = findSnapPoints( - tracks, - targetTime, - playheadTime, - zoomLevel, - excludeElementId - ); - - return snapToNearestPoint(targetTime, snapPoints, zoomLevel); - }, - [findSnapPoints, snapToNearestPoint] - ); - - const snapElementEdge = useCallback( - ( - targetTime: number, - elementDuration: number, - tracks: TimelineTrack[], - playheadTime: number, - zoomLevel: number, - excludeElementId?: string, - snapToStart = true // true for start edge, false for end edge - ): SnapResult => { - const snapPoints = findSnapPoints( - tracks, - targetTime, - playheadTime, - zoomLevel, - excludeElementId - ); - - // For end edge snapping, we need to account for element duration - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - const snapResult = snapToNearestPoint( - effectiveTargetTime, - snapPoints, - zoomLevel - ); - - // Adjust the snapped time back for end edge - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; - }, - [findSnapPoints, snapToNearestPoint] - ); - - return { - snapElementPosition, - snapElementEdge, - findSnapPoints, - snapToNearestPoint, - }; -} diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts deleted file mode 100644 index e764c550..00000000 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useCallback, useEffect, RefObject } from "react"; - -interface UseTimelineZoomProps { - containerRef: RefObject<HTMLDivElement>; - isInTimeline?: boolean; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleWheel: (e: React.WheelEvent) => void; -} - -export function useTimelineZoom({ - containerRef, - isInTimeline = false, -}: UseTimelineZoomProps): UseTimelineZoomReturn { - const [zoomLevel, setZoomLevel] = useState(1); - - const handleWheel = useCallback((e: React.WheelEvent) => { - // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.15 : 0.15; - setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); - } - // For horizontal scrolling (when shift is held or horizontal wheel movement), - // let the event bubble up to allow ScrollArea to handle it - else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Don't prevent default - let ScrollArea handle horizontal scrolling - return; - } - // Otherwise, allow normal scrolling - }, []); - - // Prevent browser zooming in/out when in timeline - useEffect(() => { - const preventZoom = (e: WheelEvent) => { - if ( - isInTimeline && - (e.ctrlKey || e.metaKey) && - containerRef.current?.contains(e.target as Node) - ) { - e.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { passive: false }); - - return () => { - document.removeEventListener("wheel", preventZoom); - }; - }, [isInTimeline, containerRef]); - - return { - zoomLevel, - setZoomLevel, - handleWheel, - }; -} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts deleted file mode 100644 index dcc02dcd..00000000 --- a/apps/web/src/hooks/use-toast.ts +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -// Inspired by react-hot-toast library -import * as React from "react"; - -import type { ToastActionElement, ToastProps } from "../components/ui/toast"; - -const TOAST_LIMIT = 1; -const TOAST_REMOVE_DELAY = 1_000_000; - -type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; -}; - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const; - -let count = 0; - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER; - return count.toString(); -} - -type ActionType = typeof actionTypes; - -type Action = - | { - type: ActionType["ADD_TOAST"]; - toast: ToasterToast; - } - | { - type: ActionType["UPDATE_TOAST"]; - toast: Partial<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); -}; - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - }; - - case "DISMISS_TOAST": { - const { toastId } = action; - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - }; - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } -}; - -const listeners: Array<(state: State) => void> = []; - -let memoryState: State = { toasts: [] }; - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); -} - -type Toast = Omit<ToasterToast, "id">; - -function toast({ ...props }: Toast) { - const id = genId(); - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); - - return { - id, - dismiss, - update, - }; -} - -function useToast() { - const [state, setState] = React.useState<State>(memoryState); - - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - }; -} - -export { useToast, toast }; |
