From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- .../timeline/element/use-element-selection.ts | 274 ++--- .../timeline/element/use-keyframe-selection.ts | 458 ++++---- apps/web/src/hooks/timeline/use-bookmark-drag.ts | 562 ++++----- .../web/src/hooks/timeline/use-edge-auto-scroll.ts | 194 +-- .../hooks/timeline/use-initial-scroll-bottom.ts | 84 +- apps/web/src/hooks/timeline/use-scroll-sync.ts | 346 +++--- .../hooks/timeline/use-snap-indicator-position.ts | 108 +- .../src/hooks/timeline/use-timeline-drag-drop.ts | 1218 +++++++++---------- apps/web/src/hooks/timeline/use-timeline-seek.ts | 394 +++---- apps/web/src/hooks/use-editor.ts | 116 +- apps/web/src/hooks/use-element-preview.ts | 76 +- apps/web/src/hooks/use-focus-lock.ts | 158 +-- apps/web/src/hooks/use-font-atlas.ts | 106 +- apps/web/src/hooks/use-infinite-scroll.ts | 70 +- apps/web/src/hooks/use-mask-handles.ts | 516 ++++---- apps/web/src/hooks/use-menu-preview.ts | 52 +- apps/web/src/hooks/use-mobile.ts | 38 +- apps/web/src/hooks/use-paste-media.ts | 228 ++-- apps/web/src/hooks/use-resize-observer.ts | 52 +- apps/web/src/hooks/use-shift-key.ts | 70 +- apps/web/src/hooks/use-sound-search.ts | 308 ++--- apps/web/src/hooks/use-storage-persistence.ts | 90 +- apps/web/src/hooks/use-transform-handles.ts | 1238 ++++++++++---------- 23 files changed, 3378 insertions(+), 3378 deletions(-) (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts index dcfb5720..42187b7f 100644 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts @@ -1,137 +1,137 @@ -import { useCallback } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import type { ElementRef } from "@/lib/timeline/types"; - -export function useElementSelection() { - const editor = useEditor(); - const selectedElements = useEditor((e) => e.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], - ); - - - /** - * Merges elements into the current selection, deduplicating by identity. - * Used for additive box-select where the pre-drag selection is preserved. - */ - const mergeElementsIntoSelection = useCallback( - ({ elements }: { elements: ElementRef[] }) => { - const merged = [ - ...selectedElements.filter( - (selectedElement) => - !elements.some( - (element) => - element.trackId === selectedElement.trackId && - element.elementId === selectedElement.elementId, - ), - ), - ...elements, - ]; - editor.selection.setSelectedElements({ elements: merged }); - }, - [selectedElements, 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, - mergeElementsIntoSelection, - addElementToSelection, - removeElementFromSelection, - toggleElementSelection, - clearElementSelection, - handleElementClick, - }; -} +import { useCallback } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { ElementRef } from "@/lib/timeline/types"; + +export function useElementSelection() { + const editor = useEditor(); + const selectedElements = useEditor((e) => e.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], + ); + + + /** + * Merges elements into the current selection, deduplicating by identity. + * Used for additive box-select where the pre-drag selection is preserved. + */ + const mergeElementsIntoSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + const merged = [ + ...selectedElements.filter( + (selectedElement) => + !elements.some( + (element) => + element.trackId === selectedElement.trackId && + element.elementId === selectedElement.elementId, + ), + ), + ...elements, + ]; + editor.selection.setSelectedElements({ elements: merged }); + }, + [selectedElements, 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, + mergeElementsIntoSelection, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, + clearElementSelection, + handleElementClick, + }; +} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts index 8634cb48..1a219d1f 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts @@ -1,229 +1,229 @@ -import { useCallback, useSyncExternalStore } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; - -function getSelectedKeyframeId({ - keyframe, -}: { - keyframe: SelectedKeyframeRef; -}): string { - return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; -} - -function mergeUniqueKeyframes({ - keyframes, -}: { - keyframes: SelectedKeyframeRef[]; -}): SelectedKeyframeRef[] { - const keyframesById = new Map(); - for (const keyframe of keyframes) { - keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); - } - return [...keyframesById.values()]; -} - -export function useKeyframeSelection() { - const editor = useEditor(); - const selectedKeyframes = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedKeyframes(), - ); - const keyframeSelectionAnchor = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getKeyframeSelectionAnchor(), - ); - - const isKeyframeSelected = useCallback( - ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { - const keyframeId = getSelectedKeyframeId({ keyframe }); - return selectedKeyframes.some( - (selectedKeyframe) => - getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, - ); - }, - [selectedKeyframes], - ); - - const setKeyframeSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); - editor.selection.setSelectedKeyframes({ - keyframes: uniqueKeyframes, - anchorKeyframe: - anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, - }); - }, - [editor], - ); - - const addKeyframesToSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const mergedKeyframes = mergeUniqueKeyframes({ - keyframes: [...selectedKeyframes, ...keyframes], - }); - editor.selection.setSelectedKeyframes({ - keyframes: mergedKeyframes, - anchorKeyframe: - anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, - }); - }, - [selectedKeyframes, editor], - ); - - const removeKeyframesFromSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const keyframeIdsToRemove = new Set( - keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - ); - const nextKeyframes = selectedKeyframes.filter( - (selectedKeyframe) => - !keyframeIdsToRemove.has( - getSelectedKeyframeId({ keyframe: selectedKeyframe }), - ), - ); - editor.selection.setSelectedKeyframes({ - keyframes: nextKeyframes, - anchorKeyframe: - anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, - }); - }, - [selectedKeyframes, editor], - ); - - const clearKeyframeSelection = useCallback(() => { - editor.selection.clearKeyframeSelection(); - }, [editor]); - - const toggleKeyframeSelection = useCallback( - ({ - keyframes, - isMultiKey, - }: { - keyframes: SelectedKeyframeRef[]; - isMultiKey: boolean; - }) => { - const anchorKeyframe = keyframes[0]; - const areAllKeyframesSelected = keyframes.every((keyframe) => - isKeyframeSelected({ keyframe }), - ); - if (!isMultiKey) { - setKeyframeSelection({ keyframes, anchorKeyframe }); - return; - } - - if (areAllKeyframesSelected) { - removeKeyframesFromSelection({ keyframes, anchorKeyframe }); - return; - } - - addKeyframesToSelection({ keyframes, anchorKeyframe }); - }, - [ - setKeyframeSelection, - isKeyframeSelected, - removeKeyframesFromSelection, - addKeyframesToSelection, - ], - ); - - const selectKeyframeRange = useCallback( - ({ - orderedKeyframes, - targetKeyframes, - isAdditive, - }: { - orderedKeyframes: SelectedKeyframeRef[]; - targetKeyframes: SelectedKeyframeRef[]; - isAdditive: boolean; - }) => { - if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { - return; - } - - const anchorKeyframe = - keyframeSelectionAnchor ?? - selectedKeyframes[selectedKeyframes.length - 1] ?? - targetKeyframes[0]; - if (!anchorKeyframe) { - return; - } - - const targetKeyframeIds = new Set( - targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - ); - const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); - const anchorIndex = orderedKeyframes.findIndex( - (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, - ); - if (anchorIndex === -1) { - if (isAdditive) { - addKeyframesToSelection({ - keyframes: targetKeyframes, - anchorKeyframe, - }); - return; - } - setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); - return; - } - - const targetIndexes = orderedKeyframes - .map((keyframe, index) => ({ - keyframeId: getSelectedKeyframeId({ keyframe }), - index, - })) - .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) - .map(({ index }) => index); - if (targetIndexes.length === 0) { - return; - } - - const rangeStart = Math.min(anchorIndex, ...targetIndexes); - const rangeEnd = Math.max(anchorIndex, ...targetIndexes); - const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); - - if (isAdditive) { - addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); - return; - } - - setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); - }, - [ - keyframeSelectionAnchor, - selectedKeyframes, - addKeyframesToSelection, - setKeyframeSelection, - ], - ); - - return { - selectedKeyframes, - keyframeSelectionAnchor, - isKeyframeSelected, - setKeyframeSelection, - addKeyframesToSelection, - removeKeyframesFromSelection, - clearKeyframeSelection, - toggleKeyframeSelection, - selectKeyframeRange, - }; -} +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { SelectedKeyframeRef } from "@/lib/animation/types"; + +function getSelectedKeyframeId({ + keyframe, +}: { + keyframe: SelectedKeyframeRef; +}): string { + return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; +} + +function mergeUniqueKeyframes({ + keyframes, +}: { + keyframes: SelectedKeyframeRef[]; +}): SelectedKeyframeRef[] { + const keyframesById = new Map(); + for (const keyframe of keyframes) { + keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); + } + return [...keyframesById.values()]; +} + +export function useKeyframeSelection() { + const editor = useEditor(); + const selectedKeyframes = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedKeyframes(), + ); + const keyframeSelectionAnchor = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getKeyframeSelectionAnchor(), + ); + + const isKeyframeSelected = useCallback( + ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { + const keyframeId = getSelectedKeyframeId({ keyframe }); + return selectedKeyframes.some( + (selectedKeyframe) => + getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, + ); + }, + [selectedKeyframes], + ); + + const setKeyframeSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); + editor.selection.setSelectedKeyframes({ + keyframes: uniqueKeyframes, + anchorKeyframe: + anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, + }); + }, + [editor], + ); + + const addKeyframesToSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const mergedKeyframes = mergeUniqueKeyframes({ + keyframes: [...selectedKeyframes, ...keyframes], + }); + editor.selection.setSelectedKeyframes({ + keyframes: mergedKeyframes, + anchorKeyframe: + anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const removeKeyframesFromSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const keyframeIdsToRemove = new Set( + keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const nextKeyframes = selectedKeyframes.filter( + (selectedKeyframe) => + !keyframeIdsToRemove.has( + getSelectedKeyframeId({ keyframe: selectedKeyframe }), + ), + ); + editor.selection.setSelectedKeyframes({ + keyframes: nextKeyframes, + anchorKeyframe: + anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const clearKeyframeSelection = useCallback(() => { + editor.selection.clearKeyframeSelection(); + }, [editor]); + + const toggleKeyframeSelection = useCallback( + ({ + keyframes, + isMultiKey, + }: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => { + const anchorKeyframe = keyframes[0]; + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + if (areAllKeyframesSelected) { + removeKeyframesFromSelection({ keyframes, anchorKeyframe }); + return; + } + + addKeyframesToSelection({ keyframes, anchorKeyframe }); + }, + [ + setKeyframeSelection, + isKeyframeSelected, + removeKeyframesFromSelection, + addKeyframesToSelection, + ], + ); + + const selectKeyframeRange = useCallback( + ({ + orderedKeyframes, + targetKeyframes, + isAdditive, + }: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => { + if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { + return; + } + + const anchorKeyframe = + keyframeSelectionAnchor ?? + selectedKeyframes[selectedKeyframes.length - 1] ?? + targetKeyframes[0]; + if (!anchorKeyframe) { + return; + } + + const targetKeyframeIds = new Set( + targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); + const anchorIndex = orderedKeyframes.findIndex( + (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, + ); + if (anchorIndex === -1) { + if (isAdditive) { + addKeyframesToSelection({ + keyframes: targetKeyframes, + anchorKeyframe, + }); + return; + } + setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); + return; + } + + const targetIndexes = orderedKeyframes + .map((keyframe, index) => ({ + keyframeId: getSelectedKeyframeId({ keyframe }), + index, + })) + .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) + .map(({ index }) => index); + if (targetIndexes.length === 0) { + return; + } + + const rangeStart = Math.min(anchorIndex, ...targetIndexes); + const rangeEnd = Math.max(anchorIndex, ...targetIndexes); + const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); + + if (isAdditive) { + addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + return; + } + + setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + }, + [ + keyframeSelectionAnchor, + selectedKeyframes, + addKeyframesToSelection, + setKeyframeSelection, + ], + ); + + return { + selectedKeyframes, + keyframeSelectionAnchor, + isKeyframeSelected, + setKeyframeSelection, + addKeyframesToSelection, + removeKeyframesFromSelection, + clearKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + }; +} diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index 512db821..e7e70495 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -1,281 +1,281 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type RefObject, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; -import { snapTimeToFrame } from "@/lib/time"; -import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; -import { - findSnapPoints, - snapToNearestPoint, - type SnapPoint, -} from "@/lib/timeline/snap-utils"; -import type { Bookmark } from "@/lib/timeline"; - -export interface BookmarkDragState { - isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; -} - -interface PendingBookmarkDrag { - bookmarkTime: number; - startMouseX: number; - startMouseY: number; -} - -interface UseBookmarkDragProps { - zoomLevel: number; - scrollRef: RefObject; - snappingEnabled: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -} - -export function useBookmarkDrag({ - zoomLevel, - scrollRef, - snappingEnabled, - onSnapPointChange, -}: UseBookmarkDragProps) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const tracks = editor.timeline.getTracks(); - const activeScene = editor.scenes.getActiveScene(); - const bookmarks = activeScene?.bookmarks ?? []; - const playheadTime = editor.playback.getCurrentTime(); - const duration = editor.timeline.getTotalDuration(); - - const [dragState, setDragState] = useState({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const lastMouseXRef = useRef(0); - - const startDrag = useCallback( - ({ - bookmarkTime, - initialCurrentTime, - }: { - bookmarkTime: number; - initialCurrentTime: number; - }) => { - setDragState({ - isDragging: true, - bookmarkTime, - currentTime: initialCurrentTime, - }); - }, - [], - ); - - const endDrag = useCallback(() => { - setDragState({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - }, []); - - const getSnapResult = useCallback( - ({ - rawTime, - excludeBookmarkTime, - }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - if (!shouldSnap) { - return { snappedTime: rawTime, snapPoint: null }; - } - - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - bookmarks, - excludeBookmarkTime, - }); - const result = snapToNearestPoint({ - targetTime: rawTime, - snapPoints, - zoomLevel, - }); - return { - snappedTime: result.snappedTime, - snapPoint: result.snapPoint, - }; - }, - [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = (event: MouseEvent) => { - lastMouseXRef.current = event.clientX; - - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - if (isPendingDrag && pendingDragRef.current) { - const { startMouseX, startMouseY, bookmarkTime } = - pendingDragRef.current; - const deltaX = Math.abs(event.clientX - startMouseX); - const deltaY = Math.abs(event.clientY - startMouseY); - - if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { - return; - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX: event.clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const frameSnappedTime = snapTimeToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), - fps: activeProject.settings.fps, - }); - const { snappedTime: initialTime } = getSnapResult({ - rawTime: frameSnappedTime, - excludeBookmarkTime: bookmarkTime, - }); - - startDrag({ - bookmarkTime, - initialCurrentTime: initialTime, - }); - pendingDragRef.current = null; - setIsPendingDrag(false); - return; - } - - if (!dragState.isDragging || dragState.bookmarkTime === null) return; - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX: event.clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = snapTimeToFrame({ - time: clampedTime, - fps: activeProject.settings.fps, - }); - const snapResult = getSnapResult({ - rawTime: frameSnappedTime, - excludeBookmarkTime: dragState.bookmarkTime, - }); - - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snapResult.snappedTime, - })); - onSnapPointChange?.(snapResult.snapPoint); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.bookmarkTime, - zoomLevel, - duration, - editor.project, - scrollRef, - isPendingDrag, - startDrag, - getSnapResult, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - if (dragState.bookmarkTime === null) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); - - editor.scenes.moveBookmark({ - fromTime: dragState.bookmarkTime, - toTime: clampedTime, - }); - - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.bookmarkTime, - dragState.currentTime, - duration, - endDrag, - onSnapPointChange, - editor.scenes, - ]); - - 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 handleBookmarkMouseDown = useCallback( - ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { - if (event.button !== 0) return; - - event.preventDefault(); - event.stopPropagation(); - - pendingDragRef.current = { - bookmarkTime: bookmark.time, - startMouseX: event.clientX, - startMouseY: event.clientY, - }; - setIsPendingDrag(true); - }, - [], - ); - - return { - dragState, - handleBookmarkMouseDown, - lastMouseXRef, - }; -} +import { + useState, + useCallback, + useEffect, + useRef, + type RefObject, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; +import type { Bookmark } from "@/lib/timeline"; + +export interface BookmarkDragState { + isDragging: boolean; + bookmarkTime: number | null; + currentTime: number; +} + +interface PendingBookmarkDrag { + bookmarkTime: number; + startMouseX: number; + startMouseY: number; +} + +interface UseBookmarkDragProps { + zoomLevel: number; + scrollRef: RefObject; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export function useBookmarkDrag({ + zoomLevel, + scrollRef, + snappingEnabled, + onSnapPointChange, +}: UseBookmarkDragProps) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const tracks = editor.timeline.getTracks(); + const activeScene = editor.scenes.getActiveScene(); + const bookmarks = activeScene?.bookmarks ?? []; + const playheadTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + + const [dragState, setDragState] = useState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef(null); + const lastMouseXRef = useRef(0); + + const startDrag = useCallback( + ({ + bookmarkTime, + initialCurrentTime, + }: { + bookmarkTime: number; + initialCurrentTime: number; + }) => { + setDragState({ + isDragging: true, + bookmarkTime, + currentTime: initialCurrentTime, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + }, []); + + const getSnapResult = useCallback( + ({ + rawTime, + excludeBookmarkTime, + }: { + rawTime: number; + excludeBookmarkTime: number; + }): { snappedTime: number; snapPoint: SnapPoint | null } => { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap) { + return { snappedTime: rawTime, snapPoint: null }; + } + + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + bookmarks, + excludeBookmarkTime, + }); + const result = snapToNearestPoint({ + targetTime: rawTime, + snapPoints, + zoomLevel, + }); + return { + snappedTime: result.snappedTime, + snapPoint: result.snapPoint, + }; + }, + [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = (event: MouseEvent) => { + lastMouseXRef.current = event.clientX; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + if (isPendingDrag && pendingDragRef.current) { + const { startMouseX, startMouseY, bookmarkTime } = + pendingDragRef.current; + const deltaX = Math.abs(event.clientX - startMouseX); + const deltaY = Math.abs(event.clientY - startMouseY); + + if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { + return; + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const frameSnappedTime = snapTimeToFrame({ + time: Math.max(0, Math.min(mouseTime, duration)), + fps: activeProject.settings.fps, + }); + const { snappedTime: initialTime } = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: bookmarkTime, + }); + + startDrag({ + bookmarkTime, + initialCurrentTime: initialTime, + }); + pendingDragRef.current = null; + setIsPendingDrag(false); + return; + } + + if (!dragState.isDragging || dragState.bookmarkTime === null) return; + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const clampedTime = Math.max(0, Math.min(mouseTime, duration)); + const frameSnappedTime = snapTimeToFrame({ + time: clampedTime, + fps: activeProject.settings.fps, + }); + const snapResult = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: dragState.bookmarkTime, + }); + + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snapResult.snappedTime, + })); + onSnapPointChange?.(snapResult.snapPoint); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + zoomLevel, + duration, + editor.project, + scrollRef, + isPendingDrag, + startDrag, + getSnapResult, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + if (dragState.bookmarkTime === null) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const clampedTime = Math.max( + 0, + Math.min(dragState.currentTime, duration), + ); + + editor.scenes.moveBookmark({ + fromTime: dragState.bookmarkTime, + toTime: clampedTime, + }); + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + dragState.currentTime, + duration, + endDrag, + onSnapPointChange, + editor.scenes, + ]); + + 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 handleBookmarkMouseDown = useCallback( + ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { + if (event.button !== 0) return; + + event.preventDefault(); + event.stopPropagation(); + + pendingDragRef.current = { + bookmarkTime: bookmark.time, + startMouseX: event.clientX, + startMouseY: event.clientY, + }; + setIsPendingDrag(true); + }, + [], + ); + + return { + dragState, + handleBookmarkMouseDown, + lastMouseXRef, + }; +} diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts index fc5df4d9..509031bb 100644 --- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts +++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts @@ -1,97 +1,97 @@ -import { useEffect, useRef } from "react"; - -interface UseEdgeAutoScrollParams { - isActive: boolean; - getMouseClientX: () => number; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - contentWidth: number; - edgeThreshold?: number; - maxScrollSpeed?: number; -} - -export function useEdgeAutoScroll({ - isActive, - getMouseClientX, - rulerScrollRef, - tracksScrollRef, - contentWidth, - edgeThreshold = 100, - maxScrollSpeed = 15, -}: UseEdgeAutoScrollParams): void { - const rafRef = useRef(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 - 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, - ]); -} +import { useEffect, useRef } from "react"; + +interface UseEdgeAutoScrollParams { + isActive: boolean; + getMouseClientX: () => number; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + contentWidth: number; + edgeThreshold?: number; + maxScrollSpeed?: number; +} + +export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, +}: UseEdgeAutoScrollParams): void { + const rafRef = useRef(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 - 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-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts index 50833f14..0b1cf92a 100644 --- a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts +++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts @@ -1,42 +1,42 @@ -import { type RefObject, useLayoutEffect, useRef } from "react"; - -interface UseInitialScrollBottomProps { - tracksScrollRef: RefObject; - trackLabelsScrollRef: RefObject; - onAfterScroll?: () => void; - /** Defers the scroll until there is at least one track to measure against. */ - isReady: boolean; -} - -/** - * Scrolls the timeline tracks viewport to the bottom once on mount. - * useLayoutEffect runs synchronously after React commits the DOM but before - * the browser paints, so the initial scroll position is never visible. - */ -export function useInitialScrollBottom({ - tracksScrollRef, - trackLabelsScrollRef, - onAfterScroll, - isReady, -}: UseInitialScrollBottomProps): void { - const hasScrolledRef = useRef(false); - - useLayoutEffect(() => { - if (!isReady || hasScrolledRef.current) return; - - const viewport = tracksScrollRef.current; - if (!viewport) return; - - const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; - if (maxScrollTop <= 0) return; - - viewport.scrollTop = maxScrollTop; - - if (trackLabelsScrollRef.current) { - trackLabelsScrollRef.current.scrollTop = maxScrollTop; - } - - onAfterScroll?.(); - hasScrolledRef.current = true; - }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]); -} +import { type RefObject, useLayoutEffect, useRef } from "react"; + +interface UseInitialScrollBottomProps { + tracksScrollRef: RefObject; + trackLabelsScrollRef: RefObject; + onAfterScroll?: () => void; + /** Defers the scroll until there is at least one track to measure against. */ + isReady: boolean; +} + +/** + * Scrolls the timeline tracks viewport to the bottom once on mount. + * useLayoutEffect runs synchronously after React commits the DOM but before + * the browser paints, so the initial scroll position is never visible. + */ +export function useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll, + isReady, +}: UseInitialScrollBottomProps): void { + const hasScrolledRef = useRef(false); + + useLayoutEffect(() => { + if (!isReady || hasScrolledRef.current) return; + + const viewport = tracksScrollRef.current; + if (!viewport) return; + + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) return; + + viewport.scrollTop = maxScrollTop; + + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = maxScrollTop; + } + + onAfterScroll?.(); + hasScrolledRef.current = true; + }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]); +} diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts index 61465a3d..e147c106 100644 --- a/apps/web/src/hooks/timeline/use-scroll-sync.ts +++ b/apps/web/src/hooks/timeline/use-scroll-sync.ts @@ -1,173 +1,173 @@ -import { useEffect, useRef } from "react"; - -interface UseScrollSyncProps { - rulerScrollRef?: React.RefObject; - tracksScrollRef: React.RefObject; - trackLabelsScrollRef?: React.RefObject; - bookmarksScrollRef?: React.RefObject; -} - -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, - ]); -} +import { useEffect, useRef } from "react"; + +interface UseScrollSyncProps { + rulerScrollRef?: React.RefObject; + tracksScrollRef: React.RefObject; + trackLabelsScrollRef?: React.RefObject; + bookmarksScrollRef?: React.RefObject; +} + +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-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts index 1a8f3c5d..3dc34b7f 100644 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts @@ -1,54 +1,54 @@ -import { useEffect, useState } from "react"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants"; -interface UseSnapIndicatorPositionParams { - snapPoint: { time: number } | null; - zoomLevel: number; - timelineRef: React.RefObject; - tracksScrollRef: React.RefObject; -} - -interface SnapIndicatorPosition { - leftPosition: number; - topPosition: number; - height: number; -} - -export function useSnapIndicatorPosition({ - snapPoint, - zoomLevel, - timelineRef, - 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 timelinePosition = timelineTimeToSnappedPixels({ - time: snapPoint?.time ?? 0, - zoomLevel, - }); - const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft; - - return { - leftPosition, - topPosition: 0, - height: totalHeight, - }; -} +import { useEffect, useState } from "react"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants"; +interface UseSnapIndicatorPositionParams { + snapPoint: { time: number } | null; + zoomLevel: number; + timelineRef: React.RefObject; + tracksScrollRef: React.RefObject; +} + +interface SnapIndicatorPosition { + leftPosition: number; + topPosition: number; + height: number; +} + +export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + timelineRef, + 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 timelinePosition = timelineTimeToSnappedPixels({ + time: snapPoint?.time ?? 0, + zoomLevel, + }); + const leftPosition = TRACK_LABELS_WIDTH_PX + 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 index e8f6e49d..7cd9f140 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -1,609 +1,609 @@ -import { useState, useCallback, type RefObject } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { processMediaAssets } from "@/lib/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { snapTimeToFrame } from "@/lib/time"; -import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/lib/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; -import { computeDropTarget } from "@/lib/timeline/drop-utils"; -import { getDragData, hasDragData } from "@/lib/drag-data"; -import { isMainTrack } from "@/lib/timeline/track-utils"; -import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/lib/timeline/drag"; - -interface UseTimelineDragDropProps { - containerRef: RefObject; - headerRef?: RefObject; - tracksScrollRef?: RefObject; - zoomLevel: number; -} - -export function useTimelineDragDrop({ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, -}: UseTimelineDragDropProps) { - const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return snapTimeToFrame({ time, fps: projectFps }); - }, - [editor], - ); - - 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 === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); - - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): number => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - } - return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - }, - [editor], - ); - - 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 scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 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 - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const tracks = editor.timeline.getTracks(); - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - 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 }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = editor.timeline.getTracks(); - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = editor.timeline.getTracks(); - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = editor.timeline.getTracks(); - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = editor.timeline.getTracks(); - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = editor.timeline.getTracks(); - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const currentTracks = editor.timeline.getTracks(); - const currentTime = editor.playback.getCurrentTime(); - const onlyTrack = currentTracks[0]; - const reuseMainTrackId = - createdAsset.type !== "audio" && - currentTracks.length === 1 && - !!onlyTrack && - isMainTrack(onlyTrack) && - onlyTrack.elements.length === 0 - ? onlyTrack.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: currentTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - let trackId: string | undefined; - if (reuseMainTrackId) { - trackId = reuseMainTrackId; - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - trackId = addTrackCmd.getTrackId(); - editor.command.execute({ command: addTrackCmd }); - } else { - trackId = currentTracks[dropTarget.trackIndex]?.id; - } - } - - if (!trackId) continue; - - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime: dropTarget?.xPosition ?? currentTime, - buffer: - createdAsset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); - - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }); - editor.command.execute({ command: insertCmd }); - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, 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 === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); - - return { - isDragOver, - dropTarget, - dragElementType, - dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }, - }; -} +import { useState, useCallback, type RefObject } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { toast } from "sonner"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { snapTimeToFrame } from "@/lib/time"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/lib/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; +import { computeDropTarget } from "@/lib/timeline/drop-utils"; +import { getDragData, hasDragData } from "@/lib/drag-data"; +import { isMainTrack } from "@/lib/timeline/track-utils"; +import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; +import type { + MediaDragData, + GraphicDragData, + StickerDragData, + EffectDragData, +} from "@/lib/timeline/drag"; + +interface UseTimelineDragDropProps { + containerRef: RefObject; + headerRef?: RefObject; + tracksScrollRef?: RefObject; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + tracksScrollRef, + zoomLevel, +}: UseTimelineDragDropProps) { + const editor = useEditor(); + const [isDragOver, setIsDragOver] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + const [dragElementType, setElementType] = useState(null); + + const getSnappedTime = useCallback( + ({ time }: { time: number }) => { + const projectFps = editor.project.getActive().settings.fps; + return snapTimeToFrame({ time, fps: projectFps }); + }, + [editor], + ); + + 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 === "graphic") return "graphic"; + if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "effect") return "effect"; + if (dragData.type === "media") { + return dragData.mediaType; + } + return null; + }, + [], + ); + + const getElementDuration = useCallback( + ({ + elementType, + mediaId, + }: { + elementType: ElementType; + mediaId?: string; + }): number => { + if ( + elementType === "text" || + elementType === "graphic" || + elementType === "sticker" || + elementType === "effect" + ) { + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + if (mediaId) { + const mediaAssets = editor.media.getAssets(); + const media = mediaAssets.find((m) => m.id === mediaId); + return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + } + return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + }, + [editor], + ); + + 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 scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 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 - referenceRect.left + scrollLeft; + const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; + + const targetElementTypes = + dragData?.type === "effect" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; + + const tracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const target = computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime: currentTime, + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + targetElementTypes, + }); + + target.xPosition = getSnappedTime({ time: target.xPosition }); + + setDropTarget(target); + e.dataTransfer.dropEffect = "copy"; + }, + [ + containerRef, + headerRef, + tracksScrollRef, + zoomLevel, + getElementType, + getElementDuration, + getSnappedTime, + editor, + ], + ); + + 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 }; + }) => { + const element = buildTextElement({ + raw: { + name: dragData.name ?? "", + content: dragData.content ?? "", + }, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("text", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeStickerDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: StickerDragData; + }) => { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeGraphicDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: GraphicDragData; + }) => { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeMediaDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + if (target.targetElement) { + toast.info("Replace media source is coming soon!"); + return; + } + + const mediaAssets = editor.media.getAssets(); + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + + const duration = + mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeEffectDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: EffectDragData; + }) => { + if (target.targetElement) { + editor.timeline.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const tracks = editor.timeline.getTracks(); + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack) { + trackId = effectTrack.id; + } else if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element: buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }), + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } else { + const track = tracks[target.trackIndex]; + if (!track || track.type !== "effect") return; + trackId = track.id; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor], + ); + + const executeFileDrop = useCallback( + async ({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }) => { + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const projectId = activeProject.metadata.id; + + for (const asset of processedAssets) { + const createdAsset = await editor.media.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = + createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const currentTracks = editor.timeline.getTracks(); + const currentTime = editor.playback.getCurrentTime(); + const onlyTrack = currentTracks[0]; + const reuseMainTrackId = + createdAsset.type !== "audio" && + currentTracks.length === 1 && + !!onlyTrack && + isMainTrack(onlyTrack) && + onlyTrack.elements.length === 0 + ? onlyTrack.id + : null; + const dropTarget = reuseMainTrackId + ? null + : computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: currentTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND, + zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + + let trackId: string | undefined; + if (reuseMainTrackId) { + trackId = reuseMainTrackId; + } else { + if (!dropTarget) continue; + if (dropTarget.isNewTrack) { + const addTrackCmd = new AddTrackCommand( + trackType, + dropTarget.trackIndex, + ); + trackId = addTrackCmd.getTrackId(); + editor.command.execute({ command: addTrackCmd }); + } else { + trackId = currentTracks[dropTarget.trackIndex]?.id; + } + } + + if (!trackId) continue; + + const element = buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget?.xPosition ?? currentTime, + buffer: + createdAsset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId }, + }); + editor.command.execute({ command: insertCmd }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + }, + [editor, 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 === "graphic") { + executeGraphicDrop({ + target: currentTarget, + dragData: dragData as GraphicDragData, + }); + } else if (dragData.type === "sticker") { + executeStickerDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "effect") { + executeEffectDrop({ + target: currentTarget, + dragData: dragData as EffectDragData, + }); + } else { + executeMediaDrop({ target: currentTarget, dragData }); + } + } else if (hasFiles) { + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const mouseX = e.clientX - referenceRect.left + scrollLeft; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseY = + e.clientY - referenceRect.top + scrollTop - headerHeight; + await executeFileDrop({ + files: Array.from(e.dataTransfer.files), + mouseX, + mouseY, + }); + } + } catch (err) { + console.error("Failed to process drop:", err); + } + }, + [ + dropTarget, + executeTextDrop, + executeGraphicDrop, + executeStickerDrop, + executeMediaDrop, + executeEffectDrop, + executeFileDrop, + containerRef, + headerRef, + tracksScrollRef, + ], + ); + + return { + isDragOver, + dropTarget, + dragElementType, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts index 127ebcbb..4abbf023 100644 --- a/apps/web/src/hooks/timeline/use-timeline-seek.ts +++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts @@ -1,197 +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; - trackLabelsRef: RefObject; - rulerScrollRef: RefObject; - tracksScrollRef: RefObject; - 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, - }; -} +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; + trackLabelsRef: RefObject; + rulerScrollRef: RefObject; + tracksScrollRef: RefObject; + 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/use-editor.ts b/apps/web/src/hooks/use-editor.ts index e211a85b..bafa6cf7 100644 --- a/apps/web/src/hooks/use-editor.ts +++ b/apps/web/src/hooks/use-editor.ts @@ -1,58 +1,58 @@ -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; -import { EditorCore } from "@/core"; - -function isShallowEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (!Array.isArray(a) || !Array.isArray(b)) return false; - if (a.length !== b.length) return false; - return a.every((item, i) => Object.is(item, b[i])); -} - -const subscribeNone = () => () => {}; - -export function useEditor(): EditorCore; -export function useEditor(selector: (editor: EditorCore) => T): T; -export function useEditor( - selector?: (editor: EditorCore) => T, -): EditorCore | T { - const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef(undefined); - - const subscribeAll = useCallback( - (onChange: () => void) => { - const unsubscribers = [ - editor.playback.subscribe(onChange), - editor.timeline.subscribe(onChange), - editor.scenes.subscribe(onChange), - editor.project.subscribe(onChange), - editor.media.subscribe(onChange), - editor.renderer.subscribe(onChange), - editor.selection.subscribe(onChange), - ]; - return () => { - unsubscribers.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }, - [editor], - ); - - const getSnapshot = useCallback(() => { - const next = selectorRef.current ? selectorRef.current(editor) : editor; - if (isShallowEqual(snapshotCacheRef.current, next)) { - return snapshotCacheRef.current; - } - snapshotCacheRef.current = next; - return next; - }, [editor]); - - return useSyncExternalStore( - selector ? subscribeAll : subscribeNone, - getSnapshot, - getSnapshot, - ) as EditorCore | T; -} +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import { EditorCore } from "@/core"; + +function isShallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + return a.every((item, i) => Object.is(item, b[i])); +} + +const subscribeNone = () => () => {}; + +export function useEditor(): EditorCore; +export function useEditor(selector: (editor: EditorCore) => T): T; +export function useEditor( + selector?: (editor: EditorCore) => T, +): EditorCore | T { + const editor = useMemo(() => EditorCore.getInstance(), []); + const selectorRef = useRef(selector); + selectorRef.current = selector; + + const snapshotCacheRef = useRef(undefined); + + const subscribeAll = useCallback( + (onChange: () => void) => { + const unsubscribers = [ + editor.playback.subscribe(onChange), + editor.timeline.subscribe(onChange), + editor.scenes.subscribe(onChange), + editor.project.subscribe(onChange), + editor.media.subscribe(onChange), + editor.renderer.subscribe(onChange), + editor.selection.subscribe(onChange), + ]; + return () => { + unsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }, + [editor], + ); + + const getSnapshot = useCallback(() => { + const next = selectorRef.current ? selectorRef.current(editor) : editor; + if (isShallowEqual(snapshotCacheRef.current, next)) { + return snapshotCacheRef.current; + } + snapshotCacheRef.current = next; + return next; + }, [editor]); + + return useSyncExternalStore( + selector ? subscribeAll : subscribeNone, + getSnapshot, + getSnapshot, + ) as EditorCore | T; +} diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts index 8aa16501..d717143d 100644 --- a/apps/web/src/hooks/use-element-preview.ts +++ b/apps/web/src/hooks/use-element-preview.ts @@ -1,38 +1,38 @@ -import { useEditor } from "@/hooks/use-editor"; -import type { TimelineElement } from "@/lib/timeline"; - -/** - * Subscribes to render tracks and returns the live (preview-aware) version of - * an element alongside helpers for previewing and committing updates. - * - * Use this wherever property fields need to reflect in-progress preview state - * (e.g. a slider being dragged) rather than the last committed value. - */ -export function useElementPreview({ - trackId, - elementId, - fallback, -}: { - trackId: string; - elementId: string; - fallback: T; -}) { - const editor = useEditor(); - useEditor((e) => e.timeline.getRenderTracks()); - - const renderElement = - (editor.timeline - .getRenderTracks() - .find((t) => t.id === trackId) - ?.elements.find((el) => el.id === elementId) as T | undefined) ?? - fallback; - - const previewUpdates = (updates: Partial) => - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates }], - }); - - const commit = () => editor.timeline.commitPreview(); - - return { renderElement, previewUpdates, commit }; -} +import { useEditor } from "@/hooks/use-editor"; +import type { TimelineElement } from "@/lib/timeline"; + +/** + * Subscribes to render tracks and returns the live (preview-aware) version of + * an element alongside helpers for previewing and committing updates. + * + * Use this wherever property fields need to reflect in-progress preview state + * (e.g. a slider being dragged) rather than the last committed value. + */ +export function useElementPreview({ + trackId, + elementId, + fallback, +}: { + trackId: string; + elementId: string; + fallback: T; +}) { + const editor = useEditor(); + useEditor((e) => e.timeline.getRenderTracks()); + + const renderElement = + (editor.timeline + .getRenderTracks() + .find((t) => t.id === trackId) + ?.elements.find((el) => el.id === elementId) as T | undefined) ?? + fallback; + + const previewUpdates = (updates: Partial) => + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates }], + }); + + const commit = () => editor.timeline.commitPreview(); + + return { renderElement, previewUpdates, commit }; +} diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts index 80baebdb..236d2623 100644 --- a/apps/web/src/hooks/use-focus-lock.ts +++ b/apps/web/src/hooks/use-focus-lock.ts @@ -1,79 +1,79 @@ -import { useEffect, useRef } from "react"; - -type FocusLockCursor = "text" | "default" | "pointer" | "crosshair"; - -const DATA_ATTR = "data-focus-locked"; - -function buildFocusLockCSS({ - cursor, - allowSelector, -}: { - cursor: FocusLockCursor; - allowSelector?: string; -}) { - const rules = [ - `*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`, - `[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`, - ]; - - if (allowSelector) { - rules.push( - `${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`, - ); - } - - return rules.join("\n"); -} - -export function useFocusLock({ - isActive, - onDismiss, - cursor = "default", - allowSelector, -}: { - isActive: boolean; - onDismiss: () => void; - cursor?: FocusLockCursor; - allowSelector?: string; -}) { - const containerRef = useRef(null); - const onDismissRef = useRef(onDismiss); - onDismissRef.current = onDismiss; - - useEffect(() => { - if (!isActive) return; - const container = containerRef.current; - if (!container) return; - - container.setAttribute(DATA_ATTR, ""); - - const focusLockStyle = document.createElement("style"); - focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector }); - document.head.appendChild(focusLockStyle); - - const handleOutsidePointerDown = (event: PointerEvent) => { - if (event.button !== 0) return; - if (container.contains(event.target as Node)) return; - - const target = event.target as Element | null; - const isAllowedTarget = allowSelector && target?.closest(allowSelector); - if (isAllowedTarget) return; - - onDismissRef.current(); - }; - - document.addEventListener("pointerdown", handleOutsidePointerDown, true); - - return () => { - document.removeEventListener( - "pointerdown", - handleOutsidePointerDown, - true, - ); - container.removeAttribute(DATA_ATTR); - focusLockStyle.remove(); - }; - }, [isActive, cursor, allowSelector]); - - return { containerRef }; -} +import { useEffect, useRef } from "react"; + +type FocusLockCursor = "text" | "default" | "pointer" | "crosshair"; + +const DATA_ATTR = "data-focus-locked"; + +function buildFocusLockCSS({ + cursor, + allowSelector, +}: { + cursor: FocusLockCursor; + allowSelector?: string; +}) { + const rules = [ + `*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`, + `[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`, + ]; + + if (allowSelector) { + rules.push( + `${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`, + ); + } + + return rules.join("\n"); +} + +export function useFocusLock({ + isActive, + onDismiss, + cursor = "default", + allowSelector, +}: { + isActive: boolean; + onDismiss: () => void; + cursor?: FocusLockCursor; + allowSelector?: string; +}) { + const containerRef = useRef(null); + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + + useEffect(() => { + if (!isActive) return; + const container = containerRef.current; + if (!container) return; + + container.setAttribute(DATA_ATTR, ""); + + const focusLockStyle = document.createElement("style"); + focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector }); + document.head.appendChild(focusLockStyle); + + const handleOutsidePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + if (container.contains(event.target as Node)) return; + + const target = event.target as Element | null; + const isAllowedTarget = allowSelector && target?.closest(allowSelector); + if (isAllowedTarget) return; + + onDismissRef.current(); + }; + + document.addEventListener("pointerdown", handleOutsidePointerDown, true); + + return () => { + document.removeEventListener( + "pointerdown", + handleOutsidePointerDown, + true, + ); + container.removeAttribute(DATA_ATTR); + focusLockStyle.remove(); + }; + }, [isActive, cursor, allowSelector]); + + return { containerRef }; +} diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts index 7d6b87e9..93cea7e4 100644 --- a/apps/web/src/hooks/use-font-atlas.ts +++ b/apps/web/src/hooks/use-font-atlas.ts @@ -1,53 +1,53 @@ -import { useState, useMemo, useCallback, useEffect } from "react"; -import { - getCachedFontAtlas, - loadFontAtlas, - clearFontAtlasCache, -} from "@/lib/fonts/google-fonts"; -import type { FontAtlas } from "@/lib/fonts/types"; -import { SYSTEM_FONTS } from "@/constants/font-constants"; - -type Status = "idle" | "loading" | "error"; - -export function useFontAtlas({ open }: { open: boolean }) { - const [atlas, setAtlas] = useState(() => - getCachedFontAtlas(), - ); - const [status, setStatus] = useState(() => - getCachedFontAtlas() ? "idle" : "loading", - ); - - useEffect(() => { - if (!open || atlas) return; - - setStatus("loading"); - loadFontAtlas().then((data) => { - if (data) { - setAtlas(data); - setStatus("idle"); - } else { - setStatus("error"); - } - }); - }, [open, atlas]); - - const retry = useCallback(() => { - clearFontAtlasCache(); - setStatus("loading"); - loadFontAtlas().then((data) => { - if (data) { - setAtlas(data); - setStatus("idle"); - } else { - setStatus("error"); - } - }); - }, []); - - const fontNames = useMemo(() => { - if (!atlas) return []; - return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort(); - }, [atlas]); - - return { atlas, status, fontNames, retry }; -} +import { useState, useMemo, useCallback, useEffect } from "react"; +import { + getCachedFontAtlas, + loadFontAtlas, + clearFontAtlasCache, +} from "@/lib/fonts/google-fonts"; +import type { FontAtlas } from "@/lib/fonts/types"; +import { SYSTEM_FONTS } from "@/constants/font-constants"; + +type Status = "idle" | "loading" | "error"; + +export function useFontAtlas({ open }: { open: boolean }) { + const [atlas, setAtlas] = useState(() => + getCachedFontAtlas(), + ); + const [status, setStatus] = useState(() => + getCachedFontAtlas() ? "idle" : "loading", + ); + + useEffect(() => { + if (!open || atlas) return; + + setStatus("loading"); + loadFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, [open, atlas]); + + const retry = useCallback(() => { + clearFontAtlasCache(); + setStatus("loading"); + loadFontAtlas().then((data) => { + if (data) { + setAtlas(data); + setStatus("idle"); + } else { + setStatus("error"); + } + }); + }, []); + + const fontNames = useMemo(() => { + if (!atlas) return []; + return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort(); + }, [atlas]); + + return { atlas, status, fontNames, retry }; +} diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts index 8d6e5fcc..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; -} - -export function useInfiniteScroll({ - onLoadMore, - hasMore, - isLoading, - threshold = 200, - enabled = true, -}: UseInfiniteScrollOptions) { - const scrollAreaRef = useRef(null); - - const handleScroll = useCallback( - (event: React.UIEvent) => { - if (!enabled) return; - - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; - const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold; - - if (nearBottom && hasMore && !isLoading) { - onLoadMore(); - } - }, - [onLoadMore, hasMore, isLoading, threshold, enabled], - ); - - return { scrollAreaRef, handleScroll }; -} +import { useRef, useCallback } from "react"; + +interface UseInfiniteScrollOptions { + onLoadMore: () => void; + hasMore: boolean; + isLoading: boolean; + threshold?: number; + enabled?: boolean; +} + +export function useInfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + threshold = 200, + enabled = true, +}: UseInfiniteScrollOptions) { + const scrollAreaRef = useRef(null); + + const handleScroll = useCallback( + (event: React.UIEvent) => { + if (!enabled) return; + + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold; + + if (nearBottom && hasMore && !isLoading) { + onLoadMore(); + } + }, + [onLoadMore, hasMore, isLoading, threshold, enabled], + ); + + return { scrollAreaRef, handleScroll }; +} diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts index c3e83304..63fdd674 100644 --- a/apps/web/src/hooks/use-mask-handles.ts +++ b/apps/web/src/hooks/use-mask-handles.ts @@ -1,258 +1,258 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { masksRegistry } from "@/lib/masks"; -import { - getMaskHandlePositions, - getLineMaskLinePoints, -} from "@/lib/masks/handle-positions"; -import { snapMaskInteraction } from "@/lib/masks/snap"; -import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import type { ParamValues } from "@/lib/params"; -import type { - BaseMaskParams, - MaskHandlePosition, - MaskLinePoints, -} from "@/lib/masks/types"; -import type { MaskableElement } from "@/lib/timeline"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -interface DragState { - trackId: string; - elementId: string; - handleId: string; - startCanvasX: number; - startCanvasY: number; - startParams: BaseMaskParams & ParamValues; -} - -export function useMaskHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: (lines: SnapLine[]) => void; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandleId, setActiveHandleId] = useState(null); - const dragStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const tracks = useEditor((e) => e.timeline.getRenderTracks()); - const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const mediaAssets = useEditor((e) => e.media.getAssets()); - const canvasSize = useEditor( - (e) => e.project.getActive().settings.canvasSize, - ); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithMask = - selectedElements.length === 1 - ? (() => { - const sel = selectedElements[0]; - const entry = elementsWithBounds.find( - (item) => - item.trackId === sel.trackId && item.elementId === sel.elementId, - ); - if (!entry) return null; - const element = entry.element as MaskableElement; - if (!element.masks?.length) return null; - return { ...entry, element, mask: element.masks[0] }; - })() - : null; - - const handlePositions: MaskHandlePosition[] = selectedWithMask - ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); - const displayScale = (scaleX + scaleY) / 2; - return getMaskHandlePositions({ - overlayShape: def.overlayShape, - features: def.features, - params: selectedWithMask.mask.params, - bounds: selectedWithMask.bounds, - displayScale, - }); - })() - : []; - - const linePoints: MaskLinePoints | null = - selectedWithMask?.mask.type === "split" - ? getLineMaskLinePoints({ - centerX: selectedWithMask.mask.params.centerX, - centerY: selectedWithMask.mask.params.centerY, - rotation: selectedWithMask.mask.params.rotation, - bounds: selectedWithMask.bounds, - }) - : null; - - const clearMaskHandleState = useCallback(() => { - dragStateRef.current = null; - setActiveHandleId(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); - - useEffect(() => { - if (!activeHandleId) return; - - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearMaskHandleState(); - releaseCapturedPointer(); - }, - }); - }, [ - activeHandleId, - clearMaskHandleState, - editor.timeline, - releaseCapturedPointer, - ]); - - const handlePointerDown = useCallback( - ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { - if (!selectedWithMask) return; - event.stopPropagation(); - - const pos = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!pos) return; - - dragStateRef.current = { - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - handleId, - startCanvasX: pos.x, - startCanvasY: pos.y, - startParams: { ...selectedWithMask.mask.params }, - }; - setActiveHandleId(handleId); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithMask, viewport], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const drag = dragStateRef.current; - if (!drag || !selectedWithMask) return; - - const pos = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!pos) return; - - const deltaX = pos.x - drag.startCanvasX; - const deltaY = pos.y - drag.startCanvasY; - const def = masksRegistry.get(selectedWithMask.mask.type); - - const rawParams = def.computeParamUpdate({ - handleId: drag.handleId, - startParams: drag.startParams, - deltaX, - deltaY, - startCanvasX: drag.startCanvasX, - startCanvasY: drag.startCanvasY, - bounds: selectedWithMask.bounds, - canvasSize, - }); - const proposedParams = { ...drag.startParams, ...rawParams }; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { params: nextParams, activeLines } = isShiftHeldRef.current - ? { params: proposedParams, activeLines: [] as SnapLine[] } - : snapMaskInteraction({ - handleId: drag.handleId, - startParams: drag.startParams, - proposedParams, - bounds: selectedWithMask.bounds, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - const updatedMask = { - ...selectedWithMask.mask, - params: nextParams, - }; - editor.timeline.previewElements({ - updates: [ - { - trackId: drag.trackId, - elementId: drag.elementId, - updates: { - masks: [ - updatedMask, - ...(selectedWithMask.element.masks?.slice(1) ?? []), - ], - } as Partial, - }, - ], - }); - }, - [ - selectedWithMask, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if (dragStateRef.current) { - editor.timeline.commitPreview(); - clearMaskHandleState(); - } - releaseCapturedPointer(); - }, - [clearMaskHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithMask, - handlePositions, - linePoints, - activeHandleId, - handlePointerDown, - handlePointerMove, - handlePointerUp, - }; -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { masksRegistry } from "@/lib/masks"; +import { + getMaskHandlePositions, + getLineMaskLinePoints, +} from "@/lib/masks/handle-positions"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import type { + BaseMaskParams, + MaskHandlePosition, + MaskLinePoints, +} from "@/lib/masks/types"; +import type { MaskableElement } from "@/lib/timeline"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +interface DragState { + trackId: string; + elementId: string; + handleId: string; + startCanvasX: number; + startCanvasY: number; + startParams: BaseMaskParams & ParamValues; +} + +export function useMaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandleId, setActiveHandleId] = useState(null); + const dragStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithMask = + selectedElements.length === 1 + ? (() => { + const sel = selectedElements[0]; + const entry = elementsWithBounds.find( + (item) => + item.trackId === sel.trackId && item.elementId === sel.elementId, + ); + if (!entry) return null; + const element = entry.element as MaskableElement; + if (!element.masks?.length) return null; + return { ...entry, element, mask: element.masks[0] }; + })() + : null; + + const handlePositions: MaskHandlePosition[] = selectedWithMask + ? (() => { + const def = masksRegistry.get(selectedWithMask.mask.type); + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + const displayScale = (scaleX + scaleY) / 2; + return getMaskHandlePositions({ + overlayShape: def.overlayShape, + features: def.features, + params: selectedWithMask.mask.params, + bounds: selectedWithMask.bounds, + displayScale, + }); + })() + : []; + + const linePoints: MaskLinePoints | null = + selectedWithMask?.mask.type === "split" + ? getLineMaskLinePoints({ + centerX: selectedWithMask.mask.params.centerX, + centerY: selectedWithMask.mask.params.centerY, + rotation: selectedWithMask.mask.params.rotation, + bounds: selectedWithMask.bounds, + }) + : null; + + const clearMaskHandleState = useCallback(() => { + dragStateRef.current = null; + setActiveHandleId(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandleId) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearMaskHandleState(); + releaseCapturedPointer(); + }, + }); + }, [ + activeHandleId, + clearMaskHandleState, + editor.timeline, + releaseCapturedPointer, + ]); + + const handlePointerDown = useCallback( + ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + if (!selectedWithMask) return; + event.stopPropagation(); + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + dragStateRef.current = { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + handleId, + startCanvasX: pos.x, + startCanvasY: pos.y, + startParams: { ...selectedWithMask.mask.params }, + }; + setActiveHandleId(handleId); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithMask, viewport], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const drag = dragStateRef.current; + if (!drag || !selectedWithMask) return; + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + const deltaX = pos.x - drag.startCanvasX; + const deltaY = pos.y - drag.startCanvasY; + const def = masksRegistry.get(selectedWithMask.mask.type); + + const rawParams = def.computeParamUpdate({ + handleId: drag.handleId, + startParams: drag.startParams, + deltaX, + deltaY, + startCanvasX: drag.startCanvasX, + startCanvasY: drag.startCanvasY, + bounds: selectedWithMask.bounds, + canvasSize, + }); + const proposedParams = { ...drag.startParams, ...rawParams }; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { params: nextParams, activeLines } = isShiftHeldRef.current + ? { params: proposedParams, activeLines: [] as SnapLine[] } + : snapMaskInteraction({ + handleId: drag.handleId, + startParams: drag.startParams, + proposedParams, + bounds: selectedWithMask.bounds, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + const updatedMask = { + ...selectedWithMask.mask, + params: nextParams, + }; + editor.timeline.previewElements({ + updates: [ + { + trackId: drag.trackId, + elementId: drag.elementId, + updates: { + masks: [ + updatedMask, + ...(selectedWithMask.element.masks?.slice(1) ?? []), + ], + } as Partial, + }, + ], + }); + }, + [ + selectedWithMask, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if (dragStateRef.current) { + editor.timeline.commitPreview(); + clearMaskHandleState(); + } + releaseCapturedPointer(); + }, + [clearMaskHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithMask, + handlePositions, + linePoints, + activeHandleId, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts index a17ee278..a3c14489 100644 --- a/apps/web/src/hooks/use-menu-preview.ts +++ b/apps/web/src/hooks/use-menu-preview.ts @@ -1,26 +1,26 @@ -import { useRef } from "react"; -import { useEditor } from "@/hooks/use-editor"; - -export function useMenuPreview() { - const editor = useEditor(); - const didCommitRef = useRef(false); - - const discard = () => { - if (!didCommitRef.current && editor.timeline.isPreviewActive()) { - editor.timeline.discardPreview(); - } - }; - - const markCommitted = () => { - didCommitRef.current = true; - }; - - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - discard(); - didCommitRef.current = false; - } - }; - - return { onPointerLeave: discard, onOpenChange, markCommitted }; -} +import { useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; + +export function useMenuPreview() { + const editor = useEditor(); + const didCommitRef = useRef(false); + + const discard = () => { + if (!didCommitRef.current && editor.timeline.isPreviewActive()) { + editor.timeline.discardPreview(); + } + }; + + const markCommitted = () => { + didCommitRef.current = true; + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + discard(); + didCommitRef.current = false; + } + }; + + return { onPointerLeave: discard, onOpenChange, markCommitted }; +} diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts index d6a67b93..5bdb9f80 100644 --- a/apps/web/src/hooks/use-mobile.ts +++ b/apps/web/src/hooks/use-mobile.ts @@ -1,19 +1,19 @@ -import { useEffect, useState } from "react"; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = useState(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; -} +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(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-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts index 8f362a12..2c9c43ea 100644 --- a/apps/web/src/hooks/use-paste-media.ts +++ b/apps/web/src/hooks/use-paste-media.ts @@ -1,114 +1,114 @@ -import { useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { processMediaAssets } from "@/lib/media/processing"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { invokeAction } from "@/lib/actions"; -import { buildElementFromMedia } from "@/lib/timeline/element-utils"; -import { AddMediaAssetCommand } from "@/lib/commands/media"; -import { InsertElementCommand } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { isTypableDOMElement } from "@/utils/browser"; -import type { MediaType } from "@/lib/media/types"; - -const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; - -function isMediaMimeType({ type }: { type: string }): boolean { - return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); -} - -function extractMediaFilesFromClipboard({ - clipboardData, -}: { - clipboardData: DataTransfer | null; -}): File[] { - if (!clipboardData?.items) return []; - - const files: File[] = []; - for (const item of clipboardData.items) { - if (item.kind !== "file") continue; - if (!isMediaMimeType({ type: item.type })) continue; - - const file = item.getAsFile(); - if (file) files.push(file); - } - return files; -} - -export function usePasteMedia() { - const editor = useEditor(); - - useEffect(() => { - const handlePaste = async (event: ClipboardEvent) => { - const activeElement = document.activeElement as HTMLElement; - - if (activeElement && isTypableDOMElement({ element: activeElement })) { - return; - } - - const files = extractMediaFilesFromClipboard({ - clipboardData: event.clipboardData, - }); - if (files.length === 0) { - event.preventDefault(); - invokeAction("paste-copied"); - return; - } - - event.preventDefault(); - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - try { - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const startTime = editor.playback.getCurrentTime(); - - for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, - asset, - ); - const assetId = addMediaCmd.getAssetId(); - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - const trackType = asset.type === "audio" ? "audio" : "video"; - - const element = buildElementFromMedia({ - mediaId: assetId, - mediaType: asset.type, - name: asset.name, - duration, - startTime, - buffer: - asset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); - - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "auto", trackType }, - }); - const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); - editor.command.execute({ command: batchCmd }); - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - } catch (error) { - console.error("Failed to paste media:", error); - } - }; - - window.addEventListener("paste", handlePaste); - return () => window.removeEventListener("paste", handlePaste); - }, [editor]); -} +import { useEffect } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { invokeAction } from "@/lib/actions"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +import { AddMediaAssetCommand } from "@/lib/commands/media"; +import { InsertElementCommand } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { isTypableDOMElement } from "@/utils/browser"; +import type { MediaType } from "@/lib/media/types"; + +const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; + +function isMediaMimeType({ type }: { type: string }): boolean { + return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); +} + +function extractMediaFilesFromClipboard({ + clipboardData, +}: { + clipboardData: DataTransfer | null; +}): File[] { + if (!clipboardData?.items) return []; + + const files: File[] = []; + for (const item of clipboardData.items) { + if (item.kind !== "file") continue; + if (!isMediaMimeType({ type: item.type })) continue; + + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; +} + +export function usePasteMedia() { + const editor = useEditor(); + + useEffect(() => { + const handlePaste = async (event: ClipboardEvent) => { + const activeElement = document.activeElement as HTMLElement; + + if (activeElement && isTypableDOMElement({ element: activeElement })) { + return; + } + + const files = extractMediaFilesFromClipboard({ + clipboardData: event.clipboardData, + }); + if (files.length === 0) { + event.preventDefault(); + invokeAction("paste-copied"); + return; + } + + event.preventDefault(); + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + try { + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const startTime = editor.playback.getCurrentTime(); + + for (const asset of processedAssets) { + const addMediaCmd = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + ); + const assetId = addMediaCmd.getAssetId(); + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const trackType = asset.type === "audio" ? "audio" : "video"; + + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType }, + }); + const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); + editor.command.execute({ command: batchCmd }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } catch (error) { + console.error("Failed to paste media:", error); + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [editor]); +} diff --git a/apps/web/src/hooks/use-resize-observer.ts b/apps/web/src/hooks/use-resize-observer.ts index a4b3a603..8153b83a 100644 --- a/apps/web/src/hooks/use-resize-observer.ts +++ b/apps/web/src/hooks/use-resize-observer.ts @@ -1,26 +1,26 @@ -import { useEffect } from "react"; - -export function useResizeObserver({ - ref, - onResize, -}: { - ref: React.RefObject; - onResize: (entry: ResizeObserverEntry) => void; -}) { - useEffect(() => { - const element = ref.current; - if (!element) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - onResize(entry); - } - }); - - observer.observe(element); - - return () => { - observer.disconnect(); - }; - }, [ref, onResize]); -} +import { useEffect } from "react"; + +export function useResizeObserver({ + ref, + onResize, +}: { + ref: React.RefObject; + onResize: (entry: ResizeObserverEntry) => void; +}) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + onResize(entry); + } + }); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [ref, onResize]); +} diff --git a/apps/web/src/hooks/use-shift-key.ts b/apps/web/src/hooks/use-shift-key.ts index 4f9fe67c..33eb2fc6 100644 --- a/apps/web/src/hooks/use-shift-key.ts +++ b/apps/web/src/hooks/use-shift-key.ts @@ -1,35 +1,35 @@ -import { useEffect, useRef, type RefObject } from "react"; - -export function useShiftKey(): RefObject { - const isShiftHeldRef = useRef(false); - - useEffect(() => { - const handleKeyDown = ({ key }: KeyboardEvent) => { - if (key === "Shift") { - isShiftHeldRef.current = true; - } - }; - - const handleKeyUp = ({ key }: KeyboardEvent) => { - if (key === "Shift") { - isShiftHeldRef.current = false; - } - }; - - const handleBlur = () => { - isShiftHeldRef.current = false; - }; - - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyUp); - window.addEventListener("blur", handleBlur); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keyup", handleKeyUp); - window.removeEventListener("blur", handleBlur); - }; - }, []); - - return isShiftHeldRef; -} +import { useEffect, useRef, type RefObject } from "react"; + +export function useShiftKey(): RefObject { + const isShiftHeldRef = useRef(false); + + useEffect(() => { + const handleKeyDown = ({ key }: KeyboardEvent) => { + if (key === "Shift") { + isShiftHeldRef.current = true; + } + }; + + const handleKeyUp = ({ key }: KeyboardEvent) => { + if (key === "Shift") { + isShiftHeldRef.current = false; + } + }; + + const handleBlur = () => { + isShiftHeldRef.current = false; + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + return isShiftHeldRef; +} diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts index f3c0aab4..263a01e2 100644 --- a/apps/web/src/hooks/use-sound-search.ts +++ b/apps/web/src/hooks/use-sound-search.ts @@ -1,154 +1,154 @@ -import { useEffect } from "react"; -import { useSoundsStore } from "@/stores/sounds-store"; - -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(); - - const loadMore = async () => { - if (isLoadingMore || !hasNextPage) return; - - try { - setLoadingMore({ loading: true }); - const nextPage = currentPage + 1; - - const searchParams = new URLSearchParams({ - page: nextPage.toString(), - type: "effects", - }); - - if (query.trim()) { - searchParams.set("q", query); - } - - searchParams.set("commercial_only", commercialOnly.toString()); - const response = await fetch( - `/api/sounds/search?${searchParams.toString()}`, - ); - - if (response.ok) { - const data = await response.json(); - - 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 }); - } - }; - - useEffect(() => { - if (!query.trim()) { - setSearchResults({ results: [] }); - setSearchError({ error: null }); - setLastSearchQuery({ query: "" }); - return; - } - - if (query === lastSearchQuery && searchResults.length > 0) { - return; - } - - let ignore = false; - - const timeoutId = setTimeout(async () => { - try { - setSearching({ searching: true }); - setSearchError({ error: null }); - resetPagination(); - - 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); - - 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, - }; -} +import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +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(); + + const loadMore = async () => { + if (isLoadingMore || !hasNextPage) return; + + try { + setLoadingMore({ loading: true }); + const nextPage = currentPage + 1; + + const searchParams = new URLSearchParams({ + page: nextPage.toString(), + type: "effects", + }); + + if (query.trim()) { + searchParams.set("q", query); + } + + searchParams.set("commercial_only", commercialOnly.toString()); + const response = await fetch( + `/api/sounds/search?${searchParams.toString()}`, + ); + + if (response.ok) { + const data = await response.json(); + + 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 }); + } + }; + + useEffect(() => { + if (!query.trim()) { + setSearchResults({ results: [] }); + setSearchError({ error: null }); + setLastSearchQuery({ query: "" }); + return; + } + + if (query === lastSearchQuery && searchResults.length > 0) { + return; + } + + let ignore = false; + + const timeoutId = setTimeout(async () => { + try { + setSearching({ searching: true }); + setSearchError({ error: null }); + resetPagination(); + + 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); + + 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, + }; +} diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts index 79e05069..a026b510 100644 --- a/apps/web/src/hooks/use-storage-persistence.ts +++ b/apps/web/src/hooks/use-storage-persistence.ts @@ -1,45 +1,45 @@ -"use client"; - -import { useEffect, useState } from "react"; - -const DISMISSED_KEY = "opencut-storage-persist-dismissed"; - -function isFirefox(): boolean { - return navigator.userAgent.toLowerCase().includes("firefox"); -} - -export function useStoragePersistence() { - const [showDialog, setShowDialog] = useState(false); - - useEffect(() => { - if (!navigator.storage?.persist) return; - - const run = async () => { - const alreadyPersisted = await navigator.storage.persisted(); - if (alreadyPersisted) return; - - const dismissed = localStorage.getItem(DISMISSED_KEY) === "true"; - if (dismissed) return; - - if (isFirefox()) { - setShowDialog(true); - } else { - await navigator.storage.persist(); - } - }; - - run(); - }, []); - - const onConfirm = async () => { - setShowDialog(false); - await navigator.storage.persist(); - }; - - const onDismiss = () => { - setShowDialog(false); - localStorage.setItem(DISMISSED_KEY, "true"); - }; - - return { showDialog, onConfirm, onDismiss }; -} +"use client"; + +import { useEffect, useState } from "react"; + +const DISMISSED_KEY = "opencut-storage-persist-dismissed"; + +function isFirefox(): boolean { + return navigator.userAgent.toLowerCase().includes("firefox"); +} + +export function useStoragePersistence() { + const [showDialog, setShowDialog] = useState(false); + + useEffect(() => { + if (!navigator.storage?.persist) return; + + const run = async () => { + const alreadyPersisted = await navigator.storage.persisted(); + if (alreadyPersisted) return; + + const dismissed = localStorage.getItem(DISMISSED_KEY) === "true"; + if (dismissed) return; + + if (isFirefox()) { + setShowDialog(true); + } else { + await navigator.storage.persist(); + } + }; + + run(); + }, []); + + const onConfirm = async () => { + setShowDialog(false); + await navigator.storage.persist(); + }; + + const onDismiss = () => { + setShowDialog(false); + localStorage.setItem(DISMISSED_KEY, "true"); + }; + + return { showDialog, onConfirm, onDismiss }; +} diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index a01fb361..af4bb69f 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,619 +1,619 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/lib/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getElementLocalTime, - resolveTransformAtTime, - setChannel, -} from "@/lib/animation"; -import type { Transform } from "@/lib/rendering"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ - edge, -}: { - edge: Edge; -}): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} - -export function useTransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); - const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; - const mediaAssets = useEditor((e) => e.media.getAssets()); - const canvasSize = useEditor( - (e) => e.project.getActive().settings.canvasSize, - ); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; - - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); - - useEffect(() => { - if (!activeHandle) return; - - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); - - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - !!element.animations?.channels["transform.scaleX"] || - !!element.animations?.channels["transform.scaleY"]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = - !!element.animations?.channels[propertyPath]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, - [clearActiveHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithBounds, - hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - }; -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; +import type { Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type Edge = "right" | "left" | "bottom"; +type HandleType = Corner | Edge | "rotation"; + +function getPreferredEdge({ + edge, +}: { + edge: Edge; +}): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +interface EdgeScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + edge: Edge; + rotationRad: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandle, setActiveHandle] = useState(null); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + const edgeScaleStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const clearActiveHandleState = useCallback(() => { + scaleStateRef.current = null; + rotationStateRef.current = null; + edgeScaleStateRef.current = null; + setActiveHandle(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandle) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearActiveHandleState(); + releaseCapturedPointer(); + }, + }); + }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const shouldClearScaleAnimation = + !!element.animations?.channels["transform.scaleX"] || + !!element.animations?.channels["transform.scaleY"]; + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations: element.animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : element.animations; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(corner); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds, viewport], + ); + + const handleEdgePointerDown = useCallback( + ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const rotationRad = (bounds.rotation * Math.PI) / 180; + + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + const shouldClearScaleAnimation = + !!element.animations?.channels[propertyPath]; + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath, + channel: undefined, + }) + : element.animations; + + edgeScaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(edge); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if ( + !scaleStateRef.current && + !rotationStateRef.current && + !edgeScaleStateRef.current + ) + return; + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + } = scaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = + Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / initialDistance; + + // Use actual element dimensions (base * current scale) so snap + // computes the correct edges when scaleX ≠ scaleY + const effectiveWidth = baseWidth * initialTransform.scaleX; + const effectiveHeight = baseHeight * initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale: snappedFactor, activeLines } = + isShiftHeldRef.current + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: clampScaleNonZero( + initialTransform.scaleX * snappedFactor, + ), + scaleY: clampScaleNonZero( + initialTransform.scaleY * snappedFactor, + ), + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if ( + edgeScaleStateRef.current && + (activeHandle === "right" || + activeHandle === "left" || + activeHandle === "bottom") + ) { + const { + trackId, + elementId, + initialTransform, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + } = edgeScaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const xProjection = + deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); + const yProjection = + -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); + const projection = + edge === "right" + ? xProjection + : edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + edge === "right" || edge === "left" + ? proposedScale + : initialTransform.scaleX; + const proposedScaleY = + edge === "bottom" ? proposedScale : initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = isShiftHeldRef.current + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: initialTransform.position, + baseWidth, + baseHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + preferredEdges: getPreferredEdge({ edge }), + }); + + const relevantSnap = + edge === "right" || edge === "left" ? xSnap : ySnap; + onSnapLinesChange?.(relevantSnap.activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: + edge === "right" || edge === "left" + ? xSnap.snappedScale + : initialTransform.scaleX, + scaleY: + edge === "bottom" + ? ySnap.snappedScale + : initialTransform.scaleY, + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const { snappedRotation } = isShiftHeldRef.current + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [ + activeHandle, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if ( + scaleStateRef.current || + rotationStateRef.current || + edgeScaleStateRef.current + ) { + editor.timeline.commitPreview(); + clearActiveHandleState(); + } + releaseCapturedPointer(); + }, + [clearActiveHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} -- cgit v1.3.1