diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/hooks | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/hooks')
32 files changed, 0 insertions, 7194 deletions
diff --git a/apps/web/src/hooks/actions/use-action-handler.ts b/apps/web/src/hooks/actions/use-action-handler.ts deleted file mode 100644 index 3f6dbd21..00000000 --- a/apps/web/src/hooks/actions/use-action-handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import type { - TAction, - TActionFunc, - TActionHandlerOptions, - TArgOfAction, - TInvocationTrigger, -} from "@/lib/actions"; -import { bindAction, unbindAction } from "@/lib/actions"; - -export function useActionHandler<A extends TAction>( - action: A, - handler: TActionFunc<A>, - isActive: TActionHandlerOptions, -) { - const handlerRef = useRef<TActionFunc<A>>(handler); - const isBoundRef = useRef(false); - - useEffect(() => { - handlerRef.current = handler; - }, [handler]); - - const stableHandler = useCallback( - (...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => { - ( - handlerRef.current as ( - ...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?] - ) => void - )(...parameters); - }, - [], - ) as TActionFunc<A>; - - useEffect(() => { - const shouldBind = - isActive === undefined || - (typeof isActive === "boolean" ? isActive : isActive.current); - - if (shouldBind && !isBoundRef.current) { - bindAction(action, stableHandler); - isBoundRef.current = true; - } else if (!shouldBind && isBoundRef.current) { - unbindAction(action, stableHandler); - isBoundRef.current = false; - } - - return () => { - unbindAction(action, stableHandler); - isBoundRef.current = false; - }; - }, [action, stableHandler, isActive]); -} diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts deleted file mode 100644 index 873fd33a..00000000 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ /dev/null @@ -1,488 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useActionHandler } from "@/hooks/actions/use-action-handler"; -import { useEditor } from "../use-editor"; -import { useElementSelection } from "../timeline/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; -import { getElementsAtTime, hasMediaId } from "@/lib/timeline"; -import { cancelInteraction } from "@/lib/cancel-interaction"; -import { invokeAction } from "@/lib/actions"; -import { canToggleSourceAudio } from "@/lib/timeline/audio-separation"; -import { - activateScope, - clearActiveScope, - type ScopeEntry, -} from "@/lib/selection/scope"; - -export function useEditorActions() { - const editor = useEditor(); - const { selectedElements, setElementSelection } = useElementSelection(); - const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); - const selectedMaskPointSelection = useEditor((e) => - e.selection.getSelectedMaskPointSelection(), - ); - const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); - const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); - const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef<ScopeEntry | null>(null); - const hasTimelineSelection = - selectedElements.length > 0 || - selectedKeyframes.length > 0 || - selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { - editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { - editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } - - useEffect(() => { - if (!hasTimelineSelection) { - return; - } - - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); - - useActionHandler( - "toggle-play", - () => { - editor.playback.toggle(); - }, - undefined, - ); - - useActionHandler( - "stop-playback", - () => { - if (editor.playback.getIsPlaying()) { - editor.playback.toggle(); - } - editor.playback.seek({ time: 0 }); - }, - undefined, - ); - - useActionHandler( - "seek-forward", - (args) => { - const seconds = args?.seconds ?? 1; - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), - }); - }, - undefined, - ); - - useActionHandler( - "seek-backward", - (args) => { - const seconds = args?.seconds ?? 1; - editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), - }); - }, - undefined, - ); - - useActionHandler( - "frame-step-forward", - () => { - const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + ticksPerFrame, - ), - }); - }, - undefined, - ); - - useActionHandler( - "frame-step-backward", - () => { - const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), - }); - }, - undefined, - ); - - useActionHandler( - "jump-forward", - (args) => { - const seconds = args?.seconds ?? 5; - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), - }); - }, - undefined, - ); - - useActionHandler( - "jump-backward", - (args) => { - const seconds = args?.seconds ?? 5; - editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), - }); - }, - undefined, - ); - - useActionHandler( - "goto-start", - () => { - editor.playback.seek({ time: 0 }); - }, - undefined, - ); - - useActionHandler( - "goto-end", - () => { - editor.playback.seek({ time: editor.timeline.getTotalDuration() }); - }, - undefined, - ); - - useActionHandler( - "split", - () => { - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.scenes.getActiveScene().tracks; - const elementsToSplit = - selectedElements.length > 0 - ? selectedElements - : getElementsAtTime({ - tracks, - time: currentTime, - }); - - if (elementsToSplit.length === 0) return; - - editor.timeline.splitElements({ - elements: elementsToSplit, - splitTime: currentTime, - }); - }, - undefined, - ); - - useActionHandler( - "split-left", - () => { - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.scenes.getActiveScene().tracks; - const elementsToSplit = - selectedElements.length > 0 - ? selectedElements - : getElementsAtTime({ - tracks, - time: currentTime, - }); - - if (elementsToSplit.length === 0) return; - - const rightSideElements = editor.timeline.splitElements({ - elements: elementsToSplit, - splitTime: currentTime, - retainSide: "right", - }); - - if (rippleEditingEnabled && rightSideElements.length > 0) { - const firstRightElement = editor.timeline.getElementsWithTracks({ - elements: [rightSideElements[0]], - })[0]; - if (firstRightElement) { - editor.playback.seek({ time: firstRightElement.element.startTime }); - } - } - }, - undefined, - ); - - useActionHandler( - "split-right", - () => { - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.scenes.getActiveScene().tracks; - const elementsToSplit = - selectedElements.length > 0 - ? selectedElements - : getElementsAtTime({ - tracks, - time: currentTime, - }); - - if (elementsToSplit.length === 0) return; - - editor.timeline.splitElements({ - elements: elementsToSplit, - splitTime: currentTime, - retainSide: "left", - }); - }, - undefined, - ); - - useActionHandler( - "delete-selected", - () => { - switch (editor.selection.getActiveSelectionKind()) { - case "mask-points": - if (!selectedMaskPointSelection) { - return; - } - editor.timeline.deleteCustomMaskPoints({ - trackId: selectedMaskPointSelection.trackId, - elementId: selectedMaskPointSelection.elementId, - maskId: selectedMaskPointSelection.maskId, - pointIds: selectedMaskPointSelection.pointIds, - }); - return; - case "keyframes": - if (selectedKeyframes.length === 0) { - return; - } - editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); - clearKeyframeSelection(); - return; - case "elements": - if (selectedElements.length === 0) { - return; - } - editor.timeline.deleteElements({ - elements: selectedElements, - }); - return; - default: - return; - } - }, - undefined, - ); - - useActionHandler( - "toggle-source-audio", - () => { - if (selectedElements.length !== 1) { - return; - } - - const selectedElement = editor.timeline.getElementsWithTracks({ - elements: selectedElements, - })[0]; - if (!selectedElement) { - return; - } - - const mediaAsset = (() => { - const { element } = selectedElement; - if (!hasMediaId(element)) { - return null; - } - - return ( - editor.media - .getAssets() - .find((asset) => asset.id === element.mediaId) ?? null - ); - })(); - if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { - return; - } - - editor.timeline.toggleSourceAudioSeparation({ - trackId: selectedElement.track.id, - elementId: selectedElement.element.id, - }); - }, - undefined, - ); - - useActionHandler( - "select-all", - () => { - const scene = editor.scenes.getActiveScene(); - const allElements = [ - ...scene.tracks.overlay, - scene.tracks.main, - ...scene.tracks.audio, - ].flatMap((track) => - track.elements.map((element) => ({ - trackId: track.id, - elementId: element.id, - })), - ); - setElementSelection({ elements: allElements }); - }, - undefined, - ); - - useActionHandler( - "cancel-interaction", - () => { - if (!cancelInteraction()) { - invokeAction("deselect-all"); - } - }, - undefined, - ); - - useActionHandler( - "deselect-all", - () => { - if (!clearActiveScope()) { - editor.selection.clearMostSpecificSelection(); - } - }, - undefined, - ); - - useActionHandler( - "duplicate-selected", - () => { - editor.timeline.duplicateElements({ - elements: selectedElements, - }); - }, - undefined, - ); - - useActionHandler( - "toggle-elements-muted-selected", - () => { - editor.timeline.toggleElementsMuted({ elements: selectedElements }); - }, - undefined, - ); - - useActionHandler( - "toggle-elements-visibility-selected", - () => { - editor.timeline.toggleElementsVisibility({ elements: selectedElements }); - }, - undefined, - ); - - useActionHandler( - "toggle-bookmark", - () => { - editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() }); - }, - undefined, - ); - - useActionHandler( - "copy-selected", - () => { - editor.clipboard.copy(); - }, - undefined, - ); - - useActionHandler( - "paste-copied", - () => { - editor.clipboard.paste(); - }, - undefined, - ); - - useActionHandler( - "toggle-snapping", - () => { - toggleSnapping(); - }, - undefined, - ); - - useActionHandler( - "toggle-ripple-editing", - () => { - toggleRippleEditing(); - }, - undefined, - ); - - useActionHandler( - "undo", - () => { - editor.command.undo(); - }, - undefined, - ); - - useActionHandler( - "redo", - () => { - editor.command.redo(); - }, - undefined, - ); - - // todo: potnetially unify these two actions: - useActionHandler( - "remove-media-asset", - (args) => { - if (!args) return; - editor.media.removeMediaAsset({ - projectId: args.projectId, - id: args.assetId, - }); - }, - undefined, - ); - - useActionHandler( - "remove-media-assets", - (args) => { - if (!args) return; - editor.media.removeMediaAssets({ - projectId: args.projectId, - ids: args.assetIds, - }); - }, - undefined, - ); -} diff --git a/apps/web/src/hooks/storage/use-local-storage.ts b/apps/web/src/hooks/storage/use-local-storage.ts deleted file mode 100644 index d0188ab3..00000000 --- a/apps/web/src/hooks/storage/use-local-storage.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; - -export function useLocalStorage<T>({ - key, - defaultValue, -}: { - key: string; - defaultValue: T; -}): [ - T, - ({ value }: { value: T | ((previousValue: T) => T) }) => void, - boolean, -] { - const [value, setValue] = useState<T>(defaultValue); - const [isReady, setIsReady] = useState(false); - const valueRef = useRef(defaultValue); - - // avoid hydration mismatch by reading after mount - useEffect(() => { - try { - const storedValue = localStorage.getItem(key); - if (storedValue !== null) { - const parsedValue = JSON.parse(storedValue) as T; - valueRef.current = parsedValue; - setValue(parsedValue); - } - } catch { - // localstorage might be unavailable - } - setIsReady(true); - }, [key]); - - // sync to localstorage after hydration - useEffect(() => { - if (!isReady) return; - - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - // localstorage might be full or disabled - } - }, [key, value, isReady]); - - const setValueWithCallback = useCallback( - ({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => { - const resolvedValue = - typeof nextValue === "function" - ? (nextValue as (previousValue: T) => T)(valueRef.current) - : nextValue; - - valueRef.current = resolvedValue; - setValue(resolvedValue); - }, - [], - ); - - return [value, setValueWithCallback, isReady]; -} diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts deleted file mode 100644 index 5458efe7..00000000 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/lib/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; -import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { registerCanceller } from "@/lib/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/lib/timeline"; - -interface UseElementInteractionProps { - zoomLevel: number; - timelineRef: RefObject<HTMLDivElement | null>; - tracksContainerRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - snappingEnabled: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -} - -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): number { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - zoomLevel: number; - snappedTime: number; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: number; - initialCurrentMouseY: number; -} - -export function useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - headerRef, - snappingEnabled, - onSnapPointChange, -}: UseElementInteractionProps) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); - - const [dragState, setDragState] = - useState<ElementDragState>(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef<PendingDragState | null>(null); - const moveGroupRef = useRef<MoveGroup | null>(null); - const newTrackIdsRef = useRef<string[]>([]); - const groupMoveResultRef = useRef<GroupMoveResult | null>(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); - }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: number; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - }, - [sceneTracks], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: number; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; - }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = - roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record<string, number> = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); - }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); - - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } - - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } - - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); - - return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts deleted file mode 100644 index 42187b7f..00000000 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ /dev/null @@ -1,137 +0,0 @@ -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-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts deleted file mode 100644 index 0fc46372..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { useCallback, useRef, useMemo } from "react"; -import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; -import { - useKeyframeSelection, - getSelectedKeyframeId, -} from "./use-keyframe-selection"; -import type { - SelectedKeyframeRef, - ElementKeyframe, -} from "@/lib/animation/types"; -import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { - KEYFRAME_LANE_HEIGHT_PX, - KEYFRAME_DIAMOND_SIZE_PX, -} from "@/components/editor/panels/timeline/layout"; - -export function useKeyframeBoxSelect({ - trackId, - elementId, - rows, - keyframes, - displayedStartTime, - zoomLevel, - elementLeft, -}: { - trackId: string; - elementId: string; - rows: ExpandedRow[]; - keyframes: ElementKeyframe[]; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; -}) { - const { - selectedKeyframes, - keyframeSelectionAnchor, - setKeyframeSelection, - clearKeyframeSelection, - } = useKeyframeSelection(); - - const containerRef = useRef<HTMLDivElement>(null); - const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]); - - const keyframeEntries = useMemo(() => { - const entries: Array<{ - id: string; - ref: SelectedKeyframeRef; - rowIndex: number; - offsetPx: number; - }> = []; - - for (const kf of keyframes) { - const rowIndex = rows.findIndex( - (r) => r.propertyPath === kf.propertyPath, - ); - if (rowIndex === -1) continue; - - const ref: SelectedKeyframeRef = { - trackId, - elementId, - propertyPath: kf.propertyPath, - keyframeId: kf.id, - }; - - const kfLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + kf.time, - zoomLevel, - }); - - entries.push({ - id: getSelectedKeyframeId({ keyframe: ref }), - ref, - rowIndex, - offsetPx: kfLeft - elementLeft, - }); - } - - return entries; - }, [ - keyframes, - rows, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - ]); - - const idToRefMap = useMemo(() => { - const map = new Map<string, SelectedKeyframeRef>(); - for (const entry of keyframeEntries) { - map.set(entry.id, entry.ref); - } - return map; - }, [keyframeEntries]); - - const selectedIds = useMemo( - () => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - [selectedKeyframes], - ); - - const anchorId = useMemo( - () => - keyframeSelectionAnchor - ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor }) - : null, - [keyframeSelectionAnchor], - ); - - const resolveIntersections = useCallback( - ({ - startPos, - currentPos, - }: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - }) => { - const container = containerRef.current; - if (!container) return []; - - const containerRect = container.getBoundingClientRect(); - - const sx = startPos.x - containerRect.left; - const sy = startPos.y - containerRect.top; - const cx = currentPos.x - containerRect.left; - const cy = currentPos.y - containerRect.top; - - const selLeft = Math.min(sx, cx); - const selTop = Math.min(sy, cy); - const selRight = Math.max(sx, cx); - const selBottom = Math.max(sy, cy); - - const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2; - - return keyframeEntries - .filter((entry) => { - const kfX = entry.offsetPx; - const kfY = - entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX + - KEYFRAME_LANE_HEIGHT_PX / 2; - - return !( - kfX + halfHit < selLeft || - kfX - halfHit > selRight || - kfY + halfHit < selTop || - kfY - halfHit > selBottom - ); - }) - .map((entry) => entry.id); - }, - [keyframeEntries], - ); - - const onSelectionChange = useCallback( - ({ - intersectedIds, - isAdditive, - }: { - intersectedIds: string[]; - initialSelectedIds: string[]; - initialAnchorId: string | null; - isAdditive: boolean; - }) => { - const intersectedRefs = intersectedIds - .map((id) => idToRefMap.get(id)) - .filter((ref): ref is SelectedKeyframeRef => ref != null); - - if (isAdditive) { - setKeyframeSelection({ - keyframes: [ - ...initialKeyframesRef.current, - ...intersectedRefs, - ], - }); - } else { - setKeyframeSelection({ keyframes: intersectedRefs }); - } - }, - [idToRefMap, setKeyframeSelection], - ); - - const { - selectionBox, - handleMouseDown: boxSelectMouseDown, - isSelecting, - shouldIgnoreClick, - } = useBoxSelect<string>({ - containerRef, - resolveIntersections, - selectedIds, - anchorId, - onSelectionChange, - }); - - const handleExpandedAreaMouseDown = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - initialKeyframesRef.current = selectedKeyframes; - boxSelectMouseDown(event); - }, - [boxSelectMouseDown, selectedKeyframes], - ); - - const handleExpandedAreaClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - if (shouldIgnoreClick()) return; - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - clearKeyframeSelection(); - }, - [shouldIgnoreClick, clearKeyframeSelection], - ); - - return { - containerRef, - selectionBox, - isBoxSelecting: isSelecting, - handleExpandedAreaMouseDown, - handleExpandedAreaClick, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts deleted file mode 100644 index 24bff26a..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { getKeyframeById } from "@/lib/animation"; -import { useKeyframeSelection } from "./use-keyframe-selection"; -import { roundToFrame, snappedSeekTime } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; -import { BatchCommand } from "@/lib/commands"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import type { Command } from "@/lib/commands/base-command"; -import { registerCanceller } from "@/lib/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set<string>; - deltaTime: number; -} - -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: 0, -}; - -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} - -export function useKeyframeDrag({ - zoomLevel, - element, - displayedStartTime, -}: { - zoomLevel: number; - element: TimelineElement; - displayedStartTime: number; -}) { - const editor = useEditor(); - const { - selectedKeyframes, - isKeyframeSelected, - setKeyframeSelection, - toggleKeyframeSelection, - selectKeyframeRange, - } = useKeyframeSelection(); - - const [dragState, setDragState] = - useState<KeyframeDragState>(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef<PendingKeyframeDrag | null>(null); - const mouseDownXRef = useRef<number | null>(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: 0, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND); - const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, - selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], -); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; - - if (wasDrag) return; - - const duration = editor.timeline.getTotalDuration(); - const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime; - editor.playback.seek({ time: seekTime }); - - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps], - ); - - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); - - return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts deleted file mode 100644 index 2b5a0566..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { useCallback, useSyncExternalStore } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; - -export 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<string, SelectedKeyframeRef>(); - 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 deleted file mode 100644 index 3ff1cb21..00000000 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type RefObject, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { roundToFrame } from "opencut-wasm"; -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<HTMLElement | null>; - snappingEnabled: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -} - -export function useBookmarkDrag({ - zoomLevel, - scrollRef, - snappingEnabled, - onSnapPointChange, -}: UseBookmarkDragProps) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const tracks = editor.scenes.getActiveScene().tracks; - const activeScene = editor.scenes.getActiveScene(); - const bookmarks = activeScene?.bookmarks ?? []; - const playheadTime = editor.playback.getCurrentTime(); - const duration = editor.timeline.getTotalDuration(); - - const [dragState, setDragState] = useState<BookmarkDragState>({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef<PendingBookmarkDrag | null>(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 <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_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 = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration)); - 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 = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime; - 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 deleted file mode 100644 index 509031bb..00000000 --- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef<number | null>(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth,
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(0, viewportWidth - 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 deleted file mode 100644 index 0b1cf92a..00000000 --- a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type RefObject, useLayoutEffect, useRef } from "react";
-
-interface UseInitialScrollBottomProps {
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
- 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-position.ts b/apps/web/src/hooks/timeline/use-scroll-position.ts deleted file mode 100644 index ad2c6374..00000000 --- a/apps/web/src/hooks/timeline/use-scroll-position.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useState, useRef } from "react"; - -interface UseScrollPositionReturn { - scrollLeft: number; - viewportWidth: number; -} - -export function useScrollPosition({ - scrollRef, -}: { - scrollRef: React.RefObject<HTMLElement | null>; -}): UseScrollPositionReturn { - const [scrollLeft, setScrollLeft] = useState(0); - const [viewportWidth, setViewportWidth] = useState(0); - const rafIdRef = useRef<number | null>(null); - - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) return; - - const updatePosition = () => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - - rafIdRef.current = requestAnimationFrame(() => { - setScrollLeft(scrollElement.scrollLeft); - setViewportWidth(scrollElement.clientWidth); - rafIdRef.current = null; - }); - }; - - const resizeObserver = new ResizeObserver(() => { - updatePosition(); - }); - - updatePosition(); - - scrollElement.addEventListener("scroll", updatePosition, { passive: true }); - resizeObserver.observe(scrollElement); - - return () => { - scrollElement.removeEventListener("scroll", updatePosition); - resizeObserver.disconnect(); - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - }; - }, [scrollRef]); - - return { scrollLeft, viewportWidth }; -} diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts deleted file mode 100644 index e147c106..00000000 --- a/apps/web/src/hooks/timeline/use-scroll-sync.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { useEffect, useRef } from "react";
-
-interface UseScrollSyncProps {
- rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
- bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
-}
-
-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 deleted file mode 100644 index b941dcb5..00000000 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState } from "react";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
-interface UseSnapIndicatorPositionParams {
- snapPoint: { time: number } | null;
- zoomLevel: number;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-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 =
- TIMELINE_TRACK_LABELS_COLUMN_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 deleted file mode 100644 index 25c24b8b..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ /dev/null @@ -1,648 +0,0 @@ -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 { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; -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 "@/components/editor/panels/timeline/drop-target"; -import { getDragData, hasDragData } from "@/lib/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/lib/timeline/drag"; - -interface UseTimelineDragDropProps { - containerRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - tracksScrollRef?: RefObject<HTMLDivElement | null>; - zoomLevel: number; -} - -export function useTimelineDragDrop({ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, -}: UseTimelineDragDropProps) { - const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); - const [dragElementType, setElementType] = useState<ElementType | null>(null); - - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; - }, - [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 DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_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 sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_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.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - 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.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - 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.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - 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 != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_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.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - 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.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - 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 != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - 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-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts deleted file mode 100644 index 1886222f..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import { useEditor } from "../use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; - -interface UseTimelinePlayheadProps { - zoomLevel: number; - rulerRef: React.RefObject<HTMLDivElement | null>; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - playheadRef?: React.RefObject<HTMLDivElement | null>; -} - -export function useTimelinePlayhead({ - zoomLevel, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); - const isShiftHeldRef = useShiftKey(); - - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); - - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef<number | null>(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef<number>(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject.settings.fps; - const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = findSnapPoints({ - tracks, - playheadTime: frameTime, - bookmarks, - enablePlayheadSnapping: false, - }); - const snapResult = snapToNearestPoint({ - targetTime: frameTime, - snapPoints, - zoomLevel, - }); - return snapResult.snapPoint ? snapResult.snappedTime : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - editor.timeline, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); - - const updatePlayheadLeft = useCallback( - (time: number) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); - - useEffect(() => { - const scrollEl = rulerScrollRef.current; - if (!scrollEl) return; - - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - - useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); - - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); - return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); - }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); - - return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, - }; -} diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts deleted file mode 100644 index f246261a..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-resize.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - type ResizeSide, -} from "@/lib/timeline/group-resize"; -import { - findSnapPoints, - snapToNearestPoint, - type SnapPoint, -} from "@/lib/timeline/snap-utils"; -import type { - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/lib/timeline"; -import { isRetimableElement } from "@/lib/timeline"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; -} - -export function useTimelineResize({ - zoomLevel, - onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState<ResizeInteractionState | null>( - null, - ); - const latestResultRef = useRef<GroupResizeResult | null>(null); - - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; - editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - updates: patch, - })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, - onSnapPointChange, - ]); - - return { - isResizing: resizeState !== null, - handleResizeStart, - }; -} - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.elementId), - ); - const trackMap = new Map( - [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ - track.id, - track, - ]), - ); - - return selectedElements.flatMap(({ trackId, elementId }) => { - const track = trackMap.get(trackId); - const element = track?.elements.find( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); - - return [ - { - trackId, - elementId, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - sourceDuration: element.sourceDuration, - retime: isRetimableElement(element) ? element.retime : undefined, - leftNeighborBound, - rightNeighborBound, - }, - ]; - }); -} - -function getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; - } - - const snapPoints = findSnapPoints({ - tracks: editor.scenes.getActiveScene().tracks, - playheadTime: editor.playback.getCurrentTime(), - excludeElementIds: new Set( - resizeState.members.map((member) => member.elementId), - ), - }); - let closestSnapPoint: SnapPoint | null = null; - let closestSnapDistance = Infinity; - let deltaTime = rawDeltaTime; - - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = snapToNearestPoint({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - zoomLevel, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } - - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} - -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); -} diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts deleted file mode 100644 index 756ec7ee..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-seek.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { useCallback, useRef } from "react"; -import type { MutableRefObject, RefObject } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "../use-editor"; - -interface UseTimelineSeekProps { - playheadRef: RefObject<HTMLDivElement | null>; - trackLabelsRef: RefObject<HTMLDivElement | null>; - rulerScrollRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - 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 rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / - (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject?.settings.fps; - const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime; - seek(time); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, - }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); - - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); - - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); - - return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, - }; -} diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts deleted file mode 100644 index 1f79a5b2..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { - type WheelEvent as ReactWheelEvent, - type RefObject, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD, -} from "@/components/editor/panels/timeline/interaction"; -import { - TIMELINE_ZOOM_MAX, - TIMELINE_ZOOM_MIN, -} from "@/lib/timeline/scale"; -import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; -import { useEditor } from "@/hooks/use-editor"; -import { zoomToSlider } from "@/lib/timeline/zoom-utils"; - -interface UseTimelineZoomProps { - containerRef: RefObject<HTMLDivElement | null>; - minZoom?: number; - initialZoom?: number; - initialScrollLeft?: number; - initialPlayheadTime?: number; - tracksScrollRef: RefObject<HTMLDivElement | null>; - rulerScrollRef: RefObject<HTMLDivElement | null>; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleWheel: (event: ReactWheelEvent) => void; - saveScrollPosition: () => void; -} - -export function useTimelineZoom({ - containerRef, - minZoom = TIMELINE_ZOOM_MIN, - initialZoom, - initialScrollLeft, - initialPlayheadTime, - tracksScrollRef, - rulerScrollRef, -}: UseTimelineZoomProps): UseTimelineZoomReturn { - const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( - null, - ); - - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, initialZoom), - ); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); - - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); - - if (isHorizontalScrollGesture) { - return; - } - - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); - - useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel( - Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)), - ); - return; - } - setZoomLevel((prev) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); - - useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom }); - const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); - - useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); - - // prevent browser zoom in the timeline - useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); - - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); - - return { - zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, - }; -} diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts deleted file mode 100644 index ce48e036..00000000 --- a/apps/web/src/hooks/use-editor.ts +++ /dev/null @@ -1,60 +0,0 @@ -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<T>(selector: (editor: EditorCore) => T): T; -export function useEditor<T>( - selector?: (editor: EditorCore) => T, -): EditorCore | T { - const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef<unknown>(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), - editor.clipboard.subscribe(onChange), - editor.diagnostics.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 deleted file mode 100644 index 4f28be83..00000000 --- a/apps/web/src/hooks/use-element-preview.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useEditor } from "@/hooks/use-editor";
-import { findTrackInSceneTracks, 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<T extends TimelineElement>({
- trackId,
- elementId,
- fallback,
-}: {
- trackId: string;
- elementId: string;
- fallback: T;
-}) {
- const editor = useEditor();
- useEditor((e) => e.timeline.getPreviewTracks());
-
- const previewTracks = editor.timeline.getPreviewTracks();
- const renderElement =
- (findTrackInSceneTracks({
- tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
- trackId,
- })?.elements.find((element) => element.id === elementId) as T | undefined) ??
- fallback;
-
- const previewUpdates = (updates: Partial<TimelineElement>) =>
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates }],
- });
-
- const commit = () => editor.timeline.commitPreview();
-
- return { renderElement, previewUpdates, commit };
-}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts deleted file mode 100644 index d174e002..00000000 --- a/apps/web/src/hooks/use-file-upload.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useState, useRef } from "react"; -import { hasDragData } from "@/lib/drag-data"; - -interface UseFileUploadOptions { - accept?: string; - multiple?: boolean; - onFilesSelected?: (files: File[]) => void; -} - -function containsFiles(dataTransfer: DataTransfer): boolean { - return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); -} - -export function useFileUpload({ - accept, - multiple, - onFilesSelected, -}: UseFileUploadOptions = {}) { - const [isDragOver, setIsDragOver] = useState(false); - const dragCounterRef = useRef(0); - const inputRef = useRef<HTMLInputElement>(null); - - function openFilePicker() { - if (!inputRef.current) return; - - inputRef.current.accept = accept || "*"; - inputRef.current.multiple = multiple || false; - inputRef.current.click(); - } - - function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { - const files = Array.from(event.target.files ?? []); - if (files.length > 0 && onFilesSelected) { - onFilesSelected(files); - } - - if (event.target) { - event.target.value = ""; - } - } - - function handleDragEnter(e: React.DragEvent) { - e.preventDefault(); - - if (!containsFiles(e.dataTransfer)) return; - - dragCounterRef.current += 1; - setIsDragOver(true); - } - - function handleDragOver(e: React.DragEvent) { - e.preventDefault(); - - if (!containsFiles(e.dataTransfer)) return; - } - - function handleDragLeave(e: React.DragEvent) { - e.preventDefault(); - - if (!containsFiles(e.dataTransfer)) return; - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - } - - function handleDrop(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - if (onFilesSelected && containsFiles(e.dataTransfer)) { - const files = Array.from(e.dataTransfer.files); - const shouldUseMultiple = multiple ?? false; - - if (shouldUseMultiple) { - onFilesSelected(files); - } else if (files.length > 0) { - onFilesSelected([files[0]]); - } - } - } - - return { - isDragOver, - openFilePicker, - fileInputProps: { - ref: inputRef, - type: "file", - style: { display: "none" }, - onChange: handleFileChange, - }, - dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }, - }; -} diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts deleted file mode 100644 index aa48f3b4..00000000 --- a/apps/web/src/hooks/use-font-atlas.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 "@/lib/fonts/system-fonts";
-
-type Status = "idle" | "loading" | "error";
-
-export function useFontAtlas({ open }: { open: boolean }) {
- const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
- getCachedFontAtlas(),
- );
- const [status, setStatus] = useState<Status>(() =>
- 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-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts deleted file mode 100644 index 0c524214..00000000 --- a/apps/web/src/hooks/use-keybindings.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect } from "react"; -import { invokeAction } from "@/lib/actions"; -import { useEditor } from "@/hooks/use-editor"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { isTypableDOMElement } from "@/utils/browser"; - -/** - * a composable that hooks to the caller component's - * lifecycle and hooks to the keyboard events to fire - * the appropriate actions based on keybindings - */ -export function useKeybindingsListener() { - const editor = useEditor(); - const { - keybindings, - getKeybindingString, - overlayDepth, - isLoadingProject, - isRecording, - } = useKeybindingsStore(); - - useEffect(() => { - const eventOptions: AddEventListenerOptions = { capture: true }; - const handleKeyDown = (ev: KeyboardEvent) => { - const normalizedKey = (ev.key ?? "").toLowerCase(); - - if (overlayDepth > 0 || isLoadingProject || isRecording) { - return; - } - - const binding = getKeybindingString(ev); - const activeElement = document.activeElement; - const isTextInput = - activeElement instanceof HTMLElement && - isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; - - if (normalizedKey === "escape" && isTextInput) { - activeElement.blur(); - return; - } - - if (!binding) return; - if (!boundAction) return; - - if (isTextInput) return; - if (boundAction === "paste-copied") { - if (!editor.clipboard.hasEntry()) return; - ev.preventDefault(); - invokeAction("paste-copied", undefined, "keypress"); - return; - } - - ev.preventDefault(); - - switch (boundAction) { - case "seek-forward": - invokeAction("seek-forward", { seconds: 1 }, "keypress"); - break; - case "seek-backward": - invokeAction("seek-backward", { seconds: 1 }, "keypress"); - break; - case "jump-forward": - invokeAction("jump-forward", { seconds: 5 }, "keypress"); - break; - case "jump-backward": - invokeAction("jump-backward", { seconds: 5 }, "keypress"); - break; - default: - invokeAction(boundAction, undefined, "keypress"); - } - }; - - document.addEventListener("keydown", handleKeyDown, eventOptions); - - return () => { - document.removeEventListener("keydown", handleKeyDown, eventOptions); - }; - }, [ - keybindings, - getKeybindingString, - overlayDepth, - isLoadingProject, - isRecording, - editor, - ]); -} diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts deleted file mode 100644 index 9a2f6cf5..00000000 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions"; -import { - getPlatformAlternateKey, - getPlatformSpecialKey, -} from "@/utils/platform"; - -export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: TActionWithOptionalArgs; - icon?: React.ReactNode; -} - -function formatKey({ key }: { key: string }): string { - return key - .replace("ctrl", getPlatformSpecialKey()) - .replace("alt", getPlatformAlternateKey()) - .replace("shift", "Shift") - .replace("left", "←") - .replace("right", "→") - .replace("up", "↑") - .replace("down", "↓") - .replace("space", "Space") - .replace("home", "Home") - .replace("enter", "Enter") - .replace("end", "End") - .replace("delete", "Delete") - .replace("backspace", "Backspace") - .replace("-", "+"); -} - -export function useKeyboardShortcutsHelp() { - const { keybindings } = useKeybindingsStore(); - - const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {}; - - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); - } - } - - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { - const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } - result.push({ - id: action, - keys, - description: actionDef.description, - category: actionDef.category, - action, - }); - } - - return result.sort((a, b) => { - if (a.category !== b.category) { - return a.category.localeCompare(b.category); - } - return a.description.localeCompare(b.description); - }); - }, [keybindings]); - - return { - shortcuts, - }; -} diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts deleted file mode 100644 index 917a0677..00000000 --- a/apps/web/src/hooks/use-mask-handles.ts +++ /dev/null @@ -1,633 +0,0 @@ -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 { - parseCustomMaskHandleId, - parseCustomMaskSegmentHandleId, -} from "@/lib/masks/custom-path"; -import { appendPointToCustomMask } from "@/lib/masks/definitions/custom"; -import { - getVisibleElementsWithBounds, - type ElementBounds, -} from "@/lib/preview/element-bounds"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection"; -import type { Mask, MaskInteractionResult } 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: Mask["params"]; -} - -interface PendingSegmentInsertState { - trackId: string; - elementId: string; - maskId: string; - segmentIndex: number; - startClientX: number; - startClientY: number; - startCanvasX: number; - startCanvasY: number; - startParams: Mask["params"]; - bounds: ElementBounds; -} - -const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4; - -function isMaskSelectionForElement({ - trackId, - elementId, - maskId, - selection, -}: { - trackId: string; - elementId: string; - maskId: string; - selection: SelectedMaskPointSelection | null; -}): boolean { - if (!selection) { - return false; - } - return ( - selection.trackId === trackId && - selection.elementId === elementId && - selection.maskId === maskId - ); -} - -function replaceElementMask({ - masks, - updatedMask, -}: { - masks: MaskableElement["masks"]; - updatedMask: Mask; -}): Mask[] { - return (masks ?? []).map((mask) => - mask.id === updatedMask.id ? updatedMask : mask, - ); -} - -function withUpdatedMaskParams<TMask extends Mask>({ - mask, - params, -}: { - mask: TMask; - params: TMask["params"]; -}): TMask { - return { - ...mask, - params, - }; -} - -export function useMaskHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: (lines: SnapLine[]) => void; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandleId, setActiveHandleId] = useState<string | null>(null); - const dragStateRef = useRef<DragState | null>(null); - const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( - null, - ); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - 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 selectedMaskPointSelection = useEditor((e) => - e.selection.getSelectedMaskPointSelection(), - ); - - 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; - const masks = element.masks ?? []; - if (masks.length === 0) return null; - const activeMaskId = masks.some((mask) => - isMaskSelectionForElement({ - trackId: entry.trackId, - elementId: entry.elementId, - maskId: mask.id, - selection: selectedMaskPointSelection, - }), - ) - ? selectedMaskPointSelection?.maskId - : masks[0].id; - const mask = - masks.find((candidate) => candidate.id === activeMaskId) ?? - masks[0]; - return { ...entry, element, mask }; - })() - : null; - - const { handles: baseHandlePositions, overlays }: MaskInteractionResult = - selectedWithMask - ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); - const displayScale = (scaleX + scaleY) / 2; - return def.interaction.getInteraction({ - params: selectedWithMask.mask.params, - bounds: selectedWithMask.bounds, - displayScale, - scaleX, - scaleY, - }); - })() - : { handles: [], overlays: [] }; - - const selectedPointIds = new Set( - selectedWithMask && - isMaskSelectionForElement({ - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - maskId: selectedWithMask.mask.id, - selection: selectedMaskPointSelection, - }) - ? (selectedMaskPointSelection?.pointIds ?? []) - : [], - ); - - const handlePositions = baseHandlePositions.map((handle) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); - if (!parsedHandle || parsedHandle.part !== "anchor") { - return handle; - } - return { - ...handle, - isSelected: selectedPointIds.has(parsedHandle.pointId), - }; - }); - - const customMaskPointIds = - selectedWithMask?.mask.type === "custom" - ? handlePositions - .filter((h) => h.kind === "point") - .map((h) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); - return parsedHandle?.part === "anchor" - ? parsedHandle.pointId - : null; - }) - .filter((id): id is string => id !== null) - : []; - const isCreatingCustomMask = - selectedWithMask?.mask.type === "custom" && - (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); - - useEffect(() => { - if (!selectedMaskPointSelection) { - return; - } - if ( - !selectedWithMask || - selectedWithMask.mask.type !== "custom" || - !isMaskSelectionForElement({ - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - maskId: selectedWithMask.mask.id, - selection: selectedMaskPointSelection, - }) - ) { - editor.selection.clearMaskPointSelection(); - return; - } - - const availablePointIds = new Set(customMaskPointIds); - const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter( - (pointId) => availablePointIds.has(pointId), - ); - if ( - nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length - ) { - return; - } - if (nextSelectedPointIds.length === 0) { - editor.selection.clearMaskPointSelection(); - return; - } - - editor.selection.setSelectedMaskPoints({ - selection: { - ...selectedMaskPointSelection, - pointIds: nextSelectedPointIds, - }, - }); - }, [ - customMaskPointIds, - editor.selection, - selectedMaskPointSelection, - selectedWithMask, - ]); - - const updateCustomMaskPointSelection = useCallback( - ({ - pointId, - toggleSelection, - }: { - pointId: string; - toggleSelection: boolean; - }) => { - if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { - return; - } - - const isSelectionForCurrentMask = isMaskSelectionForElement({ - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - maskId: selectedWithMask.mask.id, - selection: selectedMaskPointSelection, - }); - const currentPointIds = isSelectionForCurrentMask - ? (selectedMaskPointSelection?.pointIds ?? []) - : []; - const nextPointIds = toggleSelection - ? currentPointIds.includes(pointId) - ? currentPointIds.filter( - (currentPointId) => currentPointId !== pointId, - ) - : [...currentPointIds, pointId] - : [pointId]; - - if (nextPointIds.length === 0) { - editor.selection.clearMaskPointSelection(); - return; - } - - editor.selection.setSelectedMaskPoints({ - selection: { - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - maskId: selectedWithMask.mask.id, - pointIds: nextPointIds, - }, - }); - }, - [editor.selection, selectedMaskPointSelection, selectedWithMask], - ); - - const clearMaskHandleState = useCallback(() => { - dragStateRef.current = null; - pendingSegmentInsertRef.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; - if (event.button !== 0) return; - event.stopPropagation(); - const parsedHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskHandleId({ handleId }) - : null; - const parsedSegmentHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskSegmentHandleId({ handleId }) - : null; - if (isCreatingCustomMask) { - const firstPointId = customMaskPointIds[0]; - if ( - firstPointId && - handleId === `point:${firstPointId}:anchor` && - customMaskPointIds.length >= 3 && - selectedWithMask.mask.type === "custom" - ) { - const updatedMask = withUpdatedMaskParams({ - mask: selectedWithMask.mask, - params: { - ...selectedWithMask.mask.params, - closed: true, - }, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - patch: { - masks: replaceElementMask({ - masks: selectedWithMask.element.masks, - updatedMask, - }), - } as Partial<MaskableElement>, - }, - ], - }); - } - return; - } - - const pos = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!pos) return; - - if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { - setActiveHandleId(handleId); - pendingSegmentInsertRef.current = { - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - maskId: selectedWithMask.mask.id, - segmentIndex: parsedSegmentHandle.segmentIndex, - startClientX: event.clientX, - startClientY: event.clientY, - startCanvasX: pos.x, - startCanvasY: pos.y, - startParams: { ...selectedWithMask.mask.params }, - bounds: selectedWithMask.bounds, - }; - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - return; - } - - if (parsedHandle?.part === "anchor") { - updateCustomMaskPointSelection({ - pointId: parsedHandle.pointId, - toggleSelection: event.shiftKey, - }); - if (event.shiftKey) { - return; - } - } else if (selectedWithMask.mask.type === "custom") { - editor.selection.clearMaskPointSelection(); - } - - 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, - }; - }, - [ - customMaskPointIds, - editor.selection, - editor.timeline, - isCreatingCustomMask, - selectedWithMask, - updateCustomMaskPointSelection, - viewport, - ], - ); - - const handleCanvasPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithMask || !isCreatingCustomMask) { - return; - } - if (event.button !== 0) { - return; - } - if (selectedWithMask.mask.type !== "custom") { - return; - } - - event.stopPropagation(); - const pos = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!pos) { - return; - } - - const nextParams = appendPointToCustomMask({ - params: selectedWithMask.mask.params, - canvasPoint: pos, - bounds: selectedWithMask.bounds, - }); - const updatedMask = withUpdatedMaskParams({ - mask: selectedWithMask.mask, - params: nextParams, - }); - - editor.timeline.updateElements({ - updates: [ - { - trackId: selectedWithMask.trackId, - elementId: selectedWithMask.elementId, - patch: { - masks: replaceElementMask({ - masks: selectedWithMask.element.masks, - updatedMask, - }), - } as Partial<MaskableElement>, - }, - ], - }); - }, - [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const pendingSegmentInsert = pendingSegmentInsertRef.current; - if (pendingSegmentInsert && !dragStateRef.current) { - const distance = Math.hypot( - event.clientX - pendingSegmentInsert.startClientX, - event.clientY - pendingSegmentInsert.startClientY, - ); - if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) { - dragStateRef.current = { - trackId: pendingSegmentInsert.trackId, - elementId: pendingSegmentInsert.elementId, - handleId: "position", - startCanvasX: pendingSegmentInsert.startCanvasX, - startCanvasY: pendingSegmentInsert.startCanvasY, - startParams: pendingSegmentInsert.startParams, - }; - pendingSegmentInsertRef.current = null; - setActiveHandleId("position"); - } - } - - 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[] } - : (def.interaction.snap?.({ - handleId: drag.handleId, - startParams: drag.startParams, - proposedParams, - bounds: selectedWithMask.bounds, - canvasSize, - snapThreshold, - }) ?? { params: proposedParams, activeLines: [] as SnapLine[] }); - - onSnapLinesChange?.(activeLines); - - const updatedMask = withUpdatedMaskParams({ - mask: selectedWithMask.mask, - params: nextParams as typeof selectedWithMask.mask.params, - }); - editor.timeline.previewElements({ - updates: [ - { - trackId: drag.trackId, - elementId: drag.elementId, - updates: { - masks: replaceElementMask({ - masks: selectedWithMask.element.masks, - updatedMask, - }), - } as Partial<MaskableElement>, - }, - ], - }); - }, - [ - selectedWithMask, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - const pendingSegmentInsert = pendingSegmentInsertRef.current; - if (pendingSegmentInsert && !dragStateRef.current) { - editor.timeline.insertCustomMaskPoint({ - trackId: pendingSegmentInsert.trackId, - elementId: pendingSegmentInsert.elementId, - maskId: pendingSegmentInsert.maskId, - segmentIndex: pendingSegmentInsert.segmentIndex, - canvasPoint: { - x: pendingSegmentInsert.startCanvasX, - y: pendingSegmentInsert.startCanvasY, - }, - bounds: pendingSegmentInsert.bounds, - }); - clearMaskHandleState(); - releaseCapturedPointer(); - return; - } - - if (dragStateRef.current) { - editor.timeline.commitPreview(); - clearMaskHandleState(); - } - releaseCapturedPointer(); - }, [clearMaskHandleState, editor, releaseCapturedPointer]); - - return { - selectedWithMask, - handlePositions, - overlays, - isCreatingCustomMask, - handleCanvasPointerDown, - activeHandleId, - handlePointerDown, - handlePointerMove, - handlePointerUp, - }; -} diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts deleted file mode 100644 index a3c14489..00000000 --- a/apps/web/src/hooks/use-menu-preview.ts +++ /dev/null @@ -1,26 +0,0 @@ -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-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts deleted file mode 100644 index 95020a04..00000000 --- a/apps/web/src/hooks/use-paste-media.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { processMediaAssets } from "@/lib/media/processing"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; -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 { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -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(); - editor.clipboard.paste(); - 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 != null - ? Math.round(asset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_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-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts deleted file mode 100644 index cfb3d527..00000000 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { Transform } from "@/lib/rendering"; -import type { ElementRef, TextElement } from "@/lib/timeline"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/lib/preview/element-bounds"; -import { - getHitElements, - hitTest, - resolvePreferredHit, -} from "@/lib/preview/hit-test"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - snapPosition, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -export type OnSnapLinesChange = (lines: SnapLine[]) => void; - -const MIN_DRAG_DISTANCE = 0.5; - -interface CapturedPointerState { - pointerId: number; - captureTarget: HTMLElement; -} - -interface PendingGestureState extends CapturedPointerState { - startX: number; - startY: number; - topmostHit: ElementWithBounds | null; - selectedHit: ElementWithBounds | null; - selectedElements: ElementRef[]; -} - -interface DragState extends CapturedPointerState { - startX: number; - startY: number; - bounds: { - width: number; - height: number; - rotation: number; - }; - elements: Array<{ - trackId: string; - elementId: string; - initialTransform: Transform; - }>; -} - -function isSameElementRef({ - left, - right, -}: { - left: ElementRef; - right: ElementRef; -}): boolean { - return left.trackId === right.trackId && left.elementId === right.elementId; -} - -function buildDragSelection({ - selectedElements, - dragTarget, -}: { - selectedElements: ElementRef[]; - dragTarget: ElementWithBounds; -}): ElementRef[] { - const dragTargetRef = { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }; - - if ( - !selectedElements.some((selectedElement) => - isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ) - ) { - return [dragTargetRef]; - } - - return [ - dragTargetRef, - ...selectedElements.filter( - (selectedElement) => - !isSameElementRef({ left: selectedElement, right: dragTargetRef }), - ), - ]; -} - -export function usePreviewInteraction({ - onSnapLinesChange, - isMaskMode = false, -}: { - onSnapLinesChange?: OnSnapLinesChange; - isMaskMode?: boolean; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [isDragging, setIsDragging] = useState(false); - const [editingText, setEditingText] = useState<{ - trackId: string; - elementId: string; - element: TextElement; - originalOpacity: number; - } | null>(null); - const dragStateRef = useRef<DragState | null>(null); - const pendingGestureRef = useRef<PendingGestureState | null>(null); - const wasPlayingRef = useRef(editor.playback.getIsPlaying()); - const editingTextRef = useRef(editingText); - editingTextRef.current = editingText; - - const releaseCapturedPointer = useCallback( - (pointerState: CapturedPointerState | null) => { - if (!pointerState) return; - - if ( - !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId) - ) { - return; - } - - pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); - }, - [], - ); - - const commitTextEdit = useCallback(() => { - const current = editingTextRef.current; - if (!current) return; - editingTextRef.current = null; - editor.timeline.commitPreview(); - setEditingText(null); - }, [editor.timeline]); - - useEffect(() => { - const unsubscribe = editor.playback.subscribe(() => { - const isPlaying = editor.playback.getIsPlaying(); - if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { - commitTextEdit(); - } - wasPlayingRef.current = isPlaying; - }); - return unsubscribe; - }, [editor.playback, commitTextEdit]); - - useEffect(() => { - if (!isDragging) return; - - return registerCanceller({ - fn: () => { - const dragState = dragStateRef.current; - if (!dragState) return; - - editor.timeline.discardPreview(); - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - }, - }); - }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]); - - const handleDoubleClick = useCallback( - ({ clientX, clientY }: React.MouseEvent) => { - if (editingText || isMaskMode) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hit = hitTest({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - - if (!hit || hit.element.type !== "text") return; - - const textElement = hit.element as TextElement; - setEditingText({ - trackId: hit.trackId, - elementId: hit.elementId, - element: textElement, - originalOpacity: textElement.opacity, - }); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerDown = useCallback( - ({ - clientX, - clientY, - currentTarget, - pointerId, - button, - }: React.PointerEvent) => { - if (editingText) return; - if (isMaskMode) return; - if (button !== 0) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hits = getHitElements({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - const selectedElements = editor.selection.getSelectedElements(); - const topmostHit = hits[0] ?? null; - - pendingGestureRef.current = { - startX: startPos.x, - startY: startPos.y, - pointerId, - captureTarget: currentTarget as HTMLElement, - topmostHit, - selectedHit: resolvePreferredHit({ - hits, - preferredElements: selectedElements, - }), - selectedElements, - }; - currentTarget.setPointerCapture(pointerId); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerMove = useCallback( - ({ clientX, clientY }: React.PointerEvent) => { - const canvasSize = editor.project.getActive().settings.canvasSize; - - const currentPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!currentPos) return; - - let dragState = dragStateRef.current; - - if (!dragState) { - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - const deltaX = currentPos.x - pendingGesture.startX; - const deltaY = currentPos.y - pendingGesture.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - - if (!hasMovement) { - onSnapLinesChange?.([]); - return; - } - - const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit; - if (!dragTarget) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - const dragSelection = buildDragSelection({ - selectedElements: pendingGesture.selectedElements, - dragTarget, - }); - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: dragSelection, - }); - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - if (pendingGesture.selectedHit === null) { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }, - ], - }); - } - - dragState = { - startX: pendingGesture.startX, - startY: pendingGesture.startY, - pointerId: pendingGesture.pointerId, - captureTarget: pendingGesture.captureTarget, - bounds: { - width: dragTarget.bounds.width, - height: dragTarget.bounds.height, - rotation: dragTarget.bounds.rotation, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), - }; - dragStateRef.current = dragState; - pendingGestureRef.current = null; - setIsDragging(true); - } - - const deltaX = currentPos.x - dragState.startX; - const deltaY = currentPos.y - dragState.startY; - const firstElement = dragState.elements[0]; - const proposedPosition = { - x: firstElement.initialTransform.position.x + deltaX, - y: firstElement.initialTransform.position.y + deltaY, - }; - - const shouldSnap = !isShiftHeldRef.current; - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedPosition, activeLines } = shouldSnap - ? snapPosition({ - proposedPosition, - canvasSize, - elementSize: dragState.bounds, - rotation: dragState.bounds.rotation, - snapThreshold, - }) - : { - snappedPosition: proposedPosition, - activeLines: [] as SnapLine[], - }; - - onSnapLinesChange?.(activeLines); - - const deltaSnappedX = - snappedPosition.x - firstElement.initialTransform.position.x; - const deltaSnappedY = - snappedPosition.y - firstElement.initialTransform.position.y; - - const updates = dragState.elements.map( - ({ trackId, elementId, initialTransform }) => ({ - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, - }, - }, - }), - ); - - editor.timeline.previewElements({ updates }); - }, - [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], - ); - - const handlePointerUp = useCallback( - ({ type }: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState) { - if (type === "pointercancel") { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); - } - - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - return; - } - - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - if (type !== "pointercancel") { - const clickTarget = pendingGesture.topmostHit; - if (!clickTarget) { - editor.selection.clearSelection(); - } else { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: clickTarget.trackId, - elementId: clickTarget.elementId, - }, - ], - }); - } - } - - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - }, - [editor, onSnapLinesChange, releaseCapturedPointer], - ); - - return { - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onDoubleClick: handleDoubleClick, - editingText, - commitTextEdit, - }; -} diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts deleted file mode 100644 index 263a01e2..00000000 --- a/apps/web/src/hooks/use-sound-search.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index a026b510..00000000 --- a/apps/web/src/hooks/use-storage-persistence.ts +++ /dev/null @@ -1,45 +0,0 @@ -"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 deleted file mode 100644 index 7495a387..00000000 --- a/apps/web/src/hooks/use-transform-handles.ts +++ /dev/null @@ -1,631 +0,0 @@ -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, - hasKeyframesForPath, - 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<HandleType | null>(null); - const scaleStateRef = useRef<ScaleState | null>(null); - const rotationStateRef = useRef<RotationState | null>(null); - const edgeScaleStateRef = useRef<EdgeScaleState | null>(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - 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 = - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleX", - }) || - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "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 = - hasKeyframesForPath({ - animations: element.animations, - 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, - }; -} |
