This commit is contained in:
Maze Winther
2025-07-15 14:16:54 +02:00
parent 5e86ed9efc
commit 1fdf2a0515
6 changed files with 214 additions and 361 deletions
@@ -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<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
}
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 (
<div
className="absolute top-0 pointer-events-none z-50"
className="absolute pointer-events-none z-[90]"
style={{
left: `${leftPosition}px`,
height: `${timelineHeight}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
}}
>
{/* Snap line */}
<div className={`w-0.5 h-full ${getIndicatorColor()} opacity-80`} />
{/* Snap label */}
<div
className={`absolute top-0 left-1 px-1 py-0.5 text-xs text-white rounded ${getIndicatorColor()} opacity-90 whitespace-nowrap`}
style={{ transform: "translateY(-100%)" }}
>
{getIndicatorLabel()} ({snapPoint.time.toFixed(1)}s)
</div>
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
</div>
);
}
@@ -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>
);
}
@@ -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<HTMLDivElement>(null);
+176 -236
View File
@@ -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<SnapPoint | null>(
null
@@ -724,202 +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>
<div className="w-px h-6 bg-border mx-1" />
{/* Snapping Controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={snappingEnabled ? "default" : "text"}
size="icon"
onClick={toggleSnapping}
>
<Magnet className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle snapping (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={
gridSnappingEnabled && snappingEnabled ? "default" : "text"
}
size="icon"
onClick={toggleGridSnapping}
disabled={!snappingEnabled}
>
<Grid3X3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle grid snapping</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={
elementSnappingEnabled && snappingEnabled ? "default" : "text"
}
size="icon"
onClick={toggleElementSnapping}
disabled={!snappingEnabled}
>
<Move className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle element snapping</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={
playheadSnappingEnabled && snappingEnabled
? "default"
: "text"
}
size="icon"
onClick={togglePlayheadSnapping}
disabled={!snappingEnabled}
>
<Target className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle playhead snapping</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 */}
@@ -939,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">
@@ -1138,17 +1089,6 @@ export function Timeline() {
))}
</>
)}
{/* Snap Indicator */}
<SnapIndicator
snapPoint={currentSnapPoint}
zoomLevel={zoomLevel}
timelineHeight={Math.max(
200,
Math.min(800, getTotalTracksHeight(tracks))
)}
isVisible={showSnapIndicator}
/>
</div>
</ScrollArea>
</div>