From 4de81796a34f43d40e188f11f298943bb0654972 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:23:45 +0600 Subject: [PATCH 01/12] feat: add SnapIndicator component for timeline snapping visualization --- .../src/components/editor/snap-indicator.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/src/components/editor/snap-indicator.tsx diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx new file mode 100644 index 00000000..398e06c6 --- /dev/null +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { SnapPoint } from "@/hooks/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +interface SnapIndicatorProps { + snapPoint: SnapPoint | null; + zoomLevel: number; + timelineHeight: number; + isVisible: boolean; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + timelineHeight, + isVisible, +}: SnapIndicatorProps) { + if (!isVisible || !snapPoint) { + return null; + } + + const leftPosition = + snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const getIndicatorColor = () => { + switch (snapPoint.type) { + case "grid": + return "bg-blue-400"; + case "element-start": + case "element-end": + return "bg-green-400"; + case "playhead": + return "bg-red-400"; + default: + return "bg-gray-400"; + } + }; + + const getIndicatorLabel = () => { + switch (snapPoint.type) { + case "grid": + return "Grid"; + case "element-start": + return "Start"; + case "element-end": + return "End"; + case "playhead": + return "Playhead"; + default: + return ""; + } + }; + + return ( +
+ {/* Snap line */} +
+ + {/* Snap label */} +
+ {getIndicatorLabel()} ({snapPoint.time.toFixed(1)}s) +
+
+ ); +} From 2b57f3026c3d6b2e8975b9c4dad956af3296995c Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:25:16 +0600 Subject: [PATCH 02/12] feat: add snapping settings and actions to timeline store --- apps/web/src/stores/timeline-store.ts | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 37b45b4a..b45f5507 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -42,6 +42,22 @@ interface TimelineStore { // Manual method if you need to force recomputation getSortedTracks: () => TimelineTrack[]; + // Snapping settings + snappingEnabled: boolean; + gridSnappingEnabled: boolean; + elementSnappingEnabled: boolean; + playheadSnappingEnabled: boolean; + snapThreshold: number; + gridInterval: number; + + // Snapping actions + toggleSnapping: () => void; + toggleGridSnapping: () => void; + toggleElementSnapping: () => void; + togglePlayheadSnapping: () => void; + setSnapThreshold: (threshold: number) => void; + setGridInterval: (interval: number) => void; + // Multi-selection selectedElements: { trackId: string; elementId: string }[]; selectElement: (trackId: string, elementId: string, multi?: boolean) => void; @@ -203,6 +219,14 @@ export const useTimelineStore = create((set, get) => { redoStack: [], selectedElements: [], + // Snapping settings defaults + snappingEnabled: true, + gridSnappingEnabled: true, + elementSnappingEnabled: true, + playheadSnappingEnabled: true, + snapThreshold: 10, // pixels + gridInterval: 1, // seconds + getSortedTracks: () => { const { _tracks } = get(); const tracksWithMain = ensureMainTrack(_tracks); @@ -949,5 +973,34 @@ export const useTimelineStore = create((set, get) => { updateTracks(defaultTracks); set({ history: [], redoStack: [], selectedElements: [] }); }, + + // Snapping actions + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, + + toggleGridSnapping: () => { + set((state) => ({ gridSnappingEnabled: !state.gridSnappingEnabled })); + }, + + toggleElementSnapping: () => { + set((state) => ({ + elementSnappingEnabled: !state.elementSnappingEnabled, + })); + }, + + togglePlayheadSnapping: () => { + set((state) => ({ + playheadSnappingEnabled: !state.playheadSnappingEnabled, + })); + }, + + setSnapThreshold: (threshold) => { + set({ snapThreshold: threshold }); + }, + + setGridInterval: (interval) => { + set({ gridInterval: interval }); + }, }; }); From e7485340cf37e330cbdd940cd503845f0c32b4dc Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:25:51 +0600 Subject: [PATCH 03/12] feat: implement useTimelineSnapping hook for snapping functionality in timeline --- apps/web/src/hooks/use-timeline-snapping.ts | 202 ++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 apps/web/src/hooks/use-timeline-snapping.ts diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts new file mode 100644 index 00000000..68b9a7c0 --- /dev/null +++ b/apps/web/src/hooks/use-timeline-snapping.ts @@ -0,0 +1,202 @@ +import { useCallback } from "react"; +import { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +export interface SnapPoint { + time: number; + type: "grid" | "element-start" | "element-end" | "playhead"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +export interface UseTimelineSnappingOptions { + snapThreshold?: number; // Distance in pixels to trigger snapping + gridInterval?: number; // Grid interval in seconds + enableGridSnapping?: boolean; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; +} + +export function useTimelineSnapping({ + snapThreshold = 10, + gridInterval = 1, + enableGridSnapping = true, + enableElementSnapping = true, + enablePlayheadSnapping = true, +}: UseTimelineSnappingOptions = {}) { + const findSnapPoints = useCallback( + ( + tracks: TimelineTrack[], + currentTime: number, + playheadTime: number, + zoomLevel: number, + excludeElementId?: string + ): SnapPoint[] => { + const snapPoints: SnapPoint[] = []; + + // Add grid snap points + if (enableGridSnapping) { + const gridStart = Math.floor(currentTime / gridInterval) * gridInterval; + const gridEnd = Math.ceil(currentTime / gridInterval) * gridInterval; + + // Add nearby grid points + for (let i = -2; i <= 2; i++) { + const gridTime = gridStart + i * gridInterval; + if (gridTime >= 0) { + snapPoints.push({ + time: gridTime, + type: "grid", + }); + } + } + } + + // Add element snap points + if (enableElementSnapping) { + tracks.forEach((track) => { + track.elements.forEach((element) => { + // Skip the element being dragged + if (element.id === excludeElementId) return; + + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + snapPoints.push( + { + time: elementStart, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: elementEnd, + type: "element-end", + elementId: element.id, + trackId: track.id, + } + ); + }); + }); + } + + // Add playhead snap point + if (enablePlayheadSnapping) { + snapPoints.push({ + time: playheadTime, + type: "playhead", + }); + } + + return snapPoints; + }, + [ + enableGridSnapping, + enableElementSnapping, + enablePlayheadSnapping, + gridInterval, + ] + ); + + const snapToNearestPoint = useCallback( + ( + targetTime: number, + snapPoints: SnapPoint[], + zoomLevel: number + ): SnapResult => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + snapPoints.forEach((snapPoint) => { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + }); + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; + }, + [snapThreshold] + ); + + const snapElementPosition = useCallback( + ( + targetTime: number, + tracks: TimelineTrack[], + playheadTime: number, + zoomLevel: number, + excludeElementId?: string + ): SnapResult => { + const snapPoints = findSnapPoints( + tracks, + targetTime, + playheadTime, + zoomLevel, + excludeElementId + ); + + return snapToNearestPoint(targetTime, snapPoints, zoomLevel); + }, + [findSnapPoints, snapToNearestPoint] + ); + + const snapElementEdge = useCallback( + ( + targetTime: number, + elementDuration: number, + tracks: TimelineTrack[], + playheadTime: number, + zoomLevel: number, + excludeElementId?: string, + snapToStart = true // true for start edge, false for end edge + ): SnapResult => { + const snapPoints = findSnapPoints( + tracks, + targetTime, + playheadTime, + zoomLevel, + excludeElementId + ); + + // For end edge snapping, we need to account for element duration + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + const snapResult = snapToNearestPoint( + effectiveTargetTime, + snapPoints, + zoomLevel + ); + + // Adjust the snapped time back for end edge + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; + }, + [findSnapPoints, snapToNearestPoint] + ); + + return { + snapElementPosition, + snapElementEdge, + findSnapPoints, + snapToNearestPoint, + }; +} From af632d8cc5100124ebd12ba0173d01ba651cccd8 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:27:12 +0600 Subject: [PATCH 04/12] feat: add snapping functionality and controls to timeline component --- apps/web/src/components/editor/timeline.tsx | 136 ++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 71e2997b..cf139aa2 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -15,6 +15,10 @@ import { Video, Music, TypeIcon, + Magnet, + Grid3X3, + Move, + Target, } from "lucide-react"; import { Tooltip, @@ -50,6 +54,8 @@ import { } from "./timeline-playhead"; import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; +import { SnapIndicator } from "./snap-indicator"; +import { useTimelineSnapping } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -78,6 +84,17 @@ export function Timeline() { separateAudio, undo, redo, + snappingEnabled, + gridSnappingEnabled, + elementSnappingEnabled, + playheadSnappingEnabled, + snapThreshold, + gridInterval, + toggleSnapping, + toggleGridSnapping, + toggleElementSnapping, + togglePlayheadSnapping, + dragState, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); @@ -145,6 +162,20 @@ export function Timeline() { }, }); + // Initialize snapping functionality + const { snapElementPosition } = useTimelineSnapping({ + snapThreshold, + gridInterval, + enableGridSnapping: snappingEnabled && gridSnappingEnabled, + enableElementSnapping: snappingEnabled && elementSnappingEnabled, + enablePlayheadSnapping: snappingEnabled && playheadSnappingEnabled, + }); + + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState(null); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint; + // Timeline content click to seek handler const handleTimelineContentClick = useCallback( (e: React.MouseEvent) => { @@ -310,6 +341,34 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [redo]); + // Keyboard shortcuts for snapping + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger when typing in input fields or textareas + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + // Only trigger when timeline is focused or mouse is over timeline + if ( + !isInTimeline && + !timelineRef.current?.contains(document.activeElement) + ) { + return; + } + + if (e.key === "s" || e.key === "S") { + e.preventDefault(); + toggleSnapping(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSnapping, isInTimeline]); + // Old marquee system removed - using new SelectionBox component instead const handleDragEnter = (e: React.DragEvent) => { @@ -815,6 +874,72 @@ export function Timeline() { Delete element (Delete) + +
+ + {/* Snapping Controls */} + + + + + Toggle snapping (S) + + + + + + + Toggle grid snapping + + + + + + + Toggle element snapping + + + + + + + Toggle playhead snapping +
@@ -1033,6 +1158,17 @@ export function Timeline() { ))} )} + + {/* Snap Indicator */} +
From 1106aeeb60b493f85bd70685f066362aa43fcd6e Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:27:19 +0600 Subject: [PATCH 05/12] feat: integrate snapping functionality into timeline track component --- .../src/components/editor/timeline-track.tsx | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 033b79a6..0b29c778 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -22,6 +22,7 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; +import { useTimelineSnapping } from "@/hooks/use-timeline-snapping"; export function TimelineTrackContent({ track, @@ -45,8 +46,25 @@ export function TimelineTrackContent({ endDrag: endDragAction, clearSelectedElements, insertTrackAt, + snappingEnabled, + gridSnappingEnabled, + elementSnappingEnabled, + playheadSnappingEnabled, + snapThreshold, + gridInterval, } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + // Initialize snapping hook + const { snapElementPosition } = useTimelineSnapping({ + snapThreshold, + gridInterval, + enableGridSnapping: snappingEnabled && gridSnappingEnabled, + enableElementSnapping: snappingEnabled && elementSnappingEnabled, + enablePlayheadSnapping: snappingEnabled && playheadSnappingEnabled, + }); + const timelineRef = useRef(null); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); @@ -85,12 +103,26 @@ export function TimelineTrackContent({ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(adjustedTime, projectFps); - updateDragTime(snappedTime); + // Apply snapping if enabled + let finalTime = adjustedTime; + if (snappingEnabled) { + const snapResult = snapElementPosition( + adjustedTime, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined + ); + finalTime = snapResult.snappedTime; + } else { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + finalTime = snapTimeToFrame(adjustedTime, projectFps); + } + + updateDragTime(finalTime); }; const handleMouseUp = (e: MouseEvent) => { From 59f4098f752ff9f992f622e2984278d7b1ba91a4 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 04:32:36 +0600 Subject: [PATCH 06/12] refactor: remove keyboard shortcuts for better integration later --- apps/web/src/components/editor/timeline.tsx | 28 --------------------- 1 file changed, 28 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index cf139aa2..2f0e3d91 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -341,34 +341,6 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [redo]); - // Keyboard shortcuts for snapping - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't trigger when typing in input fields or textareas - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) { - return; - } - - // Only trigger when timeline is focused or mouse is over timeline - if ( - !isInTimeline && - !timelineRef.current?.contains(document.activeElement) - ) { - return; - } - - if (e.key === "s" || e.key === "S") { - e.preventDefault(); - toggleSnapping(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSnapping, isInTimeline]); - // Old marquee system removed - using new SelectionBox component instead const handleDragEnter = (e: React.DragEvent) => { From 9cc77b4499023fa48b9a1abcb05a59c94fcfc4c5 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 06:59:46 +0600 Subject: [PATCH 07/12] feat: enforce non-negative snap threshold and minimum grid interval in timeline store --- apps/web/src/stores/timeline-store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index b45f5507..2734903a 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -996,11 +996,11 @@ export const useTimelineStore = create((set, get) => { }, setSnapThreshold: (threshold) => { - set({ snapThreshold: threshold }); + set({ snapThreshold: Math.max(0, threshold) }); // Ensure non-negative threshold }, setGridInterval: (interval) => { - set({ gridInterval: interval }); + set({ gridInterval: Math.max(0.1, interval) }); // Ensure minimum interval }, }; }); From 2cc25b70d56c7bd9a9d23b38f1cd64e797139fa1 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 07:03:36 +0600 Subject: [PATCH 08/12] feat: enhance snapping functionality by adding snap point handling in Timeline component --- apps/web/src/components/editor/timeline.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 2f0e3d91..882af66e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -55,7 +55,7 @@ import { import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; -import { useTimelineSnapping } from "@/hooks/use-timeline-snapping"; +import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -172,9 +172,16 @@ export function Timeline() { }); // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState(null); + const [currentSnapPoint, setCurrentSnapPoint] = useState( + null + ); const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint; + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); // Timeline content click to seek handler const handleTimelineContentClick = useCallback( @@ -1113,6 +1120,7 @@ export function Timeline() { From e3cb491a29061bb42dcfa58adec50ae54baeddaa Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 07:06:29 +0600 Subject: [PATCH 09/12] feat: add snap point notification to TimelineTrackContent for improved snapping feedback --- .../src/components/editor/timeline-track.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 0b29c778..aeb6f040 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -22,14 +22,16 @@ import { TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; -import { useTimelineSnapping } from "@/hooks/use-timeline-snapping"; +import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; export function TimelineTrackContent({ track, zoomLevel, + onSnapPointChange, }: { track: TimelineTrack; zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; }) { const { mediaItems } = useMediaStore(); const { @@ -106,6 +108,7 @@ export function TimelineTrackContent({ // Apply snapping if enabled let finalTime = adjustedTime; + let snapPoint = null; if (snappingEnabled) { const snapResult = snapElementPosition( adjustedTime, @@ -115,11 +118,18 @@ export function TimelineTrackContent({ dragState.elementId || undefined ); finalTime = snapResult.snappedTime; + snapPoint = snapResult.snapPoint; + + // Notify parent component about snap point change + onSnapPointChange?.(snapPoint); } else { // Use frame snapping if project has FPS, otherwise use decimal snapping const projectStore = useProjectStore.getState(); const projectFps = projectStore.activeProject?.fps || 30; finalTime = snapTimeToFrame(adjustedTime, projectFps); + + // Clear snap point when not snapping + onSnapPointChange?.(null); } updateDragTime(finalTime); @@ -140,6 +150,8 @@ export function TimelineTrackContent({ dragState.currentTime ); endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); } return; } @@ -236,6 +248,8 @@ export function TimelineTrackContent({ if (isTrackThatStartedDrag) { endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); } }; @@ -261,6 +275,7 @@ export function TimelineTrackContent({ endDragAction, selectedElements, selectElement, + onSnapPointChange, ]); const handleElementMouseDown = ( From 0f05d25e8a980457a0b85e52af68fc8575a56a18 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 07:17:45 +0600 Subject: [PATCH 10/12] fix: ensure correct type assertion for snappedTime in useTimelineSnapping --- apps/web/src/hooks/use-timeline-snapping.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts index 68b9a7c0..c7e1f15d 100644 --- a/apps/web/src/hooks/use-timeline-snapping.ts +++ b/apps/web/src/hooks/use-timeline-snapping.ts @@ -126,7 +126,9 @@ export function useTimelineSnapping({ }); return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snappedTime: closestSnapPoint + ? (closestSnapPoint as SnapPoint).time + : targetTime, snapPoint: closestSnapPoint, snapDistance: closestDistance, }; From 5e86ed9efca3e783d6503f153545f3600cad331a Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 14:13:02 +0200 Subject: [PATCH 11/12] fix: tooltip styling --- apps/web/src/components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index a2fb0a01..0da023e6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} From 1fdf2a051529e40cf7a0857d31a83057b878f08d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 14:16:54 +0200 Subject: [PATCH 12/12] cleanup --- .../src/components/editor/snap-indicator.tsx | 65 +-- .../components/editor/timeline-playhead.tsx | 14 +- .../src/components/editor/timeline-track.tsx | 13 +- apps/web/src/components/editor/timeline.tsx | 412 ++++++++---------- apps/web/src/hooks/use-timeline-snapping.ts | 32 +- apps/web/src/stores/timeline-store.ts | 39 -- 6 files changed, 214 insertions(+), 361 deletions(-) diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 398e06c6..83a1f678 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -2,74 +2,53 @@ import { SnapPoint } from "@/hooks/use-timeline-snapping"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { snapPoint: SnapPoint | null; zoomLevel: number; - timelineHeight: number; isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject; + trackLabelsRef?: React.RefObject; } export function SnapIndicator({ snapPoint, zoomLevel, - timelineHeight, isVisible, + tracks, + timelineRef, + trackLabelsRef, }: SnapIndicatorProps) { if (!isVisible || !snapPoint) { return null; } + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; + const leftPosition = + trackLabelsWidth + snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const getIndicatorColor = () => { - switch (snapPoint.type) { - case "grid": - return "bg-blue-400"; - case "element-start": - case "element-end": - return "bg-green-400"; - case "playhead": - return "bg-red-400"; - default: - return "bg-gray-400"; - } - }; - - const getIndicatorLabel = () => { - switch (snapPoint.type) { - case "grid": - return "Grid"; - case "element-start": - return "Start"; - case "element-end": - return "End"; - case "playhead": - return "Playhead"; - default: - return ""; - } - }; - return (
- {/* Snap line */} -
- - {/* Snap label */} -
- {getIndicatorLabel()} ({snapPoint.time.toFixed(1)}s) -
+
); } diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index ce4ed1ff..78aed3a8 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -20,6 +20,7 @@ interface TimelinePlayheadProps { trackLabelsRef?: React.RefObject; timelineRef: React.RefObject; playheadRef?: React.RefObject; + isSnappingToPlayhead?: boolean; } export function TimelinePlayhead({ @@ -34,6 +35,7 @@ export function TimelinePlayhead({ trackLabelsRef, timelineRef, playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, }: TimelinePlayheadProps) { const internalPlayheadRef = useRef(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; @@ -73,11 +75,15 @@ export function TimelinePlayhead({ }} onMouseDown={handlePlayheadMouseDown} > - {/* The red line spanning full height */} -
+ {/* The playhead line spanning full height */} +
- {/* Red dot indicator at the top (in ruler area) */} -
+ {/* Playhead dot indicator at the top (in ruler area) */} +
); } diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index aeb6f040..2be143eb 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -49,22 +49,15 @@ export function TimelineTrackContent({ clearSelectedElements, insertTrackAt, snappingEnabled, - gridSnappingEnabled, - elementSnappingEnabled, - playheadSnappingEnabled, - snapThreshold, - gridInterval, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); // Initialize snapping hook const { snapElementPosition } = useTimelineSnapping({ - snapThreshold, - gridInterval, - enableGridSnapping: snappingEnabled && gridSnappingEnabled, - enableElementSnapping: snappingEnabled && elementSnappingEnabled, - enablePlayheadSnapping: snappingEnabled && playheadSnappingEnabled, + snapThreshold: 10, + enableElementSnapping: snappingEnabled, + enablePlayheadSnapping: snappingEnabled, }); const timelineRef = useRef(null); diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 882af66e..e83b64b9 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -16,9 +16,7 @@ import { Music, TypeIcon, Magnet, - Grid3X3, - Move, - Target, + Lock, } from "lucide-react"; import { Tooltip, @@ -40,13 +38,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead, @@ -55,7 +46,7 @@ import { import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; -import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; +import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, @@ -85,15 +76,7 @@ export function Timeline() { undo, redo, snappingEnabled, - gridSnappingEnabled, - elementSnappingEnabled, - playheadSnappingEnabled, - snapThreshold, - gridInterval, toggleSnapping, - toggleGridSnapping, - toggleElementSnapping, - togglePlayheadSnapping, dragState, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); @@ -135,7 +118,7 @@ export function Timeline() { const lastVerticalSync = useRef(0); // Timeline playhead ruler handlers - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({ + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ currentTime, duration, zoomLevel, @@ -162,15 +145,6 @@ export function Timeline() { }, }); - // Initialize snapping functionality - const { snapElementPosition } = useTimelineSnapping({ - snapThreshold, - gridInterval, - enableGridSnapping: snappingEnabled && gridSnappingEnabled, - enableElementSnapping: snappingEnabled && elementSnappingEnabled, - enablePlayheadSnapping: snappingEnabled && playheadSnappingEnabled, - }); - // Calculate snap indicator state const [currentSnapPoint, setCurrentSnapPoint] = useState( null @@ -724,202 +698,168 @@ export function Timeline() { onMouseLeave={() => setIsInTimeline(false)} > {/* Toolbar */} -
- - {/* Play/Pause Button */} - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - -
- {/* Time Display */} -
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s -
- {/* Test Clip Button - for debugging */} - {tracks.length === 0 && ( - <> -
- - - - - Add a test clip to try playback - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - - -
- - {/* Snapping Controls */} - - - - - Toggle snapping (S) - - - - - - - Toggle grid snapping - - - - - - - Toggle element snapping - - - - - - - Toggle playhead snapping - - +
+
+ + {/* Play/Pause Button */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + +
+ {/* Time Display */} +
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> +
+ + + + + + Add a test clip to try playback + + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + + +
+
+ + + + + + Auto snapping + + +
{/* Timeline Container */} @@ -939,6 +879,17 @@ export function Timeline() { trackLabelsRef={trackLabelsRef} timelineRef={timelineRef} playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + {/* Timeline Header with Ruler */}
@@ -1138,17 +1089,6 @@ export function Timeline() { ))} )} - - {/* Snap Indicator */} -
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts index c7e1f15d..f7f96392 100644 --- a/apps/web/src/hooks/use-timeline-snapping.ts +++ b/apps/web/src/hooks/use-timeline-snapping.ts @@ -1,10 +1,10 @@ import { useCallback } from "react"; -import { TimelineElement, TimelineTrack } from "@/types/timeline"; +import { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; export interface SnapPoint { time: number; - type: "grid" | "element-start" | "element-end" | "playhead"; + type: "element-start" | "element-end" | "playhead"; elementId?: string; trackId?: string; } @@ -17,16 +17,12 @@ export interface SnapResult { export interface UseTimelineSnappingOptions { snapThreshold?: number; // Distance in pixels to trigger snapping - gridInterval?: number; // Grid interval in seconds - enableGridSnapping?: boolean; enableElementSnapping?: boolean; enablePlayheadSnapping?: boolean; } export function useTimelineSnapping({ snapThreshold = 10, - gridInterval = 1, - enableGridSnapping = true, enableElementSnapping = true, enablePlayheadSnapping = true, }: UseTimelineSnappingOptions = {}) { @@ -40,23 +36,6 @@ export function useTimelineSnapping({ ): SnapPoint[] => { const snapPoints: SnapPoint[] = []; - // Add grid snap points - if (enableGridSnapping) { - const gridStart = Math.floor(currentTime / gridInterval) * gridInterval; - const gridEnd = Math.ceil(currentTime / gridInterval) * gridInterval; - - // Add nearby grid points - for (let i = -2; i <= 2; i++) { - const gridTime = gridStart + i * gridInterval; - if (gridTime >= 0) { - snapPoints.push({ - time: gridTime, - type: "grid", - }); - } - } - } - // Add element snap points if (enableElementSnapping) { tracks.forEach((track) => { @@ -97,12 +76,7 @@ export function useTimelineSnapping({ return snapPoints; }, - [ - enableGridSnapping, - enableElementSnapping, - enablePlayheadSnapping, - gridInterval, - ] + [enableElementSnapping, enablePlayheadSnapping] ); const snapToNearestPoint = useCallback( diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 2734903a..f6b9bfd8 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -44,19 +44,9 @@ interface TimelineStore { // Snapping settings snappingEnabled: boolean; - gridSnappingEnabled: boolean; - elementSnappingEnabled: boolean; - playheadSnappingEnabled: boolean; - snapThreshold: number; - gridInterval: number; // Snapping actions toggleSnapping: () => void; - toggleGridSnapping: () => void; - toggleElementSnapping: () => void; - togglePlayheadSnapping: () => void; - setSnapThreshold: (threshold: number) => void; - setGridInterval: (interval: number) => void; // Multi-selection selectedElements: { trackId: string; elementId: string }[]; @@ -221,11 +211,6 @@ export const useTimelineStore = create((set, get) => { // Snapping settings defaults snappingEnabled: true, - gridSnappingEnabled: true, - elementSnappingEnabled: true, - playheadSnappingEnabled: true, - snapThreshold: 10, // pixels - gridInterval: 1, // seconds getSortedTracks: () => { const { _tracks } = get(); @@ -978,29 +963,5 @@ export const useTimelineStore = create((set, get) => { toggleSnapping: () => { set((state) => ({ snappingEnabled: !state.snappingEnabled })); }, - - toggleGridSnapping: () => { - set((state) => ({ gridSnappingEnabled: !state.gridSnappingEnabled })); - }, - - toggleElementSnapping: () => { - set((state) => ({ - elementSnappingEnabled: !state.elementSnappingEnabled, - })); - }, - - togglePlayheadSnapping: () => { - set((state) => ({ - playheadSnappingEnabled: !state.playheadSnappingEnabled, - })); - }, - - setSnapThreshold: (threshold) => { - set({ snapThreshold: Math.max(0, threshold) }); // Ensure non-negative threshold - }, - - setGridInterval: (interval) => { - set({ gridInterval: Math.max(0.1, interval) }); // Ensure minimum interval - }, }; });