mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
|
|||||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
isSnappingToPlayhead?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelinePlayhead({
|
export function TimelinePlayhead({
|
||||||
@@ -34,6 +35,7 @@ export function TimelinePlayhead({
|
|||||||
trackLabelsRef,
|
trackLabelsRef,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
playheadRef: externalPlayheadRef,
|
playheadRef: externalPlayheadRef,
|
||||||
|
isSnappingToPlayhead = false,
|
||||||
}: TimelinePlayheadProps) {
|
}: TimelinePlayheadProps) {
|
||||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||||
@@ -73,11 +75,15 @@ export function TimelinePlayhead({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={handlePlayheadMouseDown}
|
onMouseDown={handlePlayheadMouseDown}
|
||||||
>
|
>
|
||||||
{/* The red line spanning full height */}
|
{/* The playhead line spanning full height */}
|
||||||
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
|
<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) */}
|
{/* 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 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ import {
|
|||||||
TIMELINE_CONSTANTS,
|
TIMELINE_CONSTANTS,
|
||||||
} from "@/constants/timeline-constants";
|
} from "@/constants/timeline-constants";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||||
|
|
||||||
export function TimelineTrackContent({
|
export function TimelineTrackContent({
|
||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
onSnapPointChange,
|
||||||
}: {
|
}: {
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
|
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const {
|
const {
|
||||||
@@ -45,8 +48,18 @@ export function TimelineTrackContent({
|
|||||||
endDrag: endDragAction,
|
endDrag: endDragAction,
|
||||||
clearSelectedElements,
|
clearSelectedElements,
|
||||||
insertTrackAt,
|
insertTrackAt,
|
||||||
|
snappingEnabled,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
|
|
||||||
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
|
// Initialize snapping hook
|
||||||
|
const { snapElementPosition } = useTimelineSnapping({
|
||||||
|
snapThreshold: 10,
|
||||||
|
enableElementSnapping: snappingEnabled,
|
||||||
|
enablePlayheadSnapping: snappingEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const [isDropping, setIsDropping] = useState(false);
|
const [isDropping, setIsDropping] = useState(false);
|
||||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||||
@@ -85,12 +98,34 @@ export function TimelineTrackContent({
|
|||||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||||
);
|
);
|
||||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||||
|
|
||||||
|
// 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
|
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||||
const projectStore = useProjectStore.getState();
|
const projectStore = useProjectStore.getState();
|
||||||
const projectFps = projectStore.activeProject?.fps || 30;
|
const projectFps = projectStore.activeProject?.fps || 30;
|
||||||
const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
|
finalTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||||
|
|
||||||
updateDragTime(snappedTime);
|
// Clear snap point when not snapping
|
||||||
|
onSnapPointChange?.(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDragTime(finalTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (e: MouseEvent) => {
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
@@ -108,6 +143,8 @@ export function TimelineTrackContent({
|
|||||||
dragState.currentTime
|
dragState.currentTime
|
||||||
);
|
);
|
||||||
endDragAction();
|
endDragAction();
|
||||||
|
// Clear snap point when drag ends
|
||||||
|
onSnapPointChange?.(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,6 +241,8 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
if (isTrackThatStartedDrag) {
|
if (isTrackThatStartedDrag) {
|
||||||
endDragAction();
|
endDragAction();
|
||||||
|
// Clear snap point when drag ends
|
||||||
|
onSnapPointChange?.(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,6 +268,7 @@ export function TimelineTrackContent({
|
|||||||
endDragAction,
|
endDragAction,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
selectElement,
|
selectElement,
|
||||||
|
onSnapPointChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleElementMouseDown = (
|
const handleElementMouseDown = (
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
Music,
|
Music,
|
||||||
TypeIcon,
|
TypeIcon,
|
||||||
|
Magnet,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -36,13 +38,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
|||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "../ui/select";
|
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
import {
|
import {
|
||||||
TimelinePlayhead,
|
TimelinePlayhead,
|
||||||
@@ -50,6 +45,8 @@ import {
|
|||||||
} from "./timeline-playhead";
|
} from "./timeline-playhead";
|
||||||
import { SelectionBox } from "./selection-box";
|
import { SelectionBox } from "./selection-box";
|
||||||
import { useSelectionBox } from "@/hooks/use-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 type { DragData, TimelineTrack } from "@/types/timeline";
|
||||||
import {
|
import {
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
@@ -78,6 +75,9 @@ export function Timeline() {
|
|||||||
separateAudio,
|
separateAudio,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
snappingEnabled,
|
||||||
|
toggleSnapping,
|
||||||
|
dragState,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { mediaItems, addMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem } = useMediaStore();
|
||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
@@ -118,7 +118,7 @@ export function Timeline() {
|
|||||||
const lastVerticalSync = useRef(0);
|
const lastVerticalSync = useRef(0);
|
||||||
|
|
||||||
// Timeline playhead ruler handlers
|
// Timeline playhead ruler handlers
|
||||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
|
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
zoomLevel,
|
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
|
// Timeline content click to seek handler
|
||||||
const handleTimelineContentClick = useCallback(
|
const handleTimelineContentClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -686,7 +698,8 @@ export function Timeline() {
|
|||||||
onMouseLeave={() => setIsInTimeline(false)}
|
onMouseLeave={() => setIsInTimeline(false)}
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="border-b flex items-center px-2 py-1 gap-1">
|
<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}>
|
<TooltipProvider delayDuration={500}>
|
||||||
{/* Play/Pause Button */}
|
{/* Play/Pause Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -742,14 +755,20 @@ export function Timeline() {
|
|||||||
Add Test Clip
|
Add Test Clip
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
<TooltipContent>
|
||||||
|
Add a test clip to try playback
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="w-px h-6 bg-border mx-1" />
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSplitSelected}
|
||||||
|
>
|
||||||
<Scissors className="h-4 w-4" />
|
<Scissors className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -781,7 +800,11 @@ export function Timeline() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSeparateAudio}
|
||||||
|
>
|
||||||
<SplitSquareHorizontal className="h-4 w-4" />
|
<SplitSquareHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -801,7 +824,11 @@ export function Timeline() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleFreezeSelected}
|
||||||
|
>
|
||||||
<Snowflake className="h-4 w-4" />
|
<Snowflake className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -809,7 +836,11 @@ export function Timeline() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -817,6 +848,19 @@ export function Timeline() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</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 */}
|
{/* Timeline Container */}
|
||||||
<div
|
<div
|
||||||
@@ -835,6 +879,17 @@ export function Timeline() {
|
|||||||
trackLabelsRef={trackLabelsRef}
|
trackLabelsRef={trackLabelsRef}
|
||||||
timelineRef={timelineRef}
|
timelineRef={timelineRef}
|
||||||
playheadRef={playheadRef}
|
playheadRef={playheadRef}
|
||||||
|
isSnappingToPlayhead={
|
||||||
|
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SnapIndicator
|
||||||
|
snapPoint={currentSnapPoint}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
tracks={tracks}
|
||||||
|
timelineRef={timelineRef}
|
||||||
|
trackLabelsRef={trackLabelsRef}
|
||||||
|
isVisible={showSnapIndicator}
|
||||||
/>
|
/>
|
||||||
{/* Timeline Header with Ruler */}
|
{/* Timeline Header with Ruler */}
|
||||||
<div className="flex bg-panel sticky top-0 z-10">
|
<div className="flex bg-panel sticky top-0 z-10">
|
||||||
@@ -1016,6 +1071,7 @@ export function Timeline() {
|
|||||||
<TimelineTrackContent
|
<TimelineTrackContent
|
||||||
track={track}
|
track={track}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
|
onSnapPointChange={handleSnapPointChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ interface TimelineStore {
|
|||||||
// Manual method if you need to force recomputation
|
// Manual method if you need to force recomputation
|
||||||
getSortedTracks: () => TimelineTrack[];
|
getSortedTracks: () => TimelineTrack[];
|
||||||
|
|
||||||
|
// Snapping settings
|
||||||
|
snappingEnabled: boolean;
|
||||||
|
|
||||||
|
// Snapping actions
|
||||||
|
toggleSnapping: () => void;
|
||||||
|
|
||||||
// Multi-selection
|
// Multi-selection
|
||||||
selectedElements: { trackId: string; elementId: string }[];
|
selectedElements: { trackId: string; elementId: string }[];
|
||||||
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
|
||||||
@@ -203,6 +209,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
redoStack: [],
|
redoStack: [],
|
||||||
selectedElements: [],
|
selectedElements: [],
|
||||||
|
|
||||||
|
// Snapping settings defaults
|
||||||
|
snappingEnabled: true,
|
||||||
|
|
||||||
getSortedTracks: () => {
|
getSortedTracks: () => {
|
||||||
const { _tracks } = get();
|
const { _tracks } = get();
|
||||||
const tracksWithMain = ensureMainTrack(_tracks);
|
const tracksWithMain = ensureMainTrack(_tracks);
|
||||||
@@ -949,5 +958,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
updateTracks(defaultTracks);
|
updateTracks(defaultTracks);
|
||||||
set({ history: [], redoStack: [], selectedElements: [] });
|
set({ history: [], redoStack: [], selectedElements: [] });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Snapping actions
|
||||||
|
toggleSnapping: () => {
|
||||||
|
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user