refactor all timeline components

This commit is contained in:
Maze Winther
2025-07-17 19:33:10 +02:00
parent 56e6edcc5a
commit 0b919d4df8
14 changed files with 968 additions and 1103 deletions
@@ -59,7 +59,7 @@ export function useTimelineContentClick({
// Determine if we're clicking in ruler or tracks area // Determine if we're clicking in ruler or tracks area
const isRulerClick = (e.target as HTMLElement).closest( const isRulerClick = (e.target as HTMLElement).closest(
"[data-ruler-area]", "[data-ruler-area]"
); );
let mouseX: number; let mouseX: number;
@@ -68,7 +68,7 @@ export function useTimelineContentClick({
if (isRulerClick) { if (isRulerClick) {
// Calculate based on ruler position // Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector( const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]", "[data-radix-scroll-area-viewport]"
) as HTMLElement; ) as HTMLElement;
if (!rulerContent) return; if (!rulerContent) return;
const rect = rulerContent.getBoundingClientRect(); const rect = rulerContent.getBoundingClientRect();
@@ -77,7 +77,7 @@ export function useTimelineContentClick({
} else { } else {
// Calculate based on tracks content position // Calculate based on tracks content position
const tracksContent = tracksScrollRef.current?.querySelector( const tracksContent = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]", "[data-radix-scroll-area-viewport]"
) as HTMLElement; ) as HTMLElement;
if (!tracksContent) return; if (!tracksContent) return;
const rect = tracksContent.getBoundingClientRect(); const rect = tracksContent.getBoundingClientRect();
@@ -90,8 +90,8 @@ export function useTimelineContentClick({
Math.min( Math.min(
duration, duration,
(mouseX + scrollLeft) / (mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
), )
); );
// Use frame snapping for timeline clicking // Use frame snapping for timeline clicking
@@ -111,7 +111,7 @@ export function useTimelineContentClick({
justFinishedSelecting, justFinishedSelecting,
playheadRef, playheadRef,
activeProject?.fps, activeProject?.fps,
], ]
); );
return { handleTimelineContentClick }; return { handleTimelineContentClick };
@@ -57,14 +57,13 @@ export function TimelineContent({
}: TimelineContentProps) { }: TimelineContentProps) {
return ( return (
<div <div
className="relative min-w-max min-h-max" className="relative min-w-max min-h-max h-full"
style={{ minWidth: `${dynamicTimelineWidth}px` }} style={{ minWidth: `${dynamicTimelineWidth}px` }}
> >
<div <div
className={`h-full grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : "grid-rows-[20px_1fr]"}`} className={`h-full grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : "grid-rows-[20px_1fr]"}`}
> >
{/* Top-Left Corner (Empty space above track labels) */}
<div className="sticky top-0 left-0 border-inset bg-card"></div>
{/* Top Row (Sticky Ruler Header) */} {/* Top Row (Sticky Ruler Header) */}
<TimelineRuler <TimelineRuler
@@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
@@ -66,7 +66,7 @@ export function useTimelineDragHandlers({
// Ignore timeline element drags - they're handled by track-specific handlers // Ignore timeline element drags - they're handled by track-specific handlers
const hasTimelineElement = e.dataTransfer.types.includes( const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element", "application/x-timeline-element"
); );
if (hasTimelineElement) { if (hasTimelineElement) {
return; return;
@@ -83,7 +83,7 @@ export function useTimelineDragHandlers({
} else { } else {
// Handle media items // Handle media items
const mediaItem = mediaItems.find( const mediaItem = mediaItems.find(
(item: any) => item.id === dragData.id, (item: any) => item.id === dragData.id
); );
if (!mediaItem) { if (!mediaItem) {
toast.error("Media item not found"); toast.error("Media item not found");
@@ -108,15 +108,14 @@ export function useTimelineDragHandlers({
try { try {
const processedItems = await processMediaFiles( const processedItems = await processMediaFiles(
e.dataTransfer.files, e.dataTransfer.files,
(p) => setProgress(p), (p) => setProgress(p)
); );
for (const processedItem of processedItems) { for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem); await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems; const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find( const addedItem = currentMediaItems.find(
(item) => (item) =>
item.name === processedItem.name && item.name === processedItem.name && item.url === processedItem.url
item.url === processedItem.url,
); );
if (addedItem) { if (addedItem) {
useTimelineStore.getState().addMediaToNewTrack(addedItem); useTimelineStore.getState().addMediaToNewTrack(addedItem);
@@ -1,48 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { Scissors, Trash2, Copy, RefreshCw } from "lucide-react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
Type,
Copy,
RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform"; import AudioWaveform from "@/components/editor/audio-waveform";
import { toast } from "sonner"; import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline"; import { TimelineElementProps } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import { import {
getTrackElementClasses, getTrackElementClasses,
TIMELINE_CONSTANTS, TIMELINE_CONSTANTS,
getTrackHeight, getTrackHeight,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "../ui/context-menu"; } from "@/components/ui/context-menu";
export function TimelineElement({ export function TimelineElement({
element, element,
@@ -59,23 +36,13 @@ export function TimelineElement({
removeElementFromTrack, removeElementFromTrack,
dragState, dragState,
splitElement, splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
addElementToTrack, addElementToTrack,
replaceElementMedia, replaceElementMedia,
} = useTimelineStore(); } = useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false); const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
const {
resizing,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useTimelineElementResize({
element, element,
track, track,
zoomLevel, zoomLevel,
@@ -98,92 +65,6 @@ export function TimelineElement({
: element.startTime; : element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel; const elementLeft = elementStartTime * 50 * zoomLevel;
const handleDeleteElement = () => {
removeElementFromTrack(track.id, element.id);
setElementMenuOpen(false);
};
const handleSplitElement = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element to split");
return;
}
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
setElementMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepLeft(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepRight(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSeparateAudio = () => {
if (element.type !== "media") {
toast.error("Audio separation only available for media elements");
return;
}
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video elements");
return;
}
const audioElementId = separateAudio(track.id, element.id);
if (!audioElementId) {
toast.error("Failed to separate audio");
}
setElementMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video" && track.type === "media";
};
const handleElementSplitContext = () => { const handleElementSplitContext = () => {
const effectiveStart = element.startTime; const effectiveStart = element.startTime;
const effectiveEnd = const effectiveEnd =
@@ -2,10 +2,7 @@
import { useRef } from "react"; import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline"; import { TimelineTrack } from "@/types/timeline";
import { import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps { interface TimelinePlayheadProps {
@@ -50,10 +47,6 @@ export function TimelinePlayhead({
playheadRef, playheadRef,
}); });
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref // Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref
const trackLabelsWidth = 192; // Fixed width from grid layout const trackLabelsWidth = 192; // Fixed width from grid layout
const leftPosition = const leftPosition =
@@ -101,7 +101,7 @@ export function TimelineRuler({
> >
{formatTime(time, interval)} {formatTime(time, interval)}
</span> </span>
</div>, </div>
); );
} }
@@ -110,7 +110,7 @@ export function TimelineRuler({
return ( return (
<div <div
className="sticky top-0 bg-card border-b border-muted/30 z-[99]" className="sticky top-0 border-b border-muted/30 z-[99] w-24"
onMouseDown={handleSelectionMouseDown} onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick} onClick={handleTimelineContentClick}
data-ruler-area data-ruler-area
@@ -15,10 +15,10 @@ export function useTimelineScrollSync({
// --- Horizontal scroll synchronization between ruler and tracks --- // --- Horizontal scroll synchronization between ruler and tracks ---
useEffect(() => { useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector( const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]", "[data-radix-scroll-area-viewport]"
) as HTMLElement; ) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector( const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]", "[data-radix-scroll-area-viewport]"
) as HTMLElement; ) as HTMLElement;
if (!rulerViewport || !tracksViewport) return; if (!rulerViewport || !tracksViewport) return;
@@ -7,8 +7,6 @@ import { toast } from "sonner";
import { TimelineElement } from "./timeline-element"; import { TimelineElement } from "./timeline-element";
import { import {
TimelineTrack, TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
getMainTrack, getMainTrack,
canElementGoOnTrack, canElementGoOnTrack,
} from "@/types/timeline"; } from "@/types/timeline";
@@ -54,7 +52,7 @@ export function TimelineTrackContent({
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
// Initialize snapping hook // Initialize snapping hook
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10, snapThreshold: 10,
enableElementSnapping: snappingEnabled, enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled, enablePlayheadSnapping: snappingEnabled,
@@ -1,4 +1,3 @@
import { Fragment } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { import {
ContextMenu, ContextMenu,
@@ -7,7 +6,7 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from "../../ui/context-menu"; } from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineTrackContent } from "../timeline-track"; import { TimelineTrackContent } from "./timeline-track";
import { TrackIcon } from "./track-icon"; import { TrackIcon } from "./track-icon";
import { getTrackHeight } from "@/constants/timeline-constants"; import { getTrackHeight } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline"; import type { TimelineTrack } from "@/types/timeline";
@@ -26,16 +25,17 @@ export function TimelineTracksArea({
handleSnapPointChange, handleSnapPointChange,
clearSelectedElements, clearSelectedElements,
}: TimelineTracksAreaProps) { }: TimelineTracksAreaProps) {
const { addTrack, toggleTrackMute } = useTimelineStore(); const { toggleTrackMute } = useTimelineStore();
return ( return (
<> <>
{/* Track Rows */} <div className="h-full flex flex-col absolute left-0 top-0 w-24 bg-panel border-r border-black z-[100]">
<div className="sticky left-0 h-5 border-inset z-[100]"></div>
{tracks.map((track, index) => ( {tracks.map((track, index) => (
<Fragment key={track.id}>
{/* Left Column (Sticky Track Labels) */}
<div <div
className="sticky left-0 flex items-center border-b border-panel border-inset group bg-card z-[100]" key={track.id}
className="sticky left-0 flex items-center border-b border-panel border-inset group z-[100]"
style={{ height: `${getTrackHeight(track.type)}px` }} style={{ height: `${getTrackHeight(track.type)}px` }}
> >
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-2">
@@ -45,14 +45,17 @@ export function TimelineTracksArea({
<span className="text-xs text-red-500 font-semibold">Muted</span> <span className="text-xs text-red-500 font-semibold">Muted</span>
)} )}
</div> </div>
))}
</div>
{/* Scrollable Track Content */} <div className="h-full flex flex-col pt-5 ml-24">
<ContextMenu> {tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <div
className=" h-full" className="h-full"
style={{ height: `${getTrackHeight(track.type)}px` }}
onClick={(e) => { onClick={(e) => {
// If clicking empty area (not on a element), deselect all elements
if (!(e.target as HTMLElement).closest(".timeline-element")) { if (!(e.target as HTMLElement).closest(".timeline-element")) {
clearSelectedElements(); clearSelectedElements();
} }
@@ -72,17 +75,9 @@ export function TimelineTracksArea({
<ContextMenuItem>Track settings (soon)</ContextMenuItem> <ContextMenuItem>Track settings (soon)</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</Fragment>
))} ))}
{/* Add Track Button - spans full width */}
<div <div style={{ height: `${getTrackHeight("media")}px` }}></div>
onClick={() => addTrack("media")}
className="col-span-1 sticky left-0 w-full flex items-center border-b border-muted bg-card hover:bg-card/50 transition-colors cursor-pointer z-[100]"
style={{ height: `${getTrackHeight("media")}px` }}
>
<div className="w-full flex justify-center items-center">
<Plus className="w-4 h-4 text-muted-foreground" />
</div>
</div> </div>
</> </>
); );