diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 7e6ff326..18f768a9 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -23,7 +23,7 @@ import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "../../selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS, diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index e70f0cd7..54ce5237 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,7 +1,7 @@ "use client"; import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 628855f6..657e9a8b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,7 +4,7 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { getTrackClasses, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index b7b06d5b..42faadaf 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/types/timeline"; import type { TimelineElement as TimelineElementType } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/types/timeline"; diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 79f1272c..e7b93813 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -17,14 +17,16 @@ import { snapTimeToFrame } from "@/lib/time"; import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + snapElementEdge, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; import type { DropTarget, ElementDragState, TimelineElement, TimelineTrack, } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; interface UseElementInteractionProps { zoomLevel: number; @@ -162,7 +164,6 @@ export function useElementInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); - const { snapElementEdge } = useTimelineSnapping(); const { isElementSelected, selectElement, @@ -255,14 +256,7 @@ export function useElementInteraction({ snapPoint: snapResult.snapPoint, }; }, - [ - snappingEnabled, - editor.playback, - snapElementEdge, - tracks, - zoomLevel, - isShiftHeldRef, - ], + [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 14693fff..2b303ad9 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -5,9 +5,10 @@ import type { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { - useTimelineSnapping, + findSnapPoints, + snapToNearestPoint, type SnapPoint, -} from "@/hooks/timeline/use-timeline-snapping"; +} from "@/lib/timeline/snap-utils"; import { useTimelineStore } from "@/stores/timeline-store"; export interface ResizeState { @@ -39,7 +40,7 @@ export function useTimelineElementResize({ const activeProject = editor.project.getActive(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); + const [resizing, setResizing] = useState(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -269,8 +270,6 @@ export function useTimelineElementResize({ activeProject.settings.fps, snappingEnabled, editor, - findSnapPoints, - snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration, diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index faf28e40..0e7fabd5 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -10,9 +10,12 @@ import { useShiftKey } from "@/hooks/use-shift-key"; import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; import { snapTimeToFrame } from "@/lib/time"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; -import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; import type { Bookmark } from "@/types/timeline"; -import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; export interface BookmarkDragState { isDragging: boolean; @@ -47,8 +50,6 @@ export function useBookmarkDrag({ const playheadTime = editor.playback.getCurrentTime(); const duration = editor.timeline.getTotalDuration(); - const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping(); - const [dragState, setDragState] = useState({ isDragging: false, bookmarkTime: null, @@ -112,16 +113,7 @@ export function useBookmarkDrag({ snapPoint: result.snapPoint, }; }, - [ - snappingEnabled, - findSnapPoints, - snapToNearestPoint, - tracks, - playheadTime, - bookmarks, - zoomLevel, - isShiftHeldRef, - ], + [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], ); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 1e536dae..d6bb29e2 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -3,7 +3,10 @@ import { useState, 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 { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping"; +import { + findSnapPoints, + snapToNearestPoint, +} from "@/lib/timeline/snap-utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; interface UseTimelinePlayheadProps { @@ -28,10 +31,6 @@ export function useTimelinePlayhead({ const isPlaying = editor.playback.getIsPlaying(); const isScrubbing = editor.playback.getIsScrubbing(); const isShiftHeldRef = useShiftKey(); - const { snapToNearestPoint, findSnapPoints } = useTimelineSnapping({ - enableElementSnapping: true, - enablePlayheadSnapping: false, - }); const seek = useCallback( ({ time }: { time: number }) => editor.playback.seek({ time }), @@ -87,6 +86,7 @@ export function useTimelinePlayhead({ tracks, playheadTime: frameTime, bookmarks, + enablePlayheadSnapping: false, }); const snapResult = snapToNearestPoint({ targetTime: frameTime, @@ -110,8 +110,6 @@ export function useTimelinePlayhead({ isShiftHeldRef, editor.scenes, editor.timeline, - findSnapPoints, - snapToNearestPoint, ], ); diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts deleted file mode 100644 index 5de75448..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { useCallback } from "react"; -import type { Bookmark, TimelineTrack } from "@/types/timeline"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -export interface UseTimelineSnappingOptions { - snapThreshold?: number; - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; - enableBookmarkSnapping?: boolean; -} - -export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, - enableBookmarkSnapping = true, -}: UseTimelineSnappingOptions = {}) { - const findSnapPoints = useCallback( - ({ - tracks, - playheadTime, - excludeElementId, - bookmarks = [], - excludeBookmarkTime, - }: { - tracks: Array; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array; - excludeBookmarkTime?: number; - }): SnapPoint[] => { - const snapPoints: SnapPoint[] = []; - - if (enableElementSnapping) { - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; - - const elementStart = element.startTime; - const elementEnd = element.startTime + element.duration; - - snapPoints.push( - { - time: elementStart, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: elementEnd, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - } - } - } - - if (enablePlayheadSnapping) { - snapPoints.push({ - time: playheadTime, - type: "playhead", - }); - } - - if (enableBookmarkSnapping) { - for (const bookmark of bookmarks) { - if ( - excludeBookmarkTime != null && - Math.abs(bookmark.time - excludeBookmarkTime) < - BOOKMARK_TIME_EPSILON - ) { - continue; - } - snapPoints.push({ - time: bookmark.time, - type: "bookmark", - }); - } - } - - return snapPoints; - }, - [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping], - ); - - const snapToNearestPoint = useCallback( - ({ - targetTime, - snapPoints, - zoomLevel, - }: { - targetTime: number; - snapPoints: Array; - zoomLevel: number; - }): SnapResult => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - for (const snapPoint of snapPoints) { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - } - - return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; - }, - [snapThreshold], - ); - - const snapElementEdge = useCallback( - ({ - targetTime, - elementDuration, - tracks, - playheadTime, - zoomLevel, - excludeElementId, - snapToStart = true, - bookmarks = [], - }: { - targetTime: number; - elementDuration: number; - tracks: Array; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array; - }): SnapResult => { - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks, - }); - - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - const snapResult = snapToNearestPoint({ - targetTime: effectiveTargetTime, - snapPoints, - zoomLevel, - }); - - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; - }, - [findSnapPoints, snapToNearestPoint], - ); - - return { - snapElementEdge, - findSnapPoints, - snapToNearestPoint, - }; -} diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts new file mode 100644 index 00000000..a29737d0 --- /dev/null +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -0,0 +1,155 @@ +import type { Bookmark, TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; + +export interface SnapPoint { + time: number; + type: "element-start" | "element-end" | "playhead" | "bookmark"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +const DEFAULT_SNAP_THRESHOLD_PX = 10; + +export function findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks = [], + excludeBookmarkTime, + enableElementSnapping = true, + enablePlayheadSnapping = true, + enableBookmarkSnapping = true, +}: { + tracks: Array; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array; + excludeBookmarkTime?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; + enableBookmarkSnapping?: boolean; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + + if (enableElementSnapping) { + for (const track of tracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + snapPoints.push( + { + time: element.startTime, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: element.startTime + element.duration, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + } + } + + if (enablePlayheadSnapping) { + snapPoints.push({ time: playheadTime, type: "playhead" }); + } + + if (enableBookmarkSnapping) { + for (const bookmark of bookmarks) { + if ( + excludeBookmarkTime != null && + Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON + ) { + continue; + } + snapPoints.push({ time: bookmark.time, type: "bookmark" }); + } + } + + return snapPoints; +} + +export function snapToNearestPoint({ + targetTime, + snapPoints, + zoomLevel, + snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, +}: { + targetTime: number; + snapPoints: Array; + zoomLevel: number; + snapThreshold?: number; +}): SnapResult { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; +} + +export function snapElementEdge({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + bookmarks = [], +}: { + targetTime: number; + elementDuration: number; + tracks: Array; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array; +}): SnapResult { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks, + }); + + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + + const snapResult = snapToNearestPoint({ + targetTime: effectiveTargetTime, + snapPoints, + zoomLevel, + }); + + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; +}