mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
push
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user