Merge pull request #266 from anwarulislam/feat/snapping

feature: add timeline snapping functionality
This commit is contained in:
Maze 2025-07-15 14:17:50 +02:00 committed by GitHub
commit f5ed3e196a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 496 additions and 148 deletions

View File

@ -0,0 +1,54 @@
"use client";
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;
isVisible: boolean;
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
}
export function SnapIndicator({
snapPoint,
zoomLevel,
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;
return (
<div
className="absolute pointer-events-none z-[90]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
}}
>
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
</div>
);
}

View File

@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
@ -34,6 +35,7 @@ export function TimelinePlayhead({
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
@ -73,11 +75,15 @@ export function TimelinePlayhead({
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The red line spanning full height */}
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
/>
{/* Red dot indicator at the top (in ruler area) */}
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/>
</div>
);
}

View File

@ -22,13 +22,16 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
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 {
@ -45,8 +48,18 @@ export function TimelineTrackContent({
endDrag: endDragAction,
clearSelectedElements,
insertTrackAt,
snappingEnabled,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
// Initialize snapping hook
const { snapElementPosition } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
@ -85,12 +98,34 @@ 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;
let snapPoint = null;
if (snappingEnabled) {
const snapResult = snapElementPosition(
adjustedTime,
tracks,
currentTime,
zoomLevel,
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);
};
const handleMouseUp = (e: MouseEvent) => {
@ -108,6 +143,8 @@ export function TimelineTrackContent({
dragState.currentTime
);
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
return;
}
@ -204,6 +241,8 @@ export function TimelineTrackContent({
if (isTrackThatStartedDrag) {
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
};
@ -229,6 +268,7 @@ export function TimelineTrackContent({
endDragAction,
selectedElements,
selectElement,
onSnapPointChange,
]);
const handleElementMouseDown = (

View File

@ -15,6 +15,8 @@ import {
Video,
Music,
TypeIcon,
Magnet,
Lock,
} from "lucide-react";
import {
Tooltip,
@ -36,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,
@ -50,6 +45,8 @@ import {
} from "./timeline-playhead";
import { SelectionBox } from "./selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import {
getTrackHeight,
@ -78,6 +75,9 @@ export function Timeline() {
separateAudio,
undo,
redo,
snappingEnabled,
toggleSnapping,
dragState,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
@ -118,7 +118,7 @@ export function Timeline() {
const lastVerticalSync = useRef(0);
// Timeline playhead ruler handlers
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
@ -145,6 +145,18 @@ export function Timeline() {
},
});
// Calculate snap indicator state
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null
);
const showSnapIndicator =
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(
(e: React.MouseEvent) => {
@ -686,136 +698,168 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
>
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("media");
addElementToTrack(trackId, {
type: "media",
mediaId: "test",
name: "Test Clip",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepLeft}
>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepRight}
>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="border-b flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("media");
addElementToTrack(trackId, {
type: "media",
mediaId: "test",
name: "Test Clip",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>
Add a test clip to try playback
</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitSelected}
>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepLeft}
>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepRight}
>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSeparateAudio}
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleFreezeSelected}
>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
<Lock className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Timeline Container */}
@ -835,6 +879,17 @@ export function Timeline() {
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
isSnappingToPlayhead={
showSnapIndicator && currentSnapPoint?.type === "playhead"
}
/>
<SnapIndicator
snapPoint={currentSnapPoint}
zoomLevel={zoomLevel}
tracks={tracks}
timelineRef={timelineRef}
trackLabelsRef={trackLabelsRef}
isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
@ -1016,6 +1071,7 @@ export function Timeline() {
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange}
/>
</div>
</ContextMenuTrigger>

View File

@ -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}

View File

@ -0,0 +1,178 @@
import { useCallback } from "react";
import { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
export interface SnapPoint {
time: number;
type: "element-start" | "element-end" | "playhead";
elementId?: string;
trackId?: string;
}
export interface SnapResult {
snappedTime: number;
snapPoint: SnapPoint | null;
snapDistance: number;
}
export interface UseTimelineSnappingOptions {
snapThreshold?: number; // Distance in pixels to trigger snapping
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
}
export function useTimelineSnapping({
snapThreshold = 10,
enableElementSnapping = true,
enablePlayheadSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
(
tracks: TimelineTrack[],
currentTime: number,
playheadTime: number,
zoomLevel: number,
excludeElementId?: string
): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
// Add element snap points
if (enableElementSnapping) {
tracks.forEach((track) => {
track.elements.forEach((element) => {
// Skip the element being dragged
if (element.id === excludeElementId) return;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
snapPoints.push(
{
time: elementStart,
type: "element-start",
elementId: element.id,
trackId: track.id,
},
{
time: elementEnd,
type: "element-end",
elementId: element.id,
trackId: track.id,
}
);
});
});
}
// Add playhead snap point
if (enablePlayheadSnapping) {
snapPoints.push({
time: playheadTime,
type: "playhead",
});
}
return snapPoints;
},
[enableElementSnapping, enablePlayheadSnapping]
);
const snapToNearestPoint = useCallback(
(
targetTime: number,
snapPoints: SnapPoint[],
zoomLevel: number
): SnapResult => {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
let closestDistance = Infinity;
snapPoints.forEach((snapPoint) => {
const distance = Math.abs(targetTime - snapPoint.time);
if (distance < thresholdInSeconds && distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint;
}
});
return {
snappedTime: closestSnapPoint
? (closestSnapPoint as SnapPoint).time
: targetTime,
snapPoint: closestSnapPoint,
snapDistance: closestDistance,
};
},
[snapThreshold]
);
const snapElementPosition = useCallback(
(
targetTime: number,
tracks: TimelineTrack[],
playheadTime: number,
zoomLevel: number,
excludeElementId?: string
): SnapResult => {
const snapPoints = findSnapPoints(
tracks,
targetTime,
playheadTime,
zoomLevel,
excludeElementId
);
return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
},
[findSnapPoints, snapToNearestPoint]
);
const snapElementEdge = useCallback(
(
targetTime: number,
elementDuration: number,
tracks: TimelineTrack[],
playheadTime: number,
zoomLevel: number,
excludeElementId?: string,
snapToStart = true // true for start edge, false for end edge
): SnapResult => {
const snapPoints = findSnapPoints(
tracks,
targetTime,
playheadTime,
zoomLevel,
excludeElementId
);
// For end edge snapping, we need to account for element duration
const effectiveTargetTime = snapToStart
? targetTime
: targetTime + elementDuration;
const snapResult = snapToNearestPoint(
effectiveTargetTime,
snapPoints,
zoomLevel
);
// Adjust the snapped time back for end edge
if (!snapToStart && snapResult.snapPoint) {
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
}
return snapResult;
},
[findSnapPoints, snapToNearestPoint]
);
return {
snapElementPosition,
snapElementEdge,
findSnapPoints,
snapToNearestPoint,
};
}

View File

@ -42,6 +42,12 @@ interface TimelineStore {
// Manual method if you need to force recomputation
getSortedTracks: () => TimelineTrack[];
// Snapping settings
snappingEnabled: boolean;
// Snapping actions
toggleSnapping: () => void;
// Multi-selection
selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@ -203,6 +209,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
redoStack: [],
selectedElements: [],
// Snapping settings defaults
snappingEnabled: true,
getSortedTracks: () => {
const { _tracks } = get();
const tracksWithMain = ensureMainTrack(_tracks);
@ -949,5 +958,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateTracks(defaultTracks);
set({ history: [], redoStack: [], selectedElements: [] });
},
// Snapping actions
toggleSnapping: () => {
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
},
};
});