This commit is contained in:
Maze Winther
2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4133 additions and 4196 deletions
@@ -1,9 +1,9 @@
"use client";
import { useFileUpload } from "@opencut/hooks/use-file-upload";
import { useFileUpload } from "@/hooks/use-file-upload";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { MediaFile } from "@/types/assets";
import {
ArrowDown01,
CloudUpload,
@@ -1,8 +1,14 @@
"use client";
import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import {
ChevronDown,
ArrowLeft,
SquarePen,
Trash,
Loader2,
} from "lucide-react";
import { EditorCore } from "@/core";
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
@@ -21,6 +27,7 @@ import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
export function EditorHeader() {
return (
@@ -39,28 +46,59 @@ export function EditorHeader() {
}
function ProjectDropdown() {
const { activeProject, renameProject, deleteProject } = useProjectStore();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const handleNameSave = async (newName: string) => {
console.log("handleNameSave", newName);
const handleExit = async () => {
if (isExiting) return;
setIsExiting(true);
try {
await editor.project.prepareExit();
editor.project.closeProject();
} catch (error) {
console.error("Failed to prepare project exit:", error);
} finally {
editor.project.closeProject();
router.push("/projects");
}
};
const handleSaveProjectName = async (newName: string) => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
setIsRenameDialogOpen(false);
await editor.project.renameProject({
id: activeProject.id,
name: newName.trim(),
});
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsRenameDialogOpen(false);
}
}
};
const handleDelete = () => {
const handleDeleteProject = async () => {
if (activeProject) {
deleteProject(activeProject.id);
setIsDeleteDialogOpen(false);
router.push("/projects");
try {
await editor.project.deleteProject({ id: activeProject.id });
router.push("/projects");
} catch (error) {
toast.error("Failed to delete project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setIsDeleteDialogOpen(false);
}
}
};
@@ -77,12 +115,18 @@ function ProjectDropdown() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-40">
<Link href="/projects">
<DropdownMenuItem className="flex items-center gap-1.5">
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
>
{isExiting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowLeft className="h-4 w-4" />
Projects
</DropdownMenuItem>
</Link>
)}
Projects
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setIsRenameDialogOpen(true)}
@@ -115,13 +159,13 @@ function ProjectDropdown() {
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={handleNameSave}
onConfirm={(newName) => handleSaveProjectName(newName)}
projectName={activeProject?.name || ""}
/>
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDelete}
onConfirm={handleDeleteProject}
projectName={activeProject?.name || ""}
/>
</>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { TransitionUpIcon } from "../icons";
import { TransitionUpIcon } from "@opencut/ui/icons";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
@@ -15,20 +15,20 @@ import {
getExportFileExtension,
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export-utils";
import { useProjectStore } from "@/stores/project-store";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
import { useEditor } from "@/hooks/use-editor";
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const { activeProject } = useProjectStore();
const editor = useEditor();
const handleExport = () => {
setIsExportPopoverOpen(true);
};
const hasProject = !!activeProject;
const hasProject = !!editor.project.activeProject;
return (
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
@@ -36,10 +36,10 @@ export function ExportButton() {
<button
type="button"
className={cn(
"flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white transition-all duration-200",
hasProject
? "cursor-pointer hover:brightness-95"
: "cursor-not-allowed opacity-50"
: "cursor-not-allowed opacity-50",
)}
onClick={hasProject ? handleExport : undefined}
disabled={!hasProject}
@@ -50,11 +50,11 @@ export function ExportButton() {
}
}}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<div className="bg-linear-270 relative flex items-center gap-1.5 rounded-[0.8rem] from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
<span className="z-50 text-[0.875rem]">Export</span>
<div className="bg-linear-to-t absolute left-0 top-0 z-10 flex h-full w-full items-center justify-center rounded-[0.8rem] from-white/0 to-white/50">
<div className="bg-linear-270 absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.8rem] from-[#2567EC] to-[#37B6F7]"></div>
</div>
</div>
</button>
@@ -69,15 +69,16 @@ function ExportPopover({
}: {
onOpenChange: (open: boolean) => void;
}) {
const { activeProject } = useProjectStore();
const editor = useEditor();
const activeProject = editor.project.activeProject;
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality
DEFAULT_EXPORT_OPTIONS.quality,
);
const [includeAudio, setIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio || true
DEFAULT_EXPORT_OPTIONS.includeAudio || true,
);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
@@ -104,8 +105,8 @@ function ExportPopover({
if (result.success && result.buffer) {
// Download the file
const mimeType = getExportMimeType(format);
const extension = getExportFileExtension(format);
const mimeType = getExportMimeType({ format });
const extension = getExportFileExtension({ format });
const blob = new Blob([result.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
@@ -132,7 +133,7 @@ function ExportPopover({
};
return (
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
<>
{exportResult && !exportResult.success ? (
<ExportError
@@ -142,11 +143,11 @@ function ExportPopover({
) : (
<>
<div className="flex items-center justify-between">
<h3 className=" font-medium">
<h3 className="font-medium">
{isExporting ? "Exporting project" : "Export project"}
</h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="!size-5 text-foreground/85" />
<X className="text-foreground/85 !size-5" />
</Button>
</div>
@@ -233,7 +234,7 @@ function ExportPopover({
</div>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="w-4 h-4" />
<Download className="h-4 w-4" />
Export
</Button>
</>
@@ -242,18 +243,18 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4">
<div className="flex flex-col">
<div className="text-center flex items-center justify-between">
<p className="text-sm text-muted-foreground mb-2">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm">
{Math.round(progress * 100)}%
</p>
<p className="text-sm text-muted-foreground mb-2">100%</p>
<p className="text-muted-foreground mb-2 text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="rounded-md w-full"
className="w-full rounded-md"
onClick={() => {}}
>
Cancel
@@ -286,26 +287,24 @@ function ExportError({
return (
<div className="space-y-4">
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium text-red-400">Export failed</p>
<p className="text-xs text-muted-foreground">
{error}
</p>
<p className="text-sm font-medium text-destructive">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-8"
className="h-8 flex-1 text-xs"
onClick={handleCopy}
>
{copied ? <Check className="text-green-500" /> : <Copy />}
{copied ? <Check className="text-constructive" /> : <Copy />}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-8"
className="h-8 flex-1 text-xs"
onClick={onRetry}
>
<RotateCcw />
@@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
import { AudioElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
export function AudioProperties({ element }: { element: AudioElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}
@@ -1,5 +1,9 @@
import { MediaElement } from "@/types/timeline";
import { VideoElement, ImageElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
export function MediaProperties({
element,
}: {
element: VideoElement | ImageElement;
}) {
return <div className="space-y-4 p-5">Media properties</div>;
}
@@ -20,22 +20,17 @@ export function SelectionBox({
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const containerRect = containerRef.current.getBoundingClientRect();
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
@@ -49,7 +44,7 @@ export function SelectionBox({
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50 bg-foreground/10"
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
}
@@ -1,46 +0,0 @@
import { Slider } from "../ui/slider";
import { Label } from "../ui/label";
import { Button } from "../ui/button";
import { usePlaybackStore } from "@/stores/playback-store";
const SPEED_PRESETS = [
{ label: "0.5x", value: 0.5 },
{ label: "1x", value: 1.0 },
{ label: "1.5x", value: 1.5 },
{ label: "2x", value: 2.0 },
];
export function SpeedControl() {
const { speed, setSpeed } = usePlaybackStore();
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">Playback Speed</h3>
<div className="space-y-4">
<div className="flex gap-2">
{SPEED_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={speed === preset.value ? "default" : "outline"}
className="flex-1"
onClick={() => setSpeed(preset.value)}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-1">
<Label>Custom ({speed.toFixed(1)}x)</Label>
<Slider
value={[speed]}
min={0.1}
max={2.0}
step={0.1}
onValueChange={(value) => setSpeed(value[0])}
className="mt-2"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
import type { TimelineTrack, DropTarget } from "@/types/timeline";
interface DragLineProps {
dropTarget: DropTarget | null;
tracks: TimelineTrack[];
isVisible: boolean;
}
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
if (!isVisible || !dropTarget) return null;
const y = getDropLineY({ dropTarget, tracks });
return (
<div
className="bg-primary pointer-events-none absolute left-0 right-0 z-50 h-0.5"
style={{ top: `${y}px` }}
/>
);
}
@@ -8,10 +8,8 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
import {
TimelinePlayhead,
@@ -19,10 +17,11 @@ import {
} from "./timeline-playhead";
import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapIndicator } from "./snap-indicator";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/use-element-interaction";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -30,37 +29,59 @@ import {
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/use-scroll-sync";
import { useElementSelection } from "@/hooks/use-element-selection";
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { DragLine } from "./drag-line";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
export function Timeline() {
const {
tracks,
getTotalDuration,
clearSelectedElements,
snappingEnabled,
setSelectedElements,
toggleTrackMute,
dragState,
} = useTimelineStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const editor = useEditor();
const tracks = editor.timeline.sortedTracks;
const currentTime = editor.playback.currentTime;
const duration = editor.timeline.getTotalDuration();
const seek = (time: number) => editor.playback.seek({ time });
const { snappingEnabled } = useTimelineStore();
const { clearSelection, setSelection } = useElementSelection();
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
// Timeline zoom functionality
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
});
const { dragProps } = useTimelineDragDrop({
const {
dragState,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
} = useElementInteraction({
zoomLevel,
timelineRef,
tracksContainerRef,
onSnapPointChange: handleSnapPointChange,
});
// Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max(
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
(currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
@@ -69,14 +90,6 @@ export function Timeline() {
timelineRef.current?.clientWidth || 1000,
);
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
// Timeline playhead ruler handlers
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
@@ -88,8 +101,11 @@ export function Timeline() {
playheadRef,
});
// Selection box functionality
const tracksContainerRef = useRef<HTMLDivElement>(null);
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
zoomLevel,
});
const {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
@@ -99,23 +115,13 @@ export function Timeline() {
containerRef: tracksContainerRef,
playheadRef,
onSelectionComplete: (elements) => {
console.log(JSON.stringify({ onSelectionComplete: elements.length }));
setSelectedElements(elements);
setSelection(elements);
},
});
// 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);
}, []);
const { handleTimelineMouseDown, handleTimelineContentClick } =
useTimelineInteractions({
playheadRef,
@@ -126,19 +132,10 @@ export function Timeline() {
duration,
isSelecting,
justFinishedSelecting,
clearSelectedElements,
clearSelectedElements: clearSelection,
seek,
});
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
setDuration(
Math.max(totalDuration, TIMELINE_CONSTANTS.MIN_DURATION_SECONDS),
);
}, [tracks, setDuration, getTotalDuration]);
// --- Scroll synchronization effect ---
useScrollSync({
rulerScrollRef,
tracksScrollRef,
@@ -154,7 +151,10 @@ export function Timeline() {
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
>
<TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} />
<TimelineToolbar
zoomLevel={zoomLevel}
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
<div
className="relative flex flex-1 flex-col overflow-hidden"
@@ -185,17 +185,11 @@ export function Timeline() {
tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="bg-panel sticky top-0 z-10 flex">
{/* Track Labels Header */}
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
{/* Empty space */}
<span className="text-muted-foreground text-sm font-medium opacity-0">
.
</span>
<span className="opacity-0">.</span>
</div>
{/* Timeline Ruler */}
<TimelineRuler
zoomLevel={zoomLevel}
duration={duration}
@@ -209,9 +203,7 @@ export function Timeline() {
/>
</div>
{/* Tracks Area */}
<div className="flex flex-1 overflow-hidden">
{/* Track Labels */}
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
@@ -232,12 +224,20 @@ export function Timeline() {
{track.muted ? (
<VolumeOff
className="text-destructive h-4 w-4 cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
) : (
<Volume2
className="text-muted-foreground h-4 w-4 cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
onClick={() =>
editor.timeline.toggleTrackMute({
trackId: track.id,
})
}
/>
)}
<Eye className="text-muted-foreground h-4 w-4" />
@@ -250,13 +250,11 @@ export function Timeline() {
</div>
)}
{/* Timeline Tracks Content */}
<div
className="relative flex-1 overflow-hidden"
onWheel={(e) => {
// Check if this is horizontal scrolling - if so, don't handle it here
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
return; // Let ScrollArea handle horizontal scrolling
return;
}
handleWheel(e);
}}
@@ -273,6 +271,11 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<DragLine
dropTarget={dropTarget}
tracks={tracks}
isVisible={isDragOver}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
@@ -301,22 +304,24 @@ export function Timeline() {
height: `${getTrackHeight({ type: track.type })}px`,
}}
onClick={(e) => {
// If clicking empty area (not on a element), deselect all elements
if (
!(e.target as HTMLElement).closest(
".timeline-element",
)
) {
clearSelectedElements();
clearSelection();
}
}}
>
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange}
dragState={dragState}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
/>
</div>
</ContextMenuTrigger>
@@ -324,7 +329,9 @@ export function Timeline() {
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
toggleTrackMute(track.id);
editor.timeline.toggleTrackMute({
trackId: track.id,
});
}}
>
{track.muted ? "Unmute Track" : "Mute Track"}
@@ -15,11 +15,14 @@ import {
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { TimelineElementProps } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
import {
getTrackColor,
getTrackHeight,
isMutableElement,
} from "@/lib/timeline";
import {
ContextMenu,
ContextMenuContent,
@@ -28,6 +31,24 @@ import {
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
import {
TimelineElement as TimelineElementType,
TimelineTrack,
} from "@/types/timeline";
import { ElementDragState } from "@/types/timeline";
interface TimelineElementProps {
element: TimelineElementType;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onElementMouseDown: (
e: React.MouseEvent,
element: TimelineElementType,
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
}
export function TimelineElement({
element,
@@ -36,11 +57,11 @@ export function TimelineElement({
isSelected,
onElementMouseDown,
onElementClick,
dragState,
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const { requestRevealMedia } = useAssetsPanelStore();
const {
dragState,
copySelected,
selectedElements,
deleteSelected,
@@ -58,12 +79,11 @@ export function TimelineElement({
: null;
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
element,
track,
zoomLevel,
});
const { handleResizeStart } = useTimelineElementResize({
element,
track,
zoomLevel,
});
const {
isMultipleSelected,
@@ -215,7 +235,7 @@ export function TimelineElement({
}
};
const isMuted = element.type === "media" && element.muted;
const isMuted = isMutableElement(element) && element.muted;
return (
<ContextMenu>
@@ -230,12 +250,9 @@ export function TimelineElement({
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackElementClasses(
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackColor(
{
type: track.type,
},
@@ -266,13 +283,25 @@ export function TimelineElement({
<>
<div
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
onMouseDown={(e) =>
handleResizeStart({
e,
elementId: element.id,
side: "left",
})
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<div
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
onMouseDown={(e) =>
handleResizeStart({
e,
elementId: element.id,
side: "right",
})
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
@@ -1,7 +1,5 @@
import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useSceneStore } from "@/stores/scene-store";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/use-element-selection";
import { toast } from "sonner";
import {
TooltipProvider,
@@ -46,97 +44,41 @@ export function TimelineToolbar({
setZoomLevel,
}: {
zoomLevel: number;
setZoomLevel: (zoom: number) => void;
setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
const {
tracks,
addTrack,
addElementToTrack,
selectedElements,
clearSelectedElements,
deleteSelected,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
snappingEnabled,
toggleSnapping,
rippleEditingEnabled,
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { activeProject } = useProjectStore();
const { toggleBookmark, isBookmarked } = useSceneStore();
const { scenes, currentScene } = useSceneStore();
const editor = useEditor();
const { selectedElements, clearSelection } = useElementSelection();
const handleSplitSelected = () => {
splitSelected(currentTime);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.currentTime,
});
};
const handleDuplicateSelected = () => {
if (selectedElements.length === 0) return;
const canDuplicate = selectedElements.length === 1;
if (!canDuplicate) return;
selectedElements.forEach(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((el) => el.id === elementId);
if (element) {
const newStartTime =
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1;
const { id, ...elementWithoutId } = element;
addElementToTrack(trackId, {
...elementWithoutId,
startTime: newStartTime,
});
}
});
clearSelectedElements();
};
const handleFreezeSelected = () => {
toast.info("Freeze frame functionality coming soon!");
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
editor.timeline.duplicateElements({ elements: selectedElements });
clearSelection();
};
const handleSplitAndKeepLeft = () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return;
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 selected element");
return;
}
splitAndKeepLeft(trackId, elementId, currentTime);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.currentTime,
retainSide: "left",
});
};
const handleSplitAndKeepRight = () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) return;
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 selected element");
return;
}
splitAndKeepRight(trackId, elementId, currentTime);
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.currentTime,
retainSide: "right",
});
};
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
@@ -150,248 +92,329 @@ export function TimelineToolbar({
TIMELINE_CONSTANTS.ZOOM_MIN,
zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP,
);
setZoomLevel(newZoomLevel);
setZoomLevel({ zoom: newZoomLevel });
};
const currentBookmarked = isBookmarked({ time: currentTime });
const hasNoTracks = editor.timeline.getTracks().length === 0;
return (
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggle}>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={() => seek(0)}>
<SkipBack className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
{/* Time Display */}
<div className="flex flex-row items-center justify-center px-2">
<EditableTimecode
time={currentTime}
duration={duration}
format="HH:MM:SS:FF"
fps={activeProject?.fps ?? DEFAULT_FPS}
onTimeChange={seek}
className="text-center"
/>
<div className="text-muted-foreground px-2 font-mono text-xs">
/
</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode({ timeInSeconds: duration })}
{formatTimeCode({
timeInSeconds: duration,
format: "HH:MM:SS:FF",
})}
</div>
</div>
{tracks.length === 0 && (
<>
<div className="bg-border mx-1 h-6 w-px" />
<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="bg-border mx-1 h-6 w-px" />
<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" disabled>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Coming soon)</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={() => deleteSelected()}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={() => toggleBookmark({ time: currentTime })}
>
<Bookmark
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div>
<SplitButton className="border-foreground/10 border">
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
<LayersIcon />
</SplitButtonRight>
</ScenesView>
</SplitButton>
</div>
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
{snappingEnabled ? (
<Magnet className="text-primary h-4 w-4" />
) : (
<Magnet className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleRippleEditing}>
<Link
className={`h-4 w-4 ${
rippleEditingEnabled ? "text-primary" : ""
}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{rippleEditingEnabled
? "Disable Ripple Editing"
: "Enable Ripple Editing"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ToolbarLeftSection
hasNoTracks={hasNoTracks}
onSplit={handleSplitSelected}
onSplitLeft={handleSplitAndKeepLeft}
onSplitRight={handleSplitAndKeepRight}
onDuplicate={handleDuplicateSelected}
/>
<SceneSelector />
<ToolbarRightSection
zoomLevel={zoomLevel}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
</div>
);
}
function ToolbarLeftSection({
hasNoTracks,
onSplit,
onSplitLeft,
onSplitRight,
onDuplicate,
}: {
hasNoTracks: boolean;
onSplit: () => void;
onSplitLeft: () => void;
onSplitRight: () => void;
onDuplicate: () => void;
}) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
const currentTime = editor.playback.currentTime;
const duration = editor.timeline.getTotalDuration();
const isPlaying = editor.playback.isPlaying;
const activeProject = editor.project.getActive();
const fps = activeProject?.fps ?? DEFAULT_FPS;
const currentBookmarked = editor.scene.isBookmarked({ time: currentTime });
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.playback.toggle()}
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.playback.seek({ time: 0 })}
>
<SkipBack className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
onClick={() => handleZoom({ direction: "out" })}
<TimeDisplay currentTime={currentTime} duration={duration} fps={fps} />
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={onSplit}>
<Scissors className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onSplitLeft}
>
<ArrowLeftToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onSplitRight}
>
<ArrowRightToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" disabled type="button">
<SplitSquareHorizontal className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={onDuplicate}
>
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() =>
toast.info("Freeze frame functionality coming soon!")
}
>
<Snowflake className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() =>
editor.timeline.deleteElements({ elements: selectedElements })
}
>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="bg-border mx-1 h-6 w-px" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
type="button"
onClick={() => editor.scene.toggleBookmark({ time: currentTime })}
>
<Bookmark
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
function TimeDisplay({
currentTime,
duration,
fps,
}: {
currentTime: number;
duration: number;
fps: number;
}) {
const editor = useEditor();
return (
<div className="flex flex-row items-center justify-center px-2">
<EditableTimecode
time={currentTime}
duration={duration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={(time) => editor.playback.seek({ time })}
className="text-center"
/>
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode({ timeInSeconds: duration })}
{formatTimeCode({
timeInSeconds: duration,
format: "HH:MM:SS:FF",
})}
</div>
</div>
);
}
function SceneSelector() {
const editor = useEditor();
const currentScene = editor.scene.getCurrentScene();
const scenesCount = editor.scene.getScenes().length;
return (
<div>
<SplitButton className="border-foreground/10 border">
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight
disabled={scenesCount === 1}
onClick={() => {}}
type="button"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => setZoomLevel(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>
<Button
variant="text"
size="icon"
onClick={() => handleZoom({ direction: "in" })}
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<LayersIcon className="size-4" />
</SplitButtonRight>
</ScenesView>
</SplitButton>
</div>
);
}
function ToolbarRightSection({
zoomLevel,
onZoomChange,
onZoom,
}: {
zoomLevel: number;
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
return (
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Magnet className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" type="button" onClick={() => {}}>
<Link className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Enable Ripple Editing</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="bg-border mx-1 h-6 w-px" />
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "out" })}
>
<ZoomOut className="size-4" />
</Button>
<Slider
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>
<Button
variant="text"
size="icon"
type="button"
onClick={() => onZoom({ direction: "in" })}
>
<ZoomIn className="size-4" />
</Button>
</div>
</div>
);
@@ -1,388 +1,68 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
import { useElementSelection } from "@/hooks/use-element-selection";
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
import { useProjectStore } from "@/stores/project-store";
import { snapTimeToFrame } from "@/lib/time-utils";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
dragState: ElementDragState;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
lastMouseXRef: React.RefObject<number>;
onElementMouseDown: (params: {
e: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
onElementClick: (params: {
e: React.MouseEvent;
element: TimelineElementType;
track: TimelineTrack;
}) => void;
}
export function TimelineTrackContent({
track,
zoomLevel,
onSnapPointChange,
dragState,
rulerScrollRef,
tracksScrollRef,
}: {
track: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}) {
const {
tracks,
updateElementStartTime,
updateElementStartTimeWithRipple,
selectedElements,
selectElement,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
clearSelectedElements,
rippleEditingEnabled,
} = useTimelineStore();
lastMouseXRef,
onElementMouseDown,
onElementClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isSelected, clearSelection } = useElementSelection();
const { duration } = usePlaybackStore();
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
track,
zoomLevel,
onSnapPointChange,
});
const timelineRef = useRef<HTMLDivElement>(null);
const [mouseDownLocation, setMouseDownLocation] = useState<{
x: number;
y: number;
} | null>(null);
const lastMouseXRef = useRef(0);
// Set up mouse event listeners for drag
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
lastMouseXRef.current = e.clientX;
// On first mouse move during drag, ensure the element is selected
if (dragState.elementId && dragState.trackId) {
const isSelected = selectedElements.some(
(c) =>
c.trackId === dragState.trackId &&
c.elementId === dragState.elementId,
);
if (!isSelected) {
// Select this element (replacing other selections) since we're dragging it
selectElement(dragState.trackId, dragState.elementId, false);
}
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
const finalTime = snapTimeToFrame({
time: adjustedTime,
fps: projectFps,
});
updateDragTime(finalTime);
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
const isTrackThatStartedDrag = dragState.trackId === track.id;
const timelineRect = timelineRef.current?.getBoundingClientRect();
if (!timelineRect) {
if (isTrackThatStartedDrag) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
dragState.currentTime,
);
} else {
updateElementStartTime(
track.id,
dragState.elementId,
dragState.currentTime,
);
}
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
return;
}
const isMouseOverThisTrack =
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
const finalTime = dragState.currentTime;
if (isMouseOverThisTrack) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId,
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const targetTrack = tracks.find((t) => t.id === track.id);
const hasOverlap = targetTrack?.elements.some((existingElement) => {
if (
dragState.trackId === track.id &&
existingElement.id === dragState.elementId
) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === track.id) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
finalTime,
);
} else {
updateElementStartTime(
track.id,
dragState.elementId,
finalTime,
);
}
} else {
toast.info("Moving elements between tracks is coming soon!");
}
}
}
} else if (isTrackThatStartedDrag) {
// Mouse is not over this track, but this track started the drag
// This means user released over ruler/outside - update position within same track
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId,
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
if (existingElement.id === dragState.elementId) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId,
finalTime,
);
} else {
updateElementStartTime(track.id, dragState.elementId, finalTime);
}
}
}
}
if (isTrackThatStartedDrag) {
endDragAction();
// Clear snap point when drag ends
onSnapPointChange?.(null);
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
track.id,
updateDragTime,
updateElementStartTime,
endDragAction,
selectedElements,
selectElement,
onSnapPointChange,
]);
const duration = editor.timeline.getTotalDuration();
useEdgeAutoScroll({
isActive: dragState.isDragging,
getMouseClientX: () => lastMouseXRef.current,
getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const handleElementMouseDown = (
e: React.MouseEvent,
element: TimelineElementType,
) => {
setMouseDownLocation({ x: e.clientX, y: e.clientY });
// Detect right-click (button 2) and handle selection without starting drag
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
if (isRightClick) {
// Handle right-click selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
// If element is not selected, select it (keep other selections if multi-select)
if (!isSelected) {
selectElement(track.id, element.id, isMultiSelect);
}
// If element is already selected, keep it selected
// Don't start drag action for right-clicks
return;
}
// Handle multi-selection for left-click with modifiers
if (isMultiSelect) {
selectElement(track.id, element.id, true);
}
// Calculate the offset from the left edge of the element to where the user clicked
const elementElement = e.currentTarget as HTMLElement;
const elementRect = elementElement.getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDragAction(
element.id,
track.id,
e.clientX,
element.startTime,
clickOffsetTime,
);
};
const handleElementClick = (
e: React.MouseEvent,
element: TimelineElementType,
) => {
e.stopPropagation();
// Check if mouse moved significantly
if (mouseDownLocation) {
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
// If it moved more than a few pixels, consider it a drag and not a click.
if (deltaX > 5 || deltaY > 5) {
setMouseDownLocation(null); // Reset for next interaction
return;
}
}
// Skip selection logic for multi-selection (handled in mousedown)
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
// Handle single selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
if (!isSelected) {
// If element is not selected, select it (replacing other selections)
selectElement(track.id, element.id, false);
}
// If element is already selected, keep it selected (do nothing)
};
return (
<div
className="hover:bg-muted/20 h-full w-full"
onClick={(e) => {
// If clicking empty area (not on an element), deselect all elements
if (!(e.target as HTMLElement).closest(".timeline-element")) {
clearSelectedElements();
}
}}
{...dragProps}
>
<div
ref={timelineRef}
className="track-elements-container relative h-full min-w-full"
>
<div className="hover:bg-muted/20 size-full" onClick={clearSelection}>
<div className="track-elements-container relative h-full min-w-full">
{track.elements.length === 0 ? (
<div
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
isDragOver
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
>
{isDragOver
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop element here"
: ""}
</div>
<div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
<>
{track.elements.map((element) => {
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id,
);
const isElementSelected = isSelected({
trackId: track.id,
elementId: element.id,
});
return (
<TimelineElement
@@ -390,9 +70,14 @@ export function TimelineTrackContent({
element={element}
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
isSelected={isElementSelected}
onElementMouseDown={(e, el) =>
onElementMouseDown({ e, element: el, track })
}
onElementClick={(e, el) =>
onElementClick({ e, element: el, track })
}
dragState={dragState}
/>
);
})}
@@ -1,5 +1,5 @@
import { Button } from "./ui/button";
import { GithubIcon } from "./icons";
import { GithubIcon } from "@opencut/ui/icons";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants";
@@ -18,7 +18,7 @@ export function RenameProjectDialog({
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
onConfirm: (newName: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
+21 -25
View File
@@ -12,12 +12,13 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePlaybackStore } from "@/stores/playback-store";
import { DragData } from "@/types/timeline";
import { setAssetDragData } from "@/lib/asset-drag";
import type { AssetDragData } from "@/types/assets";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: DragData;
dragData: AssetDragData;
onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
@@ -80,14 +81,9 @@ export function DraggableMediaItem({
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Set drag data
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify(dragData)
);
setAssetDragData({ dataTransfer: e.dataTransfer, dragData });
e.dataTransfer.effectAllowed = "copy";
// Set initial position and show custom drag preview
setDragPosition({ x: e.clientX, y: e.clientY });
setIsDragging(true);
@@ -103,13 +99,13 @@ export function DraggableMediaItem({
{variant === "card" ? (
<div
ref={dragRef}
className={cn("relative group", containerClassName ?? "w-28 h-28")}
className={cn("group relative", containerClassName ?? "h-28 w-28")}
>
<div
className={cn(
"flex flex-col gap-1 p-1 h-auto w-full relative cursor-default",
"relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
className,
isHighlighted && highlightClassName
isHighlighted && highlightClassName,
)}
>
<AspectRatio
@@ -117,7 +113,7 @@ export function DraggableMediaItem({
className={cn(
"bg-panel-accent relative overflow-hidden",
rounded && "rounded-md",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding
)}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
@@ -133,7 +129,7 @@ export function DraggableMediaItem({
</AspectRatio>
{showLabel && (
<span
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
aria-label={name}
title={name}
>
@@ -148,24 +144,24 @@ export function DraggableMediaItem({
<div
ref={dragRef}
className={cn(
"relative group w-full",
isHighlighted && highlightClassName
"group relative w-full",
isHighlighted && highlightClassName,
)}
>
<div
className={cn(
"h-8 flex items-center gap-3 cursor-default w-full px-1",
"flex h-8 w-full cursor-default items-center gap-3 px-1",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className
className,
)}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
<div className="w-6 h-6 flex-shrink-0 rounded-[0.35rem] overflow-hidden">
<div className="h-6 w-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
<span className="text-sm truncate flex-1 w-full">{name}</span>
<span className="w-full flex-1 truncate text-sm">{name}</span>
</div>
</div>
)}
@@ -176,7 +172,7 @@ export function DraggableMediaItem({
typeof document !== "undefined" &&
createPortal(
<div
className="fixed pointer-events-none z-9999"
className="z-9999 pointer-events-none fixed"
style={{
left: dragPosition.x - 40, // Center the preview (half of 80px)
top: dragPosition.y - 40, // Center the preview (half of 80px)
@@ -185,9 +181,9 @@ export function DraggableMediaItem({
<div className="w-[80px]">
<AspectRatio
ratio={1}
className="relative rounded-md overflow-hidden shadow-2xl ring-3 ring-primary"
className="ring-3 ring-primary relative overflow-hidden rounded-md shadow-2xl"
>
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:rounded-none [&_img]:object-cover">
{preview}
</div>
{showPlusOnDrag && (
@@ -199,7 +195,7 @@ export function DraggableMediaItem({
</AspectRatio>
</div>
</div>,
document.body
document.body,
)}
</>
);
@@ -218,8 +214,8 @@ function PlusButton({
<Button
size="icon"
className={cn(
"absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground",
className
"bg-background hover:bg-panel text-foreground absolute bottom-2 right-2 size-5",
className,
)}
onClick={(e) => {
e.preventDefault();