push
This commit is contained in:
parent
d5ca991501
commit
c19f085e48
|
|
@ -0,0 +1,59 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Overview
|
||||
|
||||
Privacy-first video editor, with a focus on simplicity and ease of use.
|
||||
|
||||
## Core Editor System
|
||||
|
||||
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
EditorCore (singleton)
|
||||
├── playback: PlaybackManager
|
||||
├── timeline: TimelineManager
|
||||
├── scene: SceneManager
|
||||
├── project: ProjectManager
|
||||
├── media: MediaManager
|
||||
└── renderer: RendererManager
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
#### In React Components
|
||||
|
||||
**Always use the `useEditor()` hook:**
|
||||
|
||||
```typescript
|
||||
import { useEditor } from '@/hooks/use-editor';
|
||||
|
||||
function MyComponent() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
// Call methods
|
||||
editor.timeline.addTrack({ type: 'media' });
|
||||
|
||||
// Display data (auto re-renders on changes)
|
||||
return <div>{tracks.length} tracks</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The hook:
|
||||
- Returns the singleton instance
|
||||
- Subscribes to all manager changes
|
||||
- Automatically re-renders when state changes
|
||||
|
||||
#### Outside React Components
|
||||
|
||||
**Use `EditorCore.getInstance()` directly:**
|
||||
|
||||
```typescript
|
||||
// In utilities, event handlers, or non-React code
|
||||
import { EditorCore } from '@/core';
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
await editor.export({ format: 'mp4', quality: 'high' });
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
[x] Delete all stores we can
|
||||
[x] Create commands for each action
|
||||
[x] Finish timeline store
|
||||
[x] Migrate final timeline store to manager
|
||||
[x] Delete timeline store
|
||||
[ ] Update usage of all stores to use the new managers
|
||||
|
|
@ -3,4 +3,6 @@
|
|||
|
||||
# Env vars
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.example
|
||||
|
||||
.next/
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 83%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
|
|
@ -62,6 +64,8 @@
|
|||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 100% 60%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 17%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
|
|
@ -152,6 +156,9 @@
|
|||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
|
|
|||
|
|
@ -36,25 +36,12 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import type { TProject } from "@/types/project";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const {
|
||||
savedProjects,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
deleteProject,
|
||||
createNewProject,
|
||||
getFilteredAndSortedProjects,
|
||||
} = useProjectStore();
|
||||
const [thumbnailCache, setThumbnailCache] = useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
|
|
@ -62,36 +49,21 @@ export default function ProjectsPage() {
|
|||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState("createdAt-desc");
|
||||
const { getProjectThumbnail } = useTimelineStore();
|
||||
const router = useRouter();
|
||||
|
||||
const getProjectThumbnail = useCallback(
|
||||
async (projectId: string): Promise<string | null> => {
|
||||
if (thumbnailCache[projectId] !== undefined) {
|
||||
return thumbnailCache[projectId];
|
||||
}
|
||||
|
||||
setLoadingThumbnails((prev) => new Set(prev).add(projectId));
|
||||
|
||||
try {
|
||||
const thumbnail = await getProjectThumbnail(projectId);
|
||||
setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
|
||||
return thumbnail;
|
||||
} finally {
|
||||
setLoadingThumbnails((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(projectId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const projectId = await createNewProject("New Project");
|
||||
console.log("projectId", projectId);
|
||||
router.push(`/editor/${projectId}`);
|
||||
try {
|
||||
const projectId = await editor.project.createNewProject({
|
||||
name: "New project",
|
||||
});
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
toast.error("Failed to create project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProject = (projectId: string, checked: boolean) => {
|
||||
|
|
@ -118,15 +90,28 @@ export default function ProjectsPage() {
|
|||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
|
||||
);
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) =>
|
||||
editor.project.deleteProject({ id: projectId }),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete projects", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
} finally {
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
|
||||
const sortedProjects = editor.project.getFilteredAndSortedProjects({
|
||||
searchQuery,
|
||||
sortOption,
|
||||
});
|
||||
|
||||
const allSelected =
|
||||
sortedProjects.length > 0 &&
|
||||
|
|
@ -178,8 +163,8 @@ export default function ProjectsPage() {
|
|||
Your Projects
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{savedProjects.length}{" "}
|
||||
{savedProjects.length === 1 ? "project" : "projects"}
|
||||
{sortedProjects.length}{" "}
|
||||
{sortedProjects.length === 1 ? "project" : "projects"}
|
||||
{isSelectionMode && selectedProjects.size > 0 && (
|
||||
<span className="text-primary ml-2">
|
||||
• {selectedProjects.size} selected
|
||||
|
|
@ -209,9 +194,9 @@ export default function ProjectsPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
disabled={savedProjects.length === 0}
|
||||
disabled={sortedProjects.length === 0}
|
||||
>
|
||||
Select Projects
|
||||
Select projects
|
||||
</Button>
|
||||
<CreateButton onClick={handleCreateProject} />
|
||||
</div>
|
||||
|
|
@ -317,7 +302,7 @@ export default function ProjectsPage() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{isLoading || !isInitialized ? (
|
||||
{editor.project.isLoading || !editor.project.isInitialized ? (
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div
|
||||
|
|
@ -335,7 +320,7 @@ export default function ProjectsPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : savedProjects.length === 0 ? (
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<NoResults
|
||||
|
|
@ -351,7 +336,6 @@ export default function ProjectsPage() {
|
|||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedProjects.has(project.id)}
|
||||
onSelect={handleSelectProject}
|
||||
getProjectThumbnail={getProjectThumbnail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -372,7 +356,6 @@ interface ProjectCardProps {
|
|||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (projectId: string, checked: boolean) => void;
|
||||
getProjectThumbnail: (projectId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
|
|
@ -380,28 +363,12 @@ function ProjectCard({
|
|||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
getProjectThumbnail,
|
||||
}: ProjectCardProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
|
||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
|
||||
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadThumbnail = async () => {
|
||||
setIsLoadingThumbnail(true);
|
||||
try {
|
||||
const thumbnail = await getProjectThumbnail(project.id);
|
||||
setDynamicThumbnail(thumbnail);
|
||||
} finally {
|
||||
setIsLoadingThumbnail(false);
|
||||
}
|
||||
};
|
||||
loadThumbnail();
|
||||
}, [project.id, getProjectThumbnail]);
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
|
|
@ -466,13 +433,9 @@ function ProjectCard({
|
|||
)}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{isLoadingThumbnail ? (
|
||||
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
) : dynamicThumbnail ? (
|
||||
{project.thumbnail ? (
|
||||
<Image
|
||||
src={dynamicThumbnail}
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
|
|
|
|||
|
|
@ -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 [isInTimeline, setIsInTimeline] = useState(false);
|
||||
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" })}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { TextElement } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "./timeline-constants";
|
||||
|
||||
export const DEFAULT_TEXT_ELEMENT: Omit<
|
||||
TextElement,
|
||||
"id"
|
||||
> = {
|
||||
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
|
||||
type: "text",
|
||||
name: "Text",
|
||||
content: "Default Text",
|
||||
|
|
@ -16,11 +13,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<
|
|||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@ export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
|||
audio: 50,
|
||||
} as const;
|
||||
|
||||
export const TRACK_GAP = 4;
|
||||
|
||||
export const TIMELINE_CONSTANTS = {
|
||||
ELEMENT_MIN_WIDTH: 80,
|
||||
PIXELS_PER_SECOND: 50,
|
||||
TRACK_HEIGHT: 60,
|
||||
DEFAULT_TEXT_DURATION: 5,
|
||||
DEFAULT_IMAGE_DURATION: 5,
|
||||
DEFAULT_ELEMENT_DURATION: 5,
|
||||
MIN_DURATION_SECONDS: 10,
|
||||
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // Padding ahead of playhead
|
||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SceneManager } from "./managers/scene-manager";
|
|||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
import { CommandManager } from "./managers/commands";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import type { ExportOptions } from "@/types/export";
|
||||
|
|
@ -12,6 +13,7 @@ import { DEFAULT_FPS } from "@/constants/editor-constants";
|
|||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
|
||||
public readonly command: CommandManager;
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly scene: SceneManager;
|
||||
|
|
@ -20,6 +22,7 @@ export class EditorCore {
|
|||
public readonly renderer: RendererManager;
|
||||
|
||||
private constructor() {
|
||||
this.command = new CommandManager();
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.scene = new SceneManager(this);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { Command } from "@/lib/commands";
|
||||
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
command.execute();
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
return command;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.history.length === 0) return;
|
||||
const command = this.history.pop();
|
||||
command?.undo();
|
||||
if (command) {
|
||||
this.redoStack.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0) return;
|
||||
const command = this.redoStack.pop();
|
||||
command?.redo();
|
||||
if (command) {
|
||||
this.history.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.history.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
|
||||
export class MediaManager {
|
||||
private mediaFiles: MediaFile[] = [];
|
||||
private isLoading = false;
|
||||
public mediaFiles: MediaFile[] = [];
|
||||
public isLoading = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
|
@ -88,32 +87,7 @@ export class MediaManager {
|
|||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
|
||||
const updatedMediaItems = await Promise.all(
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const thumbnailUrl = await generateThumbnail({
|
||||
videoFile: item.file,
|
||||
timeInSeconds: 1,
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error,
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
this.mediaFiles = updatedMediaItems;
|
||||
this.mediaFiles = mediaItems;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import type { EditorCore } from "@/core";
|
|||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private speed = 1.0;
|
||||
public isPlaying = false;
|
||||
public currentTime = 0;
|
||||
public volume = 1;
|
||||
public muted = false;
|
||||
public previousVolume = 1;
|
||||
public speed = 1.0;
|
||||
private listeners = new Set<() => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private lastUpdate = 0;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,19 @@ import type { TCanvasSize } from "@/types/editor";
|
|||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE, DEFAULT_BLUR_INTENSITY } from "@/constants/editor-constants";
|
||||
import {
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "@/constants/editor-constants";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
|
||||
export class ProjectManager {
|
||||
private activeProject: TProject | null = null;
|
||||
private savedProjects: TProject[] = [];
|
||||
private isLoading = true;
|
||||
private isInitialized = false;
|
||||
public activeProject: TProject | null = null;
|
||||
public savedProjects: TProject[] = [];
|
||||
public isLoading = true;
|
||||
public isInitialized = false;
|
||||
private invalidProjectIds = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
|
|
@ -257,6 +262,70 @@ export class ProjectManager {
|
|||
}
|
||||
}
|
||||
|
||||
async updateProjectThumbnail({
|
||||
thumbnail,
|
||||
}: {
|
||||
thumbnail: string;
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
thumbnail,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project thumbnail:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async prepareExit(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
|
||||
const firstElement = tracks
|
||||
.flatMap((track) => track.elements)
|
||||
.sort((a, b) => a.startTime - b.startTime)[0];
|
||||
|
||||
if (
|
||||
firstElement &&
|
||||
(firstElement.type === "video" || firstElement.type === "image")
|
||||
) {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(item) => item.id === firstElement.mediaId,
|
||||
);
|
||||
|
||||
if (mediaFile) {
|
||||
let thumbnailDataUrl: string | undefined;
|
||||
|
||||
if (mediaFile.type === "video" && mediaFile.file) {
|
||||
thumbnailDataUrl = await generateThumbnail({
|
||||
videoFile: mediaFile.file,
|
||||
timeInSeconds: 1,
|
||||
});
|
||||
} else if (mediaFile.type === "image" && mediaFile.url) {
|
||||
thumbnailDataUrl = mediaFile.thumbnailUrl || mediaFile.url;
|
||||
}
|
||||
|
||||
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
|
||||
await this.updateProjectThumbnail({ thumbnail: thumbnailDataUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate project thumbnail on exit:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectBackground({
|
||||
backgroundColor,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
|
|||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
|
||||
|
|
@ -23,7 +23,7 @@ interface AudioElement {
|
|||
}
|
||||
|
||||
export class RendererManager {
|
||||
private renderTree: RootNode | null = null;
|
||||
public renderTree: RootNode | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
|
@ -166,14 +166,17 @@ export class RendererManager {
|
|||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (element.type !== "media") continue;
|
||||
if (element.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaElement = element;
|
||||
const mediaItem = mediaMap.get(mediaElement.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") continue;
|
||||
const mediaItem = mediaMap.get(element.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibleDuration =
|
||||
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
if (visibleDuration <= 0) continue;
|
||||
|
||||
try {
|
||||
|
|
@ -184,11 +187,11 @@ export class RendererManager {
|
|||
|
||||
audioElements.push({
|
||||
buffer: audioBuffer,
|
||||
startTime: mediaElement.startTime,
|
||||
duration: mediaElement.duration,
|
||||
trimStart: mediaElement.trimStart,
|
||||
trimEnd: mediaElement.trimEnd,
|
||||
muted: mediaElement.muted || track.muted || false,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
muted: element.muted || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import {
|
|||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
export class SceneManager {
|
||||
private currentScene: TScene | null = null;
|
||||
private scenes: TScene[] = [];
|
||||
public currentScene: TScene | null = null;
|
||||
public scenes: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
|
|
|||
|
|
@ -4,31 +4,30 @@ import type {
|
|||
CreateTimelineElement,
|
||||
TimelineTrack,
|
||||
TextElement,
|
||||
DragData,
|
||||
TimelineElement,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { calculateTotalDuration } from "@/lib/timeline/calculation-utils";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
ToggleTrackMuteCommand,
|
||||
AddElementToTrackCommand,
|
||||
UpdateElementTrimCommand,
|
||||
UpdateElementDurationCommand,
|
||||
DeleteElementsCommand,
|
||||
DuplicateElementsCommand,
|
||||
ToggleElementsHiddenCommand,
|
||||
ToggleElementsMutedCommand,
|
||||
UpdateTextElementCommand,
|
||||
SplitElementsCommand,
|
||||
PasteCommand,
|
||||
UpdateElementStartTimeCommand,
|
||||
MoveElementCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
|
||||
export class TimelineManager {
|
||||
private _tracks: TimelineTrack[] = [];
|
||||
private history: TimelineTrack[][] = [];
|
||||
private redoStack: TimelineTrack[][] = [];
|
||||
private clipboard: {
|
||||
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
|
||||
} | null = null;
|
||||
private tracks: TimelineTrack[] = [];
|
||||
private snappingEnabled = true;
|
||||
private rippleEditingEnabled = false;
|
||||
private selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
private dragState = {
|
||||
isDragging: false,
|
||||
elementId: null as string | null,
|
||||
trackId: null as string | null,
|
||||
startMouseX: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
public sortedTracks: TimelineTrack[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {
|
||||
|
|
@ -36,110 +35,18 @@ export class TimelineManager {
|
|||
}
|
||||
|
||||
private initializeTracks(): void {
|
||||
this._tracks = [];
|
||||
this.tracks = [];
|
||||
this.sortedTracks = [];
|
||||
}
|
||||
|
||||
getSortedTracks(): TimelineTrack[] {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSnapping(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleRippleEditing(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
selectElement({
|
||||
trackId,
|
||||
elementId,
|
||||
multi = false,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
multi?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
deselectElement({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
clearSelectedElements(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
setSelectedElements({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
setDragState({
|
||||
dragState,
|
||||
}: {
|
||||
dragState: Partial<{
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}>;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
startDrag({
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
}: {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
startMouseX: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateDragTime({ currentTime }: { currentTime: number }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
endDrag(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addTrack({ type }: { type: TrackType }): string {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
insertTrackAt({ type, index }: { type: TrackType; index: number }): string {
|
||||
throw new Error("Not implemented");
|
||||
addTrack({ type, index }: { type: TrackType; index?: number }): string {
|
||||
const command = new AddTrackCommand(type, index);
|
||||
this.editor.command.execute({ command });
|
||||
return command.getTrackId();
|
||||
}
|
||||
|
||||
removeTrack({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
removeTrackWithRipple({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new RemoveTrackCommand(trackId);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
addElementToTrack({
|
||||
|
|
@ -149,7 +56,8 @@ export class TimelineManager {
|
|||
trackId: string;
|
||||
element: CreateTimelineElement;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new AddElementToTrackCommand(trackId, element);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateElementTrim({
|
||||
|
|
@ -165,7 +73,17 @@ export class TimelineManager {
|
|||
trimEnd: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new UpdateElementTrimCommand(
|
||||
trackId,
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
);
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementDuration({
|
||||
|
|
@ -179,97 +97,99 @@ export class TimelineManager {
|
|||
duration: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new UpdateElementDurationCommand(
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
);
|
||||
if (pushHistory) {
|
||||
this.editor.command.execute({ command });
|
||||
} else {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
updateElementStartTime({
|
||||
trackId,
|
||||
elementId,
|
||||
elements,
|
||||
startTime,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
startTime: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new UpdateElementStartTimeCommand(elements, startTime);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleTrackMute({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitAndKeepLeft({
|
||||
trackId,
|
||||
elementId,
|
||||
splitTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
splitTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitAndKeepRight({
|
||||
trackId,
|
||||
elementId,
|
||||
splitTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
splitTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementStartTimeWithRipple({
|
||||
trackId,
|
||||
moveElement({
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
sourceTrackId: string;
|
||||
targetTrackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new MoveElementCommand(
|
||||
sourceTrackId,
|
||||
targetTrackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
removeElementFromTrackWithRipple({
|
||||
trackId,
|
||||
elementId,
|
||||
pushHistory = true,
|
||||
toggleTrackMute({ trackId }: { trackId: string }): void {
|
||||
const command = new ToggleTrackMuteCommand(trackId);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
splitElements({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
pushHistory?: boolean;
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
return calculateTotalDuration({ tracks: this._tracks });
|
||||
return calculateTotalDuration({ tracks: this.sortedTracks });
|
||||
}
|
||||
|
||||
getProjectThumbnail({
|
||||
projectId,
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
return this.sortedTracks.find((track) => track.id === trackId) ?? null;
|
||||
}
|
||||
|
||||
getElementsWithTracks({
|
||||
elements,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<string | null> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
elements:
|
||||
| { trackId: string; elementId: string }[]
|
||||
| { trackId: string; elementId: string };
|
||||
}):
|
||||
| Array<{ track: TimelineTrack; element: TimelineElement }>
|
||||
| { track: TimelineTrack; element: TimelineElement }
|
||||
| null {
|
||||
const normalized = Array.isArray(elements) ? elements : [elements];
|
||||
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
|
||||
[];
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
for (const { trackId, elementId } of normalized) {
|
||||
const track = this.getTrackById({ trackId });
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
|
||||
redo(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
if (track && element) {
|
||||
result.push({ track, element });
|
||||
}
|
||||
}
|
||||
|
||||
pushHistory(): void {
|
||||
throw new Error("Not implemented");
|
||||
return Array.isArray(elements) ? result : (result[0] ?? null);
|
||||
}
|
||||
|
||||
async loadProjectTimeline({
|
||||
|
|
@ -279,7 +199,19 @@ export class TimelineManager {
|
|||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
try {
|
||||
const tracks = await storageService.loadTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
if (tracks) {
|
||||
this.updateTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveProjectTimeline({
|
||||
|
|
@ -289,91 +221,34 @@ export class TimelineManager {
|
|||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
try {
|
||||
await storageService.saveTimeline({
|
||||
projectId,
|
||||
tracks: this.sortedTracks,
|
||||
sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeline(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
copySelected(): void {
|
||||
throw new Error("Not implemented");
|
||||
this.updateTracks([]);
|
||||
}
|
||||
|
||||
pasteAtTime({ time }: { time: number }): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new PasteCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
deleteSelected({
|
||||
trackId,
|
||||
elementId,
|
||||
deleteElements({
|
||||
elements,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitSelected({
|
||||
splitTime,
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
splitTime: number;
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSelectedHidden({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSelectedMuted({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
duplicateElement({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
revealElementInMedia({ elementId }: { elementId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getContextMenuState({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
hasAudioElements: boolean;
|
||||
canSplitSelected: boolean;
|
||||
currentTime: number;
|
||||
} {
|
||||
throw new Error("Not implemented");
|
||||
const command = new DeleteElementsCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateTextElement({
|
||||
|
|
@ -395,14 +270,38 @@ export class TimelineManager {
|
|||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
| "x"
|
||||
| "y"
|
||||
| "rotation"
|
||||
| "opacity"
|
||||
>
|
||||
>;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
const command = new UpdateTextElementCommand(trackId, elementId, updates);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
duplicateElements({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new DuplicateElementsCommand({ elements });
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleElementsHidden({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsHiddenCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleElementsMuted({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsMutedCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
checkElementOverlap({
|
||||
|
|
@ -419,50 +318,8 @@ export class TimelineManager {
|
|||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
findOrCreateTrack({ trackType }: { trackType: TrackType }): string {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addElementAtTime({
|
||||
item,
|
||||
currentTime = 0,
|
||||
}: {
|
||||
item: MediaFile | TextElement;
|
||||
currentTime?: number;
|
||||
}): boolean {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addElementToNewTrack({
|
||||
item,
|
||||
}: {
|
||||
item: MediaFile | TextElement | DragData;
|
||||
}): boolean {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getTracks(): TimelineTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getTracksWithMain(): TimelineTrack[] {
|
||||
return this._tracks;
|
||||
}
|
||||
|
||||
getSelectedElements(): { trackId: string; elementId: string }[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getDragState(): typeof this.dragState {
|
||||
return this.dragState;
|
||||
}
|
||||
|
||||
getSnappingEnabled(): boolean {
|
||||
return this.snappingEnabled;
|
||||
}
|
||||
|
||||
getRippleEditingEnabled(): boolean {
|
||||
return this.rippleEditingEnabled;
|
||||
return this.sortedTracks;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
|
|
@ -474,9 +331,8 @@ export class TimelineManager {
|
|||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
private updateTracks(newTracks: TimelineTrack[]): void {
|
||||
this._tracks = newTracks;
|
||||
this.tracks = newTracks; // TODO: Implement proper sorting with main track
|
||||
updateTracks(newTracks: TimelineTrack[]): void {
|
||||
this.sortedTracks = newTracks;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import type {
|
||||
ElementDragState,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
|
||||
export function useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
onSnapPointChange,
|
||||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const {
|
||||
isSelected,
|
||||
select,
|
||||
handleElementClick: handleSelectionClick,
|
||||
} = useElementSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<ElementDragState>(initialDragState);
|
||||
const lastMouseXRef = useRef(0);
|
||||
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
}: Omit<ElementDragState, "isDragging" | "currentTime">) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
currentTime: startElementTime,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
// Mouse move: update drag time
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
lastMouseXRef.current = e.clientX;
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const alreadySelected = isSelected({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
select({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseTime = Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
|
||||
setDragState((prev) => ({ ...prev, currentTime: snappedTime }));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.elementId,
|
||||
dragState.trackId,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
select,
|
||||
editor.project,
|
||||
timelineRef,
|
||||
]);
|
||||
|
||||
// Mouse up: resolve drop
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||
if (!containerRect) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(el) => el.id === dragState.elementId,
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const elementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const mouseX = e.clientX - containerRect.left;
|
||||
const mouseY = e.clientY - containerRect.top;
|
||||
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: dragState.currentTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const fps = editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({ time: dropTarget.xPosition, fps });
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
const newTrackId = editor.timeline.addTrack({
|
||||
type: sourceTrack!.type,
|
||||
index: dropTarget.trackIndex,
|
||||
});
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: newTrackId,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
});
|
||||
} else {
|
||||
const targetTrack = tracks[dropTarget.trackIndex];
|
||||
if (targetTrack) {
|
||||
editor.timeline.moveElement({
|
||||
sourceTrackId: dragState.trackId,
|
||||
targetTrackId: targetTrack.id,
|
||||
elementId: dragState.elementId,
|
||||
newStartTime: snappedTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.elementId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.project,
|
||||
editor.timeline,
|
||||
tracksContainerRef,
|
||||
]);
|
||||
|
||||
const handleElementMouseDown = useCallback(
|
||||
({
|
||||
e,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
mouseDownLocationRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
const isRightClick = e.button === 2;
|
||||
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
|
||||
// right-click: select if not already selected
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: isMultiSelect,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multi-select: toggle selection
|
||||
if (isMultiSelect) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: true,
|
||||
});
|
||||
}
|
||||
|
||||
// start drag
|
||||
const elementRect = (
|
||||
e.currentTarget as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - elementRect.left;
|
||||
const clickOffsetTime =
|
||||
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
startDrag({
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
startMouseX: e.clientX,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime,
|
||||
});
|
||||
},
|
||||
[zoomLevel, startDrag, isSelected, handleSelectionClick],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
e,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// was it a drag or a click?
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// modifier keys already handled in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
|
||||
// single click: select if not selected
|
||||
const alreadySelected = isSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
select({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
},
|
||||
[isSelected, select],
|
||||
);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { UpdateElementStartTimeCommand } from "@/lib/commands/timeline/element/update-element-start-time";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
initialStartTime: number;
|
||||
initialDuration: number;
|
||||
}
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
|
||||
const [currentTrimEnd, setCurrentTrimEnd] = useState(element.trimEnd);
|
||||
const [currentStartTime, setCurrentStartTime] = useState(element.startTime);
|
||||
const [currentDuration, setCurrentDuration] = useState(element.duration);
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
const mediaFiles = editor.media.getMediaFiles();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
|
||||
const handleResizeStart = ({
|
||||
e,
|
||||
elementId,
|
||||
side,
|
||||
}: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
initialStartTime: element.startTime,
|
||||
initialDuration: element.duration,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(element.trimStart);
|
||||
setCurrentTrimEnd(element.trimEnd);
|
||||
setCurrentStartTime(element.startTime);
|
||||
setCurrentDuration(element.duration);
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
if (element.type === "text") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(file: MediaFile) => file.id === element.mediaId,
|
||||
);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = ({ clientX }: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const maxAllowed =
|
||||
resizing.initialDuration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
} else {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = resizing.initialStartTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
setCurrentDuration(newDuration);
|
||||
} else {
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
if (canExtendElementDuration()) {
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0;
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(newTrimEnd);
|
||||
} else {
|
||||
setCurrentTrimEnd(0);
|
||||
}
|
||||
} else {
|
||||
const currentEndTime =
|
||||
resizing.initialStartTime +
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
resizing.initialTrimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
resizing.initialDuration -
|
||||
resizing.initialTrimStart -
|
||||
(snappedEndTime - resizing.initialStartTime),
|
||||
);
|
||||
|
||||
const maxTrimEnd =
|
||||
resizing.initialDuration - resizing.initialTrimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
if (!resizing) return;
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const trimStartChanged = currentTrimStart !== resizing.initialTrimStart;
|
||||
const trimEndChanged = currentTrimEnd !== resizing.initialTrimEnd;
|
||||
const startTimeChanged = currentStartTime !== resizing.initialStartTime;
|
||||
const durationChanged = currentDuration !== resizing.initialDuration;
|
||||
|
||||
if (trimStartChanged || trimEndChanged) {
|
||||
const trimCommand = new UpdateElementTrimCommand(
|
||||
track.id,
|
||||
element.id,
|
||||
currentTrimStart,
|
||||
currentTrimEnd,
|
||||
);
|
||||
editor.command.execute({ command: trimCommand });
|
||||
}
|
||||
|
||||
if (startTimeChanged) {
|
||||
const startTimeCommand = new UpdateElementStartTimeCommand(
|
||||
[{ trackId: track.id, elementId: element.id }],
|
||||
currentStartTime,
|
||||
);
|
||||
editor.command.execute({ command: startTimeCommand });
|
||||
}
|
||||
|
||||
if (durationChanged) {
|
||||
const durationCommand = new UpdateElementDurationCommand(
|
||||
track.id,
|
||||
element.id,
|
||||
currentDuration,
|
||||
);
|
||||
editor.command.execute({ command: durationCommand });
|
||||
}
|
||||
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
currentTrimStart,
|
||||
currentTrimEnd,
|
||||
currentStartTime,
|
||||
currentDuration,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,409 +1,403 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useState, useCallback, type RefObject } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { toast } from "sonner";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getAssetDragData, hasAssetDragData } from "@/lib/asset-drag";
|
||||
import type { TrackType, DropTarget } from "@/types/timeline";
|
||||
import type { MediaAssetDragData } from "@/types/assets";
|
||||
|
||||
type DragElementType = "video" | "image" | "audio" | "text";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
track?: TimelineTrack;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
isSnappingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
track,
|
||||
containerRef,
|
||||
zoomLevel,
|
||||
isSnappingEnabled = true,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const [dropPositionIndicator, setDropPositionIndicator] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setDragElementType] =
|
||||
useState<DragElementType | null>(null);
|
||||
|
||||
const { mediaFiles, addMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const {
|
||||
tracks,
|
||||
addElementToTrack,
|
||||
insertTrackAt,
|
||||
addTrack,
|
||||
snappingEnabled,
|
||||
} = useTimelineStore();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const mediaFiles = editor.media.mediaFiles;
|
||||
const activeProject = editor.project.activeProject;
|
||||
|
||||
const dragCounterRef = useRef(0);
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: number }) => {
|
||||
const projectFps = activeProject?.fps ?? DEFAULT_FPS;
|
||||
return snapTimeToFrame({ time, fps: projectFps });
|
||||
},
|
||||
[activeProject?.fps],
|
||||
);
|
||||
|
||||
const { snapElementEdge } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
});
|
||||
const getDragElementType = useCallback(
|
||||
({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): DragElementType | null => {
|
||||
const dragData = getAssetDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
|
||||
const getDropSnappedTime = useCallback(
|
||||
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
if (snappingEnabled) {
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true,
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false,
|
||||
);
|
||||
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
}
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType as DragElementType;
|
||||
}
|
||||
|
||||
return finalTime;
|
||||
return null;
|
||||
},
|
||||
[
|
||||
activeProject?.fps,
|
||||
snappingEnabled,
|
||||
snapElementEdge,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
|
||||
if (!hasMediaItem && !hasFiles) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: DragElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (elementType === "text") {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const media = mediaFiles.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
},
|
||||
[isDragOver],
|
||||
[mediaFiles],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!hasMediaItem) return;
|
||||
|
||||
if (track) {
|
||||
const trackContainer =
|
||||
(e.currentTarget as HTMLElement).closest(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement).querySelector(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement);
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const dropTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
|
||||
let overlap = false;
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
const duration =
|
||||
dragData.type === "text"
|
||||
? 5
|
||||
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
|
||||
const snappedTime = getDropSnappedTime(dropTime, duration);
|
||||
const endTime = snappedTime + duration;
|
||||
let elementType = getDragElementType({ dataTransfer: e.dataTransfer });
|
||||
|
||||
overlap = track.elements.some((el) => {
|
||||
const elEnd =
|
||||
el.startTime + (el.duration - el.trimStart - el.trimEnd);
|
||||
return snappedTime < elEnd && endTime > el.startTime;
|
||||
});
|
||||
}
|
||||
} catch (f) {}
|
||||
|
||||
setWouldOverlap(overlap);
|
||||
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
|
||||
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
|
||||
// external drops default to video until determined on drop
|
||||
if (!elementType && hasFiles) {
|
||||
elementType = "video";
|
||||
}
|
||||
|
||||
if (!elementType) return;
|
||||
|
||||
setDragElementType(elementType);
|
||||
|
||||
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
|
||||
setDropTarget(target);
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[track, zoomLevel, mediaFiles, getDropSnappedTime],
|
||||
[
|
||||
containerRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
getDragElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current <= 0) {
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPositionIndicator(null);
|
||||
}
|
||||
}, []);
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientX, clientY } = e;
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setDragElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const executeTextDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
id: "",
|
||||
name: dragData.name ?? "",
|
||||
type: "text",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.addElementToTrack({ trackId, element });
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: MediaAssetDragData;
|
||||
}) => {
|
||||
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
|
||||
if (!mediaItem) return;
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "media";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaItem.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
if (dragData.mediaType === "audio") {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "audio",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (dragData.mediaType === "video") {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[editor.timeline, mediaFiles, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({ files }: { files: File[] }) => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const processedItems = await processMediaFiles({ files });
|
||||
|
||||
for (const item of processedItems) {
|
||||
await editor.media.addMediaFile({
|
||||
projectId: activeProject.id,
|
||||
file: item,
|
||||
});
|
||||
|
||||
const added = editor.media.mediaFiles.find(
|
||||
(m) => m.name === item.name && m.url === item.url,
|
||||
);
|
||||
|
||||
if (added) {
|
||||
const trackType: TrackType =
|
||||
added.type === "audio" ? "audio" : "media";
|
||||
const trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const duration =
|
||||
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
if (added.type === "audio") {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "audio",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
muted: false,
|
||||
},
|
||||
});
|
||||
} else if (added.type === "video") {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "video",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId,
|
||||
element: {
|
||||
type: "image",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeProject, editor.media, editor.timeline, currentTime],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPositionIndicator(null);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setDragElementType(null);
|
||||
|
||||
if (!currentTarget) return;
|
||||
|
||||
const hasAsset = hasAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasMediaItem && !hasFiles) return;
|
||||
|
||||
const trackContainer =
|
||||
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
|
||||
(e.currentTarget as HTMLElement).querySelector(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement);
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const dropTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
let dropPos: "above" | "on" | "below" = "on";
|
||||
if (track) {
|
||||
if (mouseY < 20) dropPos = "above";
|
||||
else if (mouseY > 40) dropPos = "below";
|
||||
}
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
try {
|
||||
if (hasMediaItem) {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
if (hasAsset) {
|
||||
const dragData = getAssetDragData({ dataTransfer: e.dataTransfer });
|
||||
if (!dragData) return;
|
||||
|
||||
if (dragData.type === "text") {
|
||||
let targetTrackId = track?.id;
|
||||
let targetTrack = track;
|
||||
|
||||
if (!track || track.type !== "text" || dropPos !== "on") {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex = 0;
|
||||
if (track) {
|
||||
const currentIdx = tracks.findIndex((t) => t.id === track.id);
|
||||
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
|
||||
} else if (mainTrack) {
|
||||
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
|
||||
}
|
||||
targetTrackId = insertTrackAt("text", insertIndex);
|
||||
targetTrack = useTimelineStore
|
||||
.getState()
|
||||
.tracks.find((t) => t.id === targetTrackId);
|
||||
}
|
||||
|
||||
if (!targetTrack || !targetTrackId) return;
|
||||
const duration = 5;
|
||||
const finalStart = getDropSnappedTime(dropTime, duration);
|
||||
const finalEnd = finalStart + duration;
|
||||
|
||||
if (
|
||||
targetTrack.elements.some(
|
||||
(el) =>
|
||||
finalStart <
|
||||
el.startTime + el.duration - el.trimStart - el.trimEnd &&
|
||||
finalEnd > el.startTime,
|
||||
)
|
||||
) {
|
||||
toast.error("Cannot place element here - overlap detected");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
|
||||
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
|
||||
startTime: finalStart,
|
||||
});
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else {
|
||||
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
|
||||
if (!mediaItem) return;
|
||||
|
||||
let targetTrackId = track?.id;
|
||||
const isVideoOrImage =
|
||||
dragData.type === "video" || dragData.type === "image";
|
||||
const isAudio = dragData.type === "audio";
|
||||
const isCompatible = track
|
||||
? isVideoOrImage
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: isAudio
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: false
|
||||
: false;
|
||||
|
||||
let targetTrack = track;
|
||||
|
||||
if (!track || !isCompatible || dropPos !== "on") {
|
||||
if (isVideoOrImage) {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack) {
|
||||
targetTrackId = addTrack("media");
|
||||
} else if (
|
||||
mainTrack.elements.length === 0 &&
|
||||
(!track || dropPos === "on")
|
||||
) {
|
||||
targetTrackId = mainTrack.id;
|
||||
} else {
|
||||
let idx = track
|
||||
? tracks.findIndex((t) => t.id === track.id)
|
||||
: 0;
|
||||
if (track) idx = dropPos === "above" ? idx : idx + 1;
|
||||
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
|
||||
targetTrackId = insertTrackAt("media", idx);
|
||||
}
|
||||
} else if (isAudio) {
|
||||
let idx = track
|
||||
? tracks.findIndex((t) => t.id === track.id)
|
||||
: tracks.length;
|
||||
if (track) idx = dropPos === "above" ? idx : idx + 1;
|
||||
targetTrackId = insertTrackAt("audio", idx);
|
||||
}
|
||||
targetTrack = useTimelineStore
|
||||
.getState()
|
||||
.tracks.find((t) => t.id === targetTrackId);
|
||||
}
|
||||
|
||||
if (!targetTrack || !targetTrackId) return;
|
||||
const duration = mediaItem.duration || 5;
|
||||
const finalStart = getDropSnappedTime(dropTime, duration);
|
||||
const finalEnd = finalStart + duration;
|
||||
|
||||
if (
|
||||
targetTrack.elements.some(
|
||||
(el) =>
|
||||
finalStart <
|
||||
el.startTime + el.duration - el.trimStart - el.trimEnd &&
|
||||
finalEnd > el.startTime,
|
||||
)
|
||||
) {
|
||||
toast.error("Cannot place element here - overlap detected");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration,
|
||||
startTime: finalStart,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
if (!activeProject) return;
|
||||
const processedItems = await processMediaFiles({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
const added = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find(
|
||||
(m) => m.name === item.name && m.url === item.url,
|
||||
);
|
||||
if (added) {
|
||||
const type: TrackType =
|
||||
added.type === "audio" ? "audio" : "media";
|
||||
const tid = insertTrackAt(type, 0);
|
||||
addElementToTrack(tid, {
|
||||
type: "media",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration: added.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
await executeFileDrop({ files: Array.from(e.dataTransfer.files) });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
console.error("Failed to process drop:", err);
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[
|
||||
track,
|
||||
zoomLevel,
|
||||
activeProject,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
currentTime,
|
||||
getDropSnappedTime,
|
||||
addElementToTrack,
|
||||
insertTrackAt,
|
||||
addTrack,
|
||||
addMediaFile,
|
||||
],
|
||||
[dropTarget, executeTextDrop, executeMediaDrop, executeFileDrop],
|
||||
);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
wouldOverlap,
|
||||
dropPositionIndicator,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
|
|
|
|||
|
|
@ -1,261 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
updateElementStartTime,
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
pushHistory,
|
||||
} = useTimelineStore();
|
||||
|
||||
// Set up document-level mouse listeners during resize (like proper drag behavior)
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
// Add document-level listeners for proper drag behavior
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]); // Re-run when resizing state changes
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
side: "left" | "right",
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Push history once at the start of the resize operation
|
||||
pushHistory();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
// Text elements can always be extended
|
||||
if (element.type === "text") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media elements - check the media type
|
||||
if (element.type === "media") {
|
||||
const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaFile) return false;
|
||||
|
||||
// Images can be extended (static content)
|
||||
if (mediaFile.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Videos and audio cannot be extended beyond their natural duration
|
||||
// (no additional content exists)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
// Reasonable sensitivity for resize operations - similar to timeline scale
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
// Get project FPS for frame snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - different behavior for media vs text/image elements
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
|
||||
if (calculated >= 0) {
|
||||
// Normal trimming within available content
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(maxAllowed, calculated),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Trying to extend beyond trimStart = 0
|
||||
if (canExtendElementDuration()) {
|
||||
// Text/Image: extend element to the left by moving startTime and increasing duration
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = element.startTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Keep trimStart at 0 and extend the element
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
0,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: element.startTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false,
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Right resize - can extend duration for supported element types
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
// We're trying to extend beyond original duration
|
||||
if (canExtendElementDuration()) {
|
||||
// Extend the duration instead of reducing trimEnd further
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: element.duration + extensionNeeded,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Can't extend - just set trimEnd to 0 (maximum possible extension)
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
// Calculate the desired end time based on mouse movement
|
||||
const currentEndTime =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
// Snap the desired end time to frame
|
||||
const snappedEndTime = snapTimeToFrame({
|
||||
time: desiredEndTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Calculate what trimEnd should be to achieve this snapped end time
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
(snappedEndTime - element.startTime),
|
||||
);
|
||||
|
||||
// Ensure we don't trim more than available content (leave at least 0.1s visible)
|
||||
const maxTrimEnd = element.duration - element.trimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
element.trimStart,
|
||||
finalTrimEnd,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
// Return empty handlers since we use document listeners now
|
||||
handleResizeMove: () => {}, // Not used anymore
|
||||
handleResizeEnd: () => {}, // Not used anymore
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { useEffect, useMemo, useReducer } from "react";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export function useEditor(): EditorCore {
|
||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribers = [
|
||||
editor.playback.subscribe(forceUpdate),
|
||||
editor.timeline.subscribe(forceUpdate),
|
||||
editor.scene.subscribe(forceUpdate),
|
||||
editor.project.subscribe(forceUpdate),
|
||||
editor.media.subscribe(forceUpdate),
|
||||
editor.renderer.subscribe(forceUpdate),
|
||||
];
|
||||
|
||||
return () => unsubscribers.forEach((unsub) => unsub());
|
||||
}, [editor]);
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { useCallback } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
type ElementRef = { trackId: string; elementId: string };
|
||||
|
||||
export function useElementSelection() {
|
||||
const selectedElements = useTimelineStore((s) => s.selectedElements);
|
||||
const setSelectedElements = useTimelineStore((s) => s.setSelectedElements);
|
||||
|
||||
const isSelected = useCallback(
|
||||
({ trackId, elementId }: ElementRef) =>
|
||||
selectedElements.some(
|
||||
(el) => el.trackId === trackId && el.elementId === elementId,
|
||||
),
|
||||
[selectedElements],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
setSelectedElements({ elements: [{ trackId, elementId }] });
|
||||
},
|
||||
[setSelectedElements],
|
||||
);
|
||||
|
||||
const addToSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
const alreadySelected = selectedElements.some(
|
||||
(el) => el.trackId === trackId && el.elementId === elementId,
|
||||
);
|
||||
if (alreadySelected) return;
|
||||
|
||||
setSelectedElements({
|
||||
elements: [...selectedElements, { trackId, elementId }],
|
||||
});
|
||||
},
|
||||
[selectedElements, setSelectedElements],
|
||||
);
|
||||
|
||||
const removeFromSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
setSelectedElements({
|
||||
elements: selectedElements.filter(
|
||||
(el) => !(el.trackId === trackId && el.elementId === elementId),
|
||||
),
|
||||
});
|
||||
},
|
||||
[selectedElements, setSelectedElements],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
const alreadySelected = selectedElements.some(
|
||||
(el) => el.trackId === trackId && el.elementId === elementId,
|
||||
);
|
||||
|
||||
if (alreadySelected) {
|
||||
removeFromSelection({ trackId, elementId });
|
||||
} else {
|
||||
addToSelection({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[selectedElements, addToSelection, removeFromSelection],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedElements({ elements: [] });
|
||||
}, [setSelectedElements]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(elements: ElementRef[]) => {
|
||||
setSelectedElements({ elements });
|
||||
},
|
||||
[setSelectedElements],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles click interaction on an element.
|
||||
* - Regular click: select only this element
|
||||
* - Multi-key click (Ctrl/Cmd): toggle this element in selection
|
||||
*/
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
trackId,
|
||||
elementId,
|
||||
isMultiKey,
|
||||
}: ElementRef & { isMultiKey: boolean }) => {
|
||||
if (isMultiKey) {
|
||||
toggleSelection({ trackId, elementId });
|
||||
} else {
|
||||
select({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[toggleSelection, select],
|
||||
);
|
||||
|
||||
/**
|
||||
* Ensures element is selected without toggling.
|
||||
* Used for drag operations where we want to select if not already.
|
||||
*/
|
||||
const ensureSelected = useCallback(
|
||||
({ trackId, elementId }: ElementRef) => {
|
||||
if (!isSelected({ trackId, elementId })) {
|
||||
select({ trackId, elementId });
|
||||
}
|
||||
},
|
||||
[isSelected, select],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedElements,
|
||||
isSelected,
|
||||
select,
|
||||
setSelection,
|
||||
addToSelection,
|
||||
removeFromSelection,
|
||||
toggleSelection,
|
||||
clearSelection,
|
||||
handleElementClick,
|
||||
ensureSelected,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { hasAssetDragData } from "@/lib/asset-drag";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
|
|
@ -7,20 +8,23 @@ interface UseFileUploadOptions {
|
|||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
const isInternalDrag = dataTransfer.types.includes("application/x-media-item");
|
||||
const hasFiles = dataTransfer.types.includes("Files");
|
||||
|
||||
return !isInternalDrag && hasFiles;
|
||||
return (
|
||||
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
|
||||
);
|
||||
}
|
||||
|
||||
export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUploadOptions = {}) {
|
||||
export function useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
onFilesSelected,
|
||||
}: UseFileUploadOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
|
||||
inputRef.current.accept = accept || "*";
|
||||
inputRef.current.multiple = multiple || false;
|
||||
inputRef.current.click();
|
||||
|
|
@ -31,7 +35,7 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
|
|||
if (files && files.length > 0 && onFilesSelected) {
|
||||
onFilesSelected(files);
|
||||
}
|
||||
|
||||
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
|
|
@ -39,24 +43,24 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
|
|||
|
||||
function handleDragEnter(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
|
|
@ -67,11 +71,11 @@ export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUplo
|
|||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
|
||||
if (onFilesSelected && containsFiles(e.dataTransfer)) {
|
||||
const files = e.dataTransfer.files;
|
||||
const shouldUseMultiple = multiple ?? false;
|
||||
|
||||
|
||||
if (shouldUseMultiple) {
|
||||
onFilesSelected(files);
|
||||
} else if (files.length > 0) {
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { AssetDragData } from "@/types/assets";
|
||||
|
||||
const MIME_TYPE = "application/x-asset-drag";
|
||||
|
||||
export function setAssetDragData({
|
||||
dataTransfer,
|
||||
dragData,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
dragData: AssetDragData;
|
||||
}): void {
|
||||
dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
export function getAssetDragData({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): AssetDragData | null {
|
||||
const data = dataTransfer.getData(MIME_TYPE);
|
||||
return data ? (JSON.parse(data) as AssetDragData) : null;
|
||||
}
|
||||
|
||||
export function hasAssetDragData({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): boolean {
|
||||
return dataTransfer.types.includes(MIME_TYPE);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export abstract class Command {
|
||||
abstract execute(): void;
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Undo not implemented for this command");
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.execute();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Command } from "./base-command";
|
||||
export * from "./timeline";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { PasteCommand } from "./paste";
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private pastedElementIds: string[] = [];
|
||||
|
||||
constructor(private time: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
this.pastedElementIds = [];
|
||||
|
||||
const clipboard = (editor.timeline as any).getClipboard?.();
|
||||
if (!clipboard || clipboard.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minStart = Math.min(
|
||||
...clipboard.items.map((x: any) => x.element.startTime),
|
||||
);
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToAdd: TimelineElement[] = [];
|
||||
|
||||
for (const item of clipboard.items) {
|
||||
if (item.trackType !== track.type) continue;
|
||||
|
||||
const relativeOffset = item.element.startTime - minStart;
|
||||
const startTime = Math.max(0, this.time + relativeOffset);
|
||||
const newElementId = generateUUID();
|
||||
|
||||
this.pastedElementIds.push(newElementId);
|
||||
|
||||
const pastedElement: TimelineElement = {
|
||||
...item.element,
|
||||
id: newElementId,
|
||||
startTime,
|
||||
} as TimelineElement;
|
||||
|
||||
elementsToAdd.push(pastedElement);
|
||||
}
|
||||
|
||||
return elementsToAdd.length > 0
|
||||
? { ...track, elements: [...track.elements, ...elementsToAdd] }
|
||||
: track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
} from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { validateElementTrackCompatibility } from "@/lib/timeline/track-utils";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
|
||||
export class AddElementToTrackCommand extends Command {
|
||||
private elementId: string;
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private element: CreateTimelineElement,
|
||||
) {
|
||||
super();
|
||||
this.elementId = generateUUID();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
const track = this.savedState.find((t) => t.id === this.trackId);
|
||||
|
||||
if (!track) {
|
||||
console.error("Track not found:", this.trackId);
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element: this.element,
|
||||
track,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.element.type !== "text" && !this.element.mediaId) {
|
||||
console.error("Media element must have mediaId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.element.type === "text" && !this.element.content) {
|
||||
console.error("Text element must have content");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalElementsInTimeline = this.savedState.reduce(
|
||||
(total, t) => total + t.elements.length,
|
||||
0,
|
||||
);
|
||||
const isFirstElement = totalElementsInTimeline === 0;
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
...this.element,
|
||||
id: this.elementId,
|
||||
startTime: this.element.startTime,
|
||||
trimStart: this.element.trimStart ?? 0,
|
||||
trimEnd: this.element.trimEnd ?? 0,
|
||||
} as TimelineElement;
|
||||
|
||||
const isVisualMedia =
|
||||
newElement.type === "video" || newElement.type === "image";
|
||||
|
||||
if (isFirstElement && isVisualMedia) {
|
||||
const mediaFiles = editor.media.getMediaFiles();
|
||||
const mediaItem = mediaFiles.find(
|
||||
(item: MediaFile) => item.id === newElement.mediaId,
|
||||
);
|
||||
|
||||
if (mediaItem?.width && mediaItem?.height) {
|
||||
editor.project.updateCanvasSize({
|
||||
size: {
|
||||
width: mediaItem.width,
|
||||
height: mediaItem.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaItem?.type === "video" && mediaItem?.fps) {
|
||||
const activeProject = editor.project.getActive();
|
||||
if (activeProject) {
|
||||
editor.project.updateProjectFps({ fps: mediaItem.fps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTracks = this.savedState.map((t) =>
|
||||
t.id === this.trackId
|
||||
? { ...t, elements: [...t.elements, newElement] }
|
||||
: t,
|
||||
) as TimelineTrack[];
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getElementId(): string {
|
||||
return this.elementId;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState
|
||||
.map((track) => {
|
||||
const hasElementsToDelete = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToDelete) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter(
|
||||
(element) =>
|
||||
!this.elements.some(
|
||||
(el) => el.trackId === track.id && el.elementId === element.id,
|
||||
),
|
||||
),
|
||||
} as typeof track;
|
||||
})
|
||||
.filter(
|
||||
(track) =>
|
||||
track.elements.length > 0 || (track.type === "media" && track.isMain),
|
||||
);
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}
|
||||
|
||||
export class DuplicateElementsCommand extends Command {
|
||||
private duplicatedIds: string[] = [];
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private elements: DuplicateElementsParams["elements"];
|
||||
|
||||
constructor({ elements }: DuplicateElementsParams) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
this.duplicatedIds = [];
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToDuplicate = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToDuplicate.length === 0) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const newElements = track.elements.flatMap((element) => {
|
||||
const shouldDuplicate = elementsToDuplicate.some(
|
||||
(el) => el.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!shouldDuplicate) {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const newId = generateUUID();
|
||||
this.duplicatedIds.push(newId);
|
||||
|
||||
return [
|
||||
element,
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
name: `${element.name} (copy)`,
|
||||
startTime: element.startTime + effectiveDuration + 0.1,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export { AddElementToTrackCommand } from "./add-element";
|
||||
export { DeleteElementsCommand } from "./delete-elements";
|
||||
export { DuplicateElementsCommand } from "./duplicate-elements";
|
||||
export { UpdateElementTrimCommand } from "./update-element-trim";
|
||||
export { UpdateElementDurationCommand } from "./update-element-duration";
|
||||
export { UpdateElementStartTimeCommand } from "./update-element-start-time";
|
||||
export { SplitElementsCommand } from "./split-elements";
|
||||
export { UpdateTextElementCommand } from "./update-text-element";
|
||||
export { ToggleElementsHiddenCommand } from "./toggle-elements-hidden";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-element";
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
||||
import { validateElementTrackCompatibility } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private sourceTrackId: string,
|
||||
private targetTrackId: string,
|
||||
private elementId: string,
|
||||
private newStartTime: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const sourceTrack = this.savedState.find((t) => t.id === this.sourceTrackId);
|
||||
const element = sourceTrack?.elements.find((el) => el.id === this.elementId);
|
||||
|
||||
if (!sourceTrack || !element) {
|
||||
console.error("Source track or element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
|
||||
if (!targetTrack) {
|
||||
console.error("Target track not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element,
|
||||
track: targetTrack,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const movedElement: TimelineElement = {
|
||||
...element,
|
||||
startTime: this.newStartTime,
|
||||
};
|
||||
|
||||
const isSameTrack = this.sourceTrackId === this.targetTrackId;
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
if (isSameTrack && track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((el) =>
|
||||
el.id === this.elementId ? movedElement : el,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (track.id === this.sourceTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter((el) => el.id !== this.elementId),
|
||||
};
|
||||
}
|
||||
|
||||
if (track.id === this.targetTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: [...track.elements, movedElement],
|
||||
};
|
||||
}
|
||||
|
||||
return track;
|
||||
}) as TimelineTrack[];
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private newElementIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private splitTime: number,
|
||||
private retainSide: "both" | "left" | "right" = "both",
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
this.newElementIds = [];
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const elementsToSplit = this.elements.filter(
|
||||
(el) => el.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToSplit.length === 0) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(el) => el.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!shouldSplit) {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (
|
||||
this.splitTime <= effectiveStart ||
|
||||
this.splitTime >= effectiveEnd
|
||||
) {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const firstDuration = relativeTime;
|
||||
const secondDuration =
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd -
|
||||
relativeTime;
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
trimEnd: element.trimEnd + secondDuration,
|
||||
name: `${element.name} (left)`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.retainSide === "right") {
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
id: generateUUID(),
|
||||
startTime: this.splitTime,
|
||||
trimStart: element.trimStart + firstDuration,
|
||||
name: `${element.name} (right)`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.newElementIds.push(secondElementId);
|
||||
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
trimEnd: element.trimEnd + secondDuration,
|
||||
name: `${element.name} (left)`,
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
trimStart: element.trimStart + firstDuration,
|
||||
name: `${element.name} (right)`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
} as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { canBeHidden } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class ToggleElementsHiddenCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const shouldHide = this.elements.some(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && canBeHidden(element) && !element.hidden;
|
||||
});
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = this.elements.some(
|
||||
({ trackId, elementId }) =>
|
||||
track.id === trackId && element.id === elementId,
|
||||
);
|
||||
return shouldUpdate &&
|
||||
canBeHidden(element) &&
|
||||
element.hidden !== shouldHide
|
||||
? { ...element, hidden: shouldHide }
|
||||
: element;
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { isMutableElement } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class ToggleElementsMutedCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(private elements: { trackId: string; elementId: string }[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const mutableElements = this.elements.filter(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && isMutableElement(element);
|
||||
});
|
||||
|
||||
if (mutableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldMute = mutableElements.some(({ trackId, elementId }) => {
|
||||
const track = this.savedState?.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
return element && isMutableElement(element) && !element.muted;
|
||||
});
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = mutableElements.some(
|
||||
({ trackId, elementId }) =>
|
||||
track.id === trackId && element.id === elementId,
|
||||
);
|
||||
return shouldUpdate &&
|
||||
isMutableElement(element) &&
|
||||
element.muted !== shouldMute
|
||||
? { ...element, muted: shouldMute }
|
||||
: element;
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class UpdateElementDurationCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private duration: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId ? { ...el, duration: this.duration } : el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private elements: { trackId: string; elementId: string }[],
|
||||
private startTime: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((track) => {
|
||||
const hasElementsToUpdate = this.elements.some(
|
||||
(el) => el.trackId === track.id,
|
||||
);
|
||||
|
||||
if (!hasElementsToUpdate) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const newElements = track.elements.map((element) => {
|
||||
const shouldUpdate = this.elements.some(
|
||||
(el) => el.elementId === element.id && el.trackId === track.id,
|
||||
);
|
||||
return shouldUpdate
|
||||
? { ...element, startTime: Math.max(0, this.startTime) }
|
||||
: element;
|
||||
});
|
||||
return { ...track, elements: newElements } as typeof track;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private trimStart: number,
|
||||
private trimEnd: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId
|
||||
? { ...el, trimStart: this.trimStart, trimEnd: this.trimEnd }
|
||||
: el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TextElement, TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class UpdateTextElementCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private trackId: string,
|
||||
private elementId: string,
|
||||
private updates: Partial<
|
||||
Pick<
|
||||
TextElement,
|
||||
| "content"
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "color"
|
||||
| "backgroundColor"
|
||||
| "textAlign"
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) => {
|
||||
if (t.id !== this.trackId) return t;
|
||||
const newElements = t.elements.map((el) =>
|
||||
el.id === this.elementId && el.type === "text"
|
||||
? { ...el, ...this.updates }
|
||||
: el,
|
||||
);
|
||||
return { ...t, elements: newElements } as typeof t;
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./track";
|
||||
export * from "./element";
|
||||
export * from "./clipboard";
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TrackType, TimelineTrack } from "@/types/timeline";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class AddTrackCommand extends Command {
|
||||
private trackId: string;
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private type: TrackType,
|
||||
private index?: number,
|
||||
) {
|
||||
super();
|
||||
this.trackId = generateUUID();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const trackName =
|
||||
this.type === "media"
|
||||
? "Media Track"
|
||||
: this.type === "text"
|
||||
? "Text Track"
|
||||
: this.type === "audio"
|
||||
? "Audio Track"
|
||||
: "Track";
|
||||
|
||||
const newTrack: TimelineTrack = {
|
||||
id: this.trackId,
|
||||
name: trackName,
|
||||
type: this.type,
|
||||
elements: [],
|
||||
muted: false,
|
||||
};
|
||||
|
||||
let updatedTracks: TimelineTrack[];
|
||||
if (this.index !== undefined) {
|
||||
updatedTracks = [...(this.savedState || [])];
|
||||
updatedTracks.splice(this.index, 0, newTrack);
|
||||
} else {
|
||||
updatedTracks = [...(this.savedState || []), newTrack];
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getTrackId(): string {
|
||||
return this.trackId;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { AddTrackCommand } from "./add-track";
|
||||
export { RemoveTrackCommand } from "./remove-track";
|
||||
export { ToggleTrackMuteCommand } from "./toggle-track-mute";
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(private trackId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
const updatedTracks = this.savedState.filter(
|
||||
(track) => track.id !== this.trackId,
|
||||
);
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class ToggleTrackMuteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(private trackId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = this.savedState.map((t) =>
|
||||
t.id === this.trackId ? { ...t, muted: !t.muted } : t,
|
||||
);
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { SceneExporter } from "../services/renderer/scene-exporter";
|
||||
import { buildScene } from "../services/renderer/scene-builder";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorCore } from "@/core";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
|
||||
import { ExportOptions, ExportResult } from "@/types/export";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { TimelineTrack, type AudioElement } from "@/types/timeline";
|
||||
import { MediaFile } from "@/types/assets";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||
format: "mp4",
|
||||
|
|
@ -14,45 +18,39 @@ export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
|||
includeAudio: true,
|
||||
};
|
||||
|
||||
interface AudioElement {
|
||||
buffer: AudioBuffer;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
muted: boolean;
|
||||
}
|
||||
async function collectAudioElements({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
}): Promise<
|
||||
Omit<AudioElement, "type" | "mediaId" | "volume" | "id" | "name">[]
|
||||
> {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextConstructor();
|
||||
|
||||
async function createTimelineAudioBuffer(
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
duration: number,
|
||||
sampleRate: number = 44100,
|
||||
): Promise<AudioBuffer | null> {
|
||||
// Get Web Audio context
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
|
||||
// Collect all audio elements from timeline
|
||||
const audioElements: AudioElement[] = [];
|
||||
const audioElements: Omit<
|
||||
AudioElement,
|
||||
"type" | "mediaId" | "volume" | "id" | "name"
|
||||
>[] = [];
|
||||
const mediaMap = new Map<string, MediaFile>(mediaFiles.map((m) => [m.id, m]));
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (element.type !== "media") continue;
|
||||
if (element.type !== "audio") continue;
|
||||
|
||||
const mediaElement = element;
|
||||
const mediaItem = mediaMap.get(mediaElement.mediaId);
|
||||
const mediaItem = mediaMap.get(element.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") continue;
|
||||
|
||||
const visibleDuration =
|
||||
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
if (visibleDuration <= 0) continue;
|
||||
|
||||
try {
|
||||
// Decode audio file
|
||||
const arrayBuffer = await mediaItem.file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
|
|
@ -60,11 +58,11 @@ async function createTimelineAudioBuffer(
|
|||
|
||||
audioElements.push({
|
||||
buffer: audioBuffer,
|
||||
startTime: mediaElement.startTime,
|
||||
duration: mediaElement.duration,
|
||||
trimStart: mediaElement.trimStart,
|
||||
trimEnd: mediaElement.trimEnd,
|
||||
muted: mediaElement.muted || track.muted || false,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
muted: element.muted || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||
|
|
@ -72,12 +70,74 @@ async function createTimelineAudioBuffer(
|
|||
}
|
||||
}
|
||||
|
||||
if (audioElements.length === 0) {
|
||||
return null; // No audio to mix
|
||||
}
|
||||
return audioElements;
|
||||
}
|
||||
|
||||
// Create output buffer
|
||||
const outputChannels = 2; // Stereo
|
||||
function mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
}: {
|
||||
element: Omit<AudioElement, "type" | "mediaId" | "volume" | "id" | "name">;
|
||||
outputBuffer: AudioBuffer;
|
||||
outputLength: number;
|
||||
sampleRate: number;
|
||||
}): void {
|
||||
const {
|
||||
buffer,
|
||||
startTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
duration: elementDuration,
|
||||
} = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceDuration = elementDuration - trimStart - trimEnd;
|
||||
const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
const outputChannels = 2;
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext || window.webkitAudioContext;
|
||||
const audioContext = new AudioContextConstructor();
|
||||
|
||||
const audioElements = await collectAudioElements({ tracks, mediaFiles });
|
||||
|
||||
if (audioElements.length === 0) return null;
|
||||
|
||||
const outputChannels = 2;
|
||||
const outputLength = Math.ceil(duration * sampleRate);
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
outputChannels,
|
||||
|
|
@ -85,102 +145,71 @@ async function createTimelineAudioBuffer(
|
|||
sampleRate,
|
||||
);
|
||||
|
||||
// Mix all audio elements
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
const {
|
||||
buffer,
|
||||
startTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
duration: elementDuration,
|
||||
} = element;
|
||||
|
||||
// Calculate timing
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceDuration = elementDuration - trimStart - trimEnd;
|
||||
const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
// Resample if needed (simple approach)
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
// Mix each channel
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
// Simple resampling (could be improved with proper interpolation)
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
mixAudioChannels({
|
||||
element,
|
||||
outputBuffer,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
export async function exportProject(
|
||||
options: ExportOptions,
|
||||
): Promise<ExportResult> {
|
||||
const { format, quality, fps, includeAudio, onProgress, onCancel } = options;
|
||||
|
||||
export async function exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps,
|
||||
includeAudio,
|
||||
onProgress,
|
||||
onCancel,
|
||||
}: ExportOptions): Promise<ExportResult> {
|
||||
try {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
|
||||
const { tracks, getTotalDuration } = timelineStore;
|
||||
const { mediaFiles } = mediaStore;
|
||||
const { activeProject } = projectStore;
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.activeProject;
|
||||
|
||||
if (!activeProject) {
|
||||
return { success: false, error: "No active project" };
|
||||
}
|
||||
|
||||
const duration = getTotalDuration();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
if (duration === 0) {
|
||||
return { success: false, error: "Project is empty" };
|
||||
}
|
||||
|
||||
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
|
||||
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
|
||||
|
||||
// Create audio buffer if needed
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const mediaFiles = editor.media.getMediaFiles();
|
||||
|
||||
let audioBuffer: AudioBuffer | null = null;
|
||||
if (includeAudio) {
|
||||
onProgress?.(0.05); // 5% for audio processing
|
||||
audioBuffer = await createTimelineAudioBuffer(
|
||||
onProgress?.(0.05);
|
||||
audioBuffer = await createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Build the scene using the new node system
|
||||
const backgroundColor =
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000";
|
||||
|
||||
const scene = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
canvasSize,
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundColor,
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
});
|
||||
|
||||
// Create the exporter
|
||||
const exporter = new SceneExporter({
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
|
|
@ -191,17 +220,15 @@ export async function exportProject(
|
|||
audioBuffer: audioBuffer || undefined,
|
||||
});
|
||||
|
||||
// Set up progress tracking
|
||||
exporter.on("progress", (progress) => {
|
||||
const adjustedProgress = includeAudio ? 0.05 + progress * 0.95 : progress;
|
||||
onProgress?.(adjustedProgress);
|
||||
});
|
||||
|
||||
// Set up cancellation
|
||||
let cancelled = false;
|
||||
let isCancelled = false;
|
||||
const checkCancel = () => {
|
||||
if (onCancel?.()) {
|
||||
cancelled = true;
|
||||
isCancelled = true;
|
||||
exporter.cancel();
|
||||
}
|
||||
};
|
||||
|
|
@ -212,7 +239,7 @@ export async function exportProject(
|
|||
const buffer = await exporter.export(scene);
|
||||
clearInterval(cancelInterval);
|
||||
|
||||
if (cancelled) {
|
||||
if (isCancelled) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
|
|
@ -236,10 +263,18 @@ export async function exportProject(
|
|||
}
|
||||
}
|
||||
|
||||
export function getExportMimeType(format: "mp4" | "webm"): string {
|
||||
export function getExportMimeType({
|
||||
format,
|
||||
}: {
|
||||
format: "mp4" | "webm";
|
||||
}): string {
|
||||
return format === "webm" ? "video/webm" : "video/mp4";
|
||||
}
|
||||
|
||||
export function getExportFileExtension(format: "mp4" | "webm"): string {
|
||||
export function getExportFileExtension({
|
||||
format,
|
||||
}: {
|
||||
format: "mp4" | "webm";
|
||||
}): string {
|
||||
return `.${format}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
getMediaDuration,
|
||||
getImageDimensions,
|
||||
} from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { MediaFile } from "@/types/assets";
|
||||
import { getVideoInfo } from "./mediabunny-utils";
|
||||
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
|
||||
|
||||
|
|
@ -69,20 +69,8 @@ export async function generateThumbnail({
|
|||
}
|
||||
|
||||
frame.draw(ctx, 0, 0, width, height);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(URL.createObjectURL(blob));
|
||||
} else {
|
||||
reject(new Error("Failed to create thumbnail blob"));
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.8,
|
||||
);
|
||||
});
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
export async function processMediaFiles({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TProject } from "@/types/project";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { MediaFile } from "@/types/assets";
|
||||
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
||||
import { OPFSAdapter } from "./opfs-adapter";
|
||||
import {
|
||||
|
|
@ -180,6 +180,7 @@ class StorageService {
|
|||
width: mediaItem.width,
|
||||
height: mediaItem.height,
|
||||
duration: mediaItem.duration,
|
||||
thumbnailUrl: mediaItem.thumbnailUrl,
|
||||
ephemeral: mediaItem.ephemeral,
|
||||
};
|
||||
|
||||
|
|
@ -229,6 +230,7 @@ class StorageService {
|
|||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
thumbnailUrl: metadata.thumbnailUrl,
|
||||
ephemeral: metadata.ephemeral,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface MediaFileData {
|
|||
height?: number;
|
||||
duration?: number;
|
||||
ephemeral?: boolean;
|
||||
thumbnailUrl?: string;
|
||||
sourceStickerIconName?: string;
|
||||
// File will be stored separately in OPFS
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.elements.reduce((maxEnd, element) => {
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
return Math.max(maxEnd, elementEnd);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
||||
import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import { wouldElementOverlap } from "./element-utils";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
tracks,
|
||||
}: {
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
}): { trackIndex: number; relativeY: number } | null {
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const trackHeight = TRACK_HEIGHTS[tracks[i].type];
|
||||
const trackTop = cumulativeHeight;
|
||||
const trackBottom = trackTop + trackHeight;
|
||||
|
||||
if (mouseY >= trackTop && mouseY < trackBottom) {
|
||||
return {
|
||||
trackIndex: i,
|
||||
relativeY: mouseY - trackTop,
|
||||
};
|
||||
}
|
||||
|
||||
cumulativeHeight += trackHeight + TRACK_GAP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCompatible({
|
||||
elementType,
|
||||
trackType,
|
||||
}: {
|
||||
elementType: TimelineElement["type"];
|
||||
trackType: TimelineTrack["type"];
|
||||
}): boolean {
|
||||
if (elementType === "text") return trackType === "text";
|
||||
if (elementType === "audio") return trackType === "audio";
|
||||
if (elementType === "video" || elementType === "image") {
|
||||
return trackType === "media";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
|
||||
return tracks.findIndex((t) => t.type === "media" && t.isMain);
|
||||
}
|
||||
|
||||
function findInsertIndex({
|
||||
elementType,
|
||||
tracks,
|
||||
preferredIndex,
|
||||
insertAbove,
|
||||
}: {
|
||||
elementType: TimelineElement["type"];
|
||||
tracks: TimelineTrack[];
|
||||
preferredIndex: number;
|
||||
insertAbove: boolean;
|
||||
}): { index: number; position: "above" | "below" } {
|
||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
||||
|
||||
if (elementType === "audio") {
|
||||
if (preferredIndex <= mainTrackIndex) {
|
||||
return { index: mainTrackIndex + 1, position: "below" };
|
||||
}
|
||||
return {
|
||||
index: insertAbove ? preferredIndex : preferredIndex + 1,
|
||||
position: insertAbove ? "above" : "below",
|
||||
};
|
||||
}
|
||||
|
||||
if (preferredIndex > mainTrackIndex && mainTrackIndex >= 0) {
|
||||
return { index: mainTrackIndex, position: "above" };
|
||||
}
|
||||
|
||||
return {
|
||||
index: insertAbove ? preferredIndex : preferredIndex + 1,
|
||||
position: insertAbove ? "above" : "below",
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime,
|
||||
isExternalDrop,
|
||||
elementDuration,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
}: ComputeDropTargetParams): DropTarget {
|
||||
const xPosition = isExternalDrop
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
||||
|
||||
if (tracks.length === 0) {
|
||||
if (elementType === "audio") {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: "below",
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
|
||||
}
|
||||
|
||||
const trackAtMouse = getTrackAtY({ mouseY, tracks });
|
||||
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
if (elementType === "audio") {
|
||||
return {
|
||||
trackIndex: tracks.length,
|
||||
isNewTrack: true,
|
||||
insertPosition: "below",
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
|
||||
if (isAboveAllTracks) {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: "above",
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: Math.max(0, mainTrackIndex),
|
||||
isNewTrack: true,
|
||||
insertPosition: "above",
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
|
||||
const { trackIndex, relativeY } = trackAtMouse;
|
||||
const track = tracks[trackIndex];
|
||||
const trackHeight = TRACK_HEIGHTS[track.type];
|
||||
const isInUpperHalf = relativeY < trackHeight / 2;
|
||||
|
||||
const isTrackCompatible = isCompatible({
|
||||
elementType,
|
||||
trackType: track.type,
|
||||
});
|
||||
|
||||
const endTime = xPosition + elementDuration;
|
||||
const hasOverlap = wouldElementOverlap({
|
||||
elements: track.elements as TimelineElement[],
|
||||
startTime: xPosition,
|
||||
endTime,
|
||||
});
|
||||
|
||||
if (isTrackCompatible && !hasOverlap) {
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
|
||||
const { index, position } = findInsertIndex({
|
||||
elementType,
|
||||
tracks,
|
||||
preferredIndex: trackIndex,
|
||||
insertAbove: isInUpperHalf,
|
||||
});
|
||||
|
||||
return {
|
||||
trackIndex: index,
|
||||
isNewTrack: true,
|
||||
insertPosition: position,
|
||||
xPosition,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDropLineY({
|
||||
dropTarget,
|
||||
tracks,
|
||||
}: {
|
||||
dropTarget: DropTarget;
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < dropTarget.trackIndex && i < tracks.length; i++) {
|
||||
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
|
||||
}
|
||||
|
||||
if (!dropTarget.isNewTrack && dropTarget.trackIndex < tracks.length) {
|
||||
return y;
|
||||
}
|
||||
|
||||
if (
|
||||
dropTarget.insertPosition === "below" &&
|
||||
dropTarget.trackIndex <= tracks.length
|
||||
) {
|
||||
if (dropTarget.trackIndex > 0 && dropTarget.trackIndex <= tracks.length) {
|
||||
y += TRACK_HEIGHTS[tracks[dropTarget.trackIndex - 1]?.type ?? "media"];
|
||||
}
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
|
|
@ -1,4 +1,26 @@
|
|||
import { TimelineElement } from "@/types/timeline";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
CreateTimelineElement,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
AudioElement,
|
||||
VideoElement,
|
||||
ImageElement,
|
||||
} from "@/types/timeline";
|
||||
import type { AssetDragData } from "@/types/assets";
|
||||
|
||||
export function isMutableElement(
|
||||
element: TimelineElement,
|
||||
): element is AudioElement | VideoElement {
|
||||
return element.type === "audio" || element.type === "video";
|
||||
}
|
||||
|
||||
export function canBeHidden(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement | ImageElement | TextElement {
|
||||
return element.type !== "audio";
|
||||
}
|
||||
|
||||
export function checkElementOverlaps({
|
||||
elements,
|
||||
|
|
@ -52,3 +74,52 @@ export function resolveElementOverlaps({
|
|||
|
||||
return resolvedElements;
|
||||
}
|
||||
|
||||
export function wouldElementOverlap({
|
||||
elements,
|
||||
startTime,
|
||||
endTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
elements: TimelineElement[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
excludeElementId?: string;
|
||||
}): boolean {
|
||||
return elements.some((el) => {
|
||||
if (excludeElementId && el.id === excludeElementId) return false;
|
||||
const elEnd = el.startTime + el.duration - el.trimStart - el.trimEnd;
|
||||
return startTime < elEnd && endTime > el.startTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTextElement({
|
||||
raw,
|
||||
startTime,
|
||||
}: {
|
||||
raw: TextElement | AssetDragData;
|
||||
startTime: number;
|
||||
}): CreateTimelineElement {
|
||||
const t = raw as Partial<TextElement>;
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
|
||||
content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
|
||||
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize:
|
||||
typeof t.fontSize === "number"
|
||||
? t.fontSize
|
||||
: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
|
||||
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
|
||||
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
|
||||
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
|
||||
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
|
||||
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
|
||||
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,25 @@
|
|||
import { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export * from "./track-utils";
|
||||
export * from "./element-utils";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.elements.reduce((maxEnd, element) => {
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
return Math.max(maxEnd, elementEnd);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import type { TrackType, TimelineTrack } from "@/types/timeline";
|
||||
import { TRACK_COLORS, TRACK_HEIGHTS } from "@/constants/timeline-constants";
|
||||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
TRACK_COLORS,
|
||||
TRACK_HEIGHTS,
|
||||
TRACK_GAP,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export function getTrackColors({ type }: { type: TrackType }) {
|
||||
export function getTrackColor({ type }: { type: TrackType }) {
|
||||
return TRACK_COLORS[type];
|
||||
}
|
||||
|
||||
export function getTrackElementClasses({ type }: { type: TrackType }) {
|
||||
const colors = getTrackColors({ type });
|
||||
return `${colors.background} ${colors.border}`;
|
||||
}
|
||||
|
||||
export function getTrackHeight({ type }: { type: TrackType }): number {
|
||||
return TRACK_HEIGHTS[type];
|
||||
}
|
||||
|
|
@ -22,11 +25,10 @@ export function getCumulativeHeightBefore({
|
|||
tracks: Array<{ type: TrackType }>;
|
||||
trackIndex: number;
|
||||
}): number {
|
||||
const GAP = 4;
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + GAP,
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
|
@ -36,42 +38,20 @@ export function getTotalTracksHeight({
|
|||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
}): number {
|
||||
const GAP = 4;
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
||||
0,
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * GAP;
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
|
||||
export function sortTracksByOrder({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack[] {
|
||||
return [...tracks].sort((a, b) => {
|
||||
if (a.type === "text" && b.type !== "text") return -1;
|
||||
if (b.type === "text" && a.type !== "text") return 1;
|
||||
|
||||
if (a.type === "audio" && b.type !== "audio") return 1;
|
||||
if (b.type === "audio" && a.type !== "audio") return -1;
|
||||
|
||||
if (a.type === "media" && b.type === "media") {
|
||||
if (a.isMain && !b.isMain) return 1;
|
||||
if (!a.isMain && b.isMain) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function getMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack | null {
|
||||
return tracks.find((track) => track.isMain) || null;
|
||||
return tracks.find((track) => track.type === "media" && track.isMain) ?? null;
|
||||
}
|
||||
|
||||
export function ensureMainTrack({
|
||||
|
|
@ -79,7 +59,9 @@ export function ensureMainTrack({
|
|||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack[] {
|
||||
const hasMainTrack = tracks.some((track) => track.isMain);
|
||||
const hasMainTrack = tracks.some(
|
||||
(track) => track.type === "media" && track.isMain,
|
||||
);
|
||||
|
||||
if (!hasMainTrack) {
|
||||
const mainTrack: TimelineTrack = {
|
||||
|
|
@ -100,14 +82,13 @@ export function canElementGoOnTrack({
|
|||
elementType,
|
||||
trackType,
|
||||
}: {
|
||||
elementType: "text" | "media";
|
||||
elementType: TimelineElement["type"];
|
||||
trackType: TrackType;
|
||||
}): boolean {
|
||||
if (elementType === "text") {
|
||||
return trackType === "text";
|
||||
}
|
||||
if (elementType === "media") {
|
||||
return trackType === "media" || trackType === "audio";
|
||||
if (elementType === "text") return trackType === "text";
|
||||
if (elementType === "audio") return trackType === "audio";
|
||||
if (elementType === "video" || elementType === "image") {
|
||||
return trackType === "media";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -116,7 +97,7 @@ export function validateElementTrackCompatibility({
|
|||
element,
|
||||
track,
|
||||
}: {
|
||||
element: { type: "text" | "media" };
|
||||
element: { type: TimelineElement["type"] };
|
||||
track: { type: TrackType };
|
||||
}): { isValid: boolean; errorMessage?: string } {
|
||||
const isValid = canElementGoOnTrack({
|
||||
|
|
@ -125,12 +106,10 @@ export function validateElementTrackCompatibility({
|
|||
});
|
||||
|
||||
if (!isValid) {
|
||||
const errorMessage =
|
||||
element.type === "text"
|
||||
? "Text elements can only be placed on text tracks"
|
||||
: "Media elements can only be placed on media or audio tracks";
|
||||
|
||||
return { isValid: false, errorMessage };
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type TimelineTrack } from "@/types/timeline";
|
||||
import { type MediaFile } from "@/types/media";
|
||||
import { type MediaFile } from "@/types/assets";
|
||||
import { RootNode } from "./nodes/root-node";
|
||||
import { VideoNode } from "./nodes/video-node";
|
||||
import { ImageNode } from "./nodes/image-node";
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
interface TPlaybackState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
speed: number;
|
||||
muted: boolean;
|
||||
previousVolume?: number;
|
||||
}
|
||||
|
||||
interface TPlaybackControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggle: () => void;
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
interface TPlaybackStore extends TPlaybackState, TPlaybackControls {
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
}
|
||||
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: () => TPlaybackStore) => {
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
if (state.isPlaying && state.currentTime < state.duration) {
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
|
||||
// Get actual content duration from timeline store
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
|
||||
// Stop at actual content end, not timeline duration
|
||||
// It was either this or reducing default min timeline to 1 second
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (newTime >= effectiveDuration) {
|
||||
// When content completes, pause just before the end so we can see the last frame
|
||||
const projectFps = useProjectStore.getState().activeProject?.fps;
|
||||
if (!projectFps)
|
||||
console.error(
|
||||
"Project FPS is not set, assuming " + DEFAULT_FPS + "fps",
|
||||
);
|
||||
|
||||
const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS
|
||||
const stopTime = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
state.pause();
|
||||
state.setCurrentTime(stopTime);
|
||||
// Notify video elements to sync with end position
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
// Notify video elements to sync
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", { detail: { time: newTime } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
let lastUpdate = performance.now();
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (playbackTimer) {
|
||||
cancelAnimationFrame(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlaybackStore = create<TPlaybackStore>((set, get) => ({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
previousVolume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (effectiveDuration > 0) {
|
||||
const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
if (state.currentTime >= endThreshold) {
|
||||
get().seek(0);
|
||||
}
|
||||
}
|
||||
|
||||
set({ isPlaying: true });
|
||||
startTimer(get);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
set({ isPlaying: false });
|
||||
stopTimer();
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const { isPlaying } = get();
|
||||
if (isPlaying) {
|
||||
get().pause();
|
||||
} else {
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
seek: (time: number) => {
|
||||
const { duration } = get();
|
||||
const clampedTime = Math.max(0, Math.min(duration, time));
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
const event = new CustomEvent("playback-seek", {
|
||||
detail: { time: clampedTime },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) =>
|
||||
set((state) => ({
|
||||
volume: Math.max(0, Math.min(1, volume)),
|
||||
muted: volume === 0,
|
||||
previousVolume: volume > 0 ? volume : state.previousVolume,
|
||||
})),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent("playback-speed", {
|
||||
detail: { speed: newSpeed },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
|
||||
mute: () => {
|
||||
const { volume, previousVolume } = get();
|
||||
set({
|
||||
muted: true,
|
||||
previousVolume: volume > 0 ? volume : previousVolume,
|
||||
volume: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unmute: () => {
|
||||
const { previousVolume } = get();
|
||||
set({ muted: false, volume: previousVolume ?? 1 });
|
||||
},
|
||||
|
||||
toggleMute: () => {
|
||||
const { muted } = get();
|
||||
if (muted) {
|
||||
get().unmute();
|
||||
} else {
|
||||
get().mute();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -274,7 +274,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
|||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` }
|
||||
{ id: `sound-${sound.id}` },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useMediaStore } from "@/stores/media-store";
|
|||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
|
||||
export type StickerCategory = "all" | "general" | "brands" | "emoji";
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
|
|||
url: URL.createObjectURL(file),
|
||||
width: 200,
|
||||
height: 200,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
|
|||
const added = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find(
|
||||
(m) => m.url === mediaItem.url && m.name === mediaItem.name
|
||||
(m) => m.url === mediaItem.url && m.name === mediaItem.name,
|
||||
);
|
||||
if (!added) {
|
||||
throw new Error("Sticker not in media store");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
export interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: MediaType;
|
||||
file: File;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
|
||||
// only used in timeline, never shown or saved in media library
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
|
||||
interface BaseAssetDragData {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MediaAssetDragData extends BaseAssetDragData {
|
||||
type: "media";
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export interface TextAssetDragData extends BaseAssetDragData {
|
||||
type: "text";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type AssetDragData = MediaAssetDragData | TextAssetDragData;
|
||||
|
|
@ -3,18 +3,4 @@ export interface TCanvasSize {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export interface TTextElementDragState {
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
trackId: string | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
initialElementX: number;
|
||||
initialElementY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
elementWidth: number;
|
||||
elementHeight: number;
|
||||
}
|
||||
|
||||
export type TPlatformLayout = "tiktok";
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
// What's stored in media library
|
||||
export interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: MediaType;
|
||||
file: File;
|
||||
url?: string; // Object URL for preview
|
||||
thumbnailUrl?: string; // For video thumbnails
|
||||
duration?: number; // For video/audio duration
|
||||
width?: number; // For video/image width
|
||||
height?: number; // For video/image height
|
||||
fps?: number; // For video frame rate
|
||||
// Ephemeral items are used by timeline directly and should not appear in the media library or be persisted
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
|
|
@ -1,8 +1,29 @@
|
|||
import { MediaType } from "@/types/media";
|
||||
|
||||
export type TrackType = "media" | "text" | "audio";
|
||||
|
||||
// Base element properties
|
||||
interface BaseTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaTrack extends BaseTrack {
|
||||
type: "media";
|
||||
elements: (VideoElement | ImageElement)[];
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
||||
export interface TextTrack extends BaseTrack {
|
||||
type: "text";
|
||||
elements: TextElement[];
|
||||
}
|
||||
|
||||
export interface AudioTrack extends BaseTrack {
|
||||
type: "audio";
|
||||
elements: AudioElement[];
|
||||
}
|
||||
|
||||
export type TimelineTrack = MediaTrack | TextTrack | AudioTrack;
|
||||
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -10,17 +31,29 @@ interface BaseTimelineElement {
|
|||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export interface AudioElement extends BaseTimelineElement {
|
||||
type: "audio";
|
||||
mediaId: string;
|
||||
volume: number;
|
||||
muted?: boolean;
|
||||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
type: "video";
|
||||
mediaId: string;
|
||||
muted?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// Media element that references MediaStore
|
||||
export interface MediaElement extends BaseTimelineElement {
|
||||
type: "media";
|
||||
export interface ImageElement extends BaseTimelineElement {
|
||||
type: "image";
|
||||
mediaId: string;
|
||||
muted?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// Text element with embedded text data
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
|
|
@ -32,58 +65,52 @@ export interface TextElement extends BaseTimelineElement {
|
|||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline" | "line-through";
|
||||
x: number; // Position relative to canvas center
|
||||
y: number; // Position relative to canvas center
|
||||
rotation: number; // in degrees
|
||||
opacity: number; // 0-1
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// Typed timeline elements
|
||||
export type TimelineElement = MediaElement | TextElement;
|
||||
export type TimelineElement =
|
||||
| AudioElement
|
||||
| VideoElement
|
||||
| ImageElement
|
||||
| TextElement;
|
||||
|
||||
// Creation types (without id, for addElementToTrack)
|
||||
export type CreateMediaElement = Omit<MediaElement, "id">;
|
||||
export type CreateAudioElement = Omit<AudioElement, "id">;
|
||||
export type CreateVideoElement = Omit<VideoElement, "id">;
|
||||
export type CreateImageElement = Omit<ImageElement, "id">;
|
||||
export type CreateTextElement = Omit<TextElement, "id">;
|
||||
export type CreateTimelineElement = CreateMediaElement | CreateTextElement;
|
||||
export type CreateTimelineElement =
|
||||
| CreateAudioElement
|
||||
| CreateVideoElement
|
||||
| CreateImageElement
|
||||
| CreateTextElement;
|
||||
|
||||
export interface TimelineElementProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
// ---- Drag State ----
|
||||
|
||||
export interface ElementDragState {
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export interface DropTarget {
|
||||
trackIndex: number;
|
||||
isNewTrack: boolean;
|
||||
insertPosition: "above" | "below" | null;
|
||||
xPosition: number;
|
||||
}
|
||||
|
||||
export interface ComputeDropTargetParams {
|
||||
elementType: TimelineElement["type"];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
playheadTime: number;
|
||||
isExternalDrop: boolean;
|
||||
elementDuration: number;
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
}
|
||||
|
||||
// Drag data types for type-safe drag and drop
|
||||
export interface MediaItemDragData {
|
||||
id: string;
|
||||
type: MediaType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TextItemDragData {
|
||||
id: string;
|
||||
type: "text";
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type DragData = MediaItemDragData | TextItemDragData;
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TrackType;
|
||||
elements: TimelineElement[];
|
||||
muted?: boolean;
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,6 @@
|
|||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
"src/types/**/*.d.ts"
|
||||
, "../../use-frame-cache.ts" ],
|
||||
, "../../use-frame-cache.ts", "src/stores/timeline-store.ts" ],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
5
bun.lock
5
bun.lock
|
|
@ -18,7 +18,6 @@
|
|||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
|
|
@ -92,7 +91,6 @@
|
|||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
|
|
@ -177,7 +175,6 @@
|
|||
},
|
||||
},
|
||||
"packages/hooks": {
|
||||
"name": "@opencut/hooks",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
|
|
@ -394,8 +391,6 @@
|
|||
|
||||
"@opencut/env": ["@opencut/env@workspace:packages/env"],
|
||||
|
||||
"@opencut/hooks": ["@opencut/hooks@workspace:packages/hooks"],
|
||||
|
||||
"@opencut/tools": ["@opencut/tools@workspace:apps/tools"],
|
||||
|
||||
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
## Detailed Chat Summary
|
||||
|
||||
### Phase 1: Audit & Analysis
|
||||
Started with a 1800-line Zustand timeline store doing EVERYTHING (core logic + UI state + history). Performed comprehensive audit identifying:
|
||||
- ~40 methods mixing core logic with UI orchestration
|
||||
- Inconsistent parameter patterns (some destructured, some not)
|
||||
- Redundant methods (`updateElementStartTime` vs `updateElementStartTimeWithRipple`)
|
||||
- Three separate split operations that should be unified
|
||||
- Delete operations conflating UI state with core logic
|
||||
- Methods referencing `selectedElements` (UI state) that shouldn't exist in core
|
||||
|
||||
### Phase 2: Architecture Decision
|
||||
Decided on Command Pattern + separation of concerns:
|
||||
- **Core Logic** → TimelineManager + Commands
|
||||
- **UI State** → Zustand store
|
||||
- **Ephemeral Interaction** → Component state (dragState)
|
||||
- **History Tracking** → HistoryManager
|
||||
|
||||
### Phase 3: HistoryManager Creation
|
||||
Created generic `HistoryManager<T>`:
|
||||
- `execute(command)` returns the command (enables getting return values)
|
||||
- Maintains `history` and `redoStack` arrays of Command objects
|
||||
- `undo()` pops from history, calls `command.undo()`
|
||||
- `redo()` pops from redoStack, calls `command.redo()`
|
||||
- Methods: `canUndo()`, `canRedo()`, `clear()`
|
||||
|
||||
### Phase 4: Command Architecture
|
||||
Created base `Command` abstract class in `src/lib/commands/base-command.ts`:
|
||||
- Abstract `execute()` method (must implement)
|
||||
- `undo()` throws by default (implement if reversible)
|
||||
- `redo()` defaults to calling `execute()` again
|
||||
|
||||
Built 15 command classes organized in folders:
|
||||
- **Track operations** (3): `AddTrackCommand`, `RemoveTrackCommand`, `ToggleTrackMuteCommand`
|
||||
- **Element CRUD** (5): `AddElementToTrackCommand`, `DeleteElementsCommand`, `DuplicateElementsCommand`, `UpdateElementTrimCommand`, `UpdateElementDurationCommand`
|
||||
- **Element properties** (4): `UpdateTextElementCommand`, `ToggleElementsHiddenCommand`, `ToggleElementsMutedCommand`, `SplitElementsCommand`
|
||||
- **Unified position** (1): `UpdateElementStartTimeCommand` (replaced 3 old methods)
|
||||
- **Clipboard** (1): `PasteCommand`
|
||||
|
||||
Each command:
|
||||
- Takes minimal parameters in constructor
|
||||
- Saves state before execution for undo
|
||||
- Implements `execute()` and `undo()`
|
||||
- Has getters for return values (e.g., `getTrackId()`)
|
||||
- Uses `EditorCore.getInstance()` to access timeline manager
|
||||
|
||||
### Phase 5: AddTrackCommand Details
|
||||
Updated `AddTrackCommand` to support optional `index` parameter (unified `addTrack` and `insertTrackAt`):
|
||||
- Constructor: `new AddTrackCommand(type, index?)`
|
||||
- Splices into array at index if provided, appends if not
|
||||
- Returns trackId via `getTrackId()` getter
|
||||
- Saves full state for undo
|
||||
|
||||
### Phase 6: TimelineManager Refactor - Part 1
|
||||
Removed ALL UI state fields:
|
||||
- ❌ `clipboard` → UI state
|
||||
- ❌ `snappingEnabled` → UI preference
|
||||
- ❌ `rippleEditingEnabled` → UI preference
|
||||
- ❌ `selectedElements` → UI selection
|
||||
- ❌ `dragState` → ephemeral interaction
|
||||
- ❌ `DragData` import (only used for UI state)
|
||||
|
||||
### Phase 7: TimelineManager Refactor - Part 2
|
||||
Updated method implementations to use commands:
|
||||
- `addTrack()` → `new AddTrackCommand(type, index).execute()`
|
||||
- `removeTrack()` → `new RemoveTrackCommand(trackId).execute()`
|
||||
- `updateElementTrim()` → creates command, conditionally pushes history
|
||||
- `updateElementDuration()` → creates command, conditionally pushes history
|
||||
- `updateElementStartTime()` → simplified (always through history)
|
||||
- `deleteElements()` → simplified (always through history)
|
||||
- `splitElements()` → `new SplitElementsCommand(elements, splitTime, retainSide).execute()`
|
||||
- `duplicateElements()`, `toggleElementsHidden()`, `toggleElementsMuted()` → via commands
|
||||
|
||||
### Phase 8: TimelineManager Cleanup
|
||||
Removed methods that referenced UI state:
|
||||
- ❌ `getSortedTracks()` (stub, redundant)
|
||||
- ❌ `toggleSnapping()` (UI preference)
|
||||
- ❌ `toggleRippleEditing()` (UI preference)
|
||||
- ❌ `selectElement()`, `deselectElement()`, etc. (UI selection)
|
||||
- ❌ `setDragState()`, `startDrag()`, `updateDragTime()`, `endDrag()` (UI interaction)
|
||||
- ❌ `copySelected()` (orchestrates UI state + core logic)
|
||||
- ❌ `deleteSelectedElements()` (uses UI state)
|
||||
- ❌ `splitSelected()` (uses UI state)
|
||||
- ❌ `toggleSelectedHidden()`, `toggleSelectedMuted()` (use UI state)
|
||||
- ❌ `revealElementInMedia()` (UI navigation)
|
||||
- ❌ `getContextMenuState()` (UI query)
|
||||
- ❌ `addElementAtTime()`, `addElementToNewTrack()` (convenience wrappers)
|
||||
|
||||
**Result:** TimelineManager reduced from 1800 → 315 lines, now pure core logic only
|
||||
|
||||
### Phase 9: Timeline Store Refactor
|
||||
Completely rewrote store (1800 → 104 lines) to contain ONLY UI state:
|
||||
```typescript
|
||||
interface TimelineStore {
|
||||
// Selection
|
||||
selectedElements: { trackId: string; elementId: string }[]
|
||||
selectElement()
|
||||
deselectElement()
|
||||
clearSelectedElements()
|
||||
setSelectedElements()
|
||||
|
||||
// Preferences
|
||||
snappingEnabled: boolean
|
||||
toggleSnapping()
|
||||
|
||||
rippleEditingEnabled: boolean
|
||||
toggleRippleEditing()
|
||||
|
||||
// Clipboard
|
||||
clipboard: { items: Array<...> } | null
|
||||
setClipboard()
|
||||
}
|
||||
```
|
||||
|
||||
Removed all methods that:
|
||||
- Operated on `_tracks`
|
||||
- Managed history
|
||||
- Used commands
|
||||
- Implemented core timeline logic
|
||||
|
||||
### Phase 10: Identified dragState Problem
|
||||
Analyzed `dragState` usage across three files:
|
||||
- **timeline-track.tsx** (producer): Sets/updates drag state on mouse events
|
||||
- **timeline-element.tsx** (consumer): Reads `dragState.elementId` and `dragState.currentTime` for visual feedback during drag
|
||||
- **index.tsx** (consumer): Reads `dragState.isDragging` to show snap indicator
|
||||
|
||||
**Problem:** In global Zustand store (wrong scope)
|
||||
- Only used during active drag operations
|
||||
- Ephemeral state (cleared after drag ends)
|
||||
- Causes unnecessary re-renders across entire app
|
||||
- Only needs to exist in Timeline component tree
|
||||
|
||||
**Solution:** Move to Timeline component state:
|
||||
- Keep `dragState` as local state in `Timeline` component
|
||||
- Pass down via props to `TimelineTrack` and `TimelineElement`
|
||||
- Timeline component manages `handleMouseDown`, `handleMouseMove`, `handleMouseUp` in TimelineTrack
|
||||
- This keeps scope correct and re-renders localized
|
||||
|
||||
### Phase 11: Architectural Alignment
|
||||
Confirmed final architecture:
|
||||
- **Editor Core** (singleton):
|
||||
- `history: HistoryManager` (manages undo/redo)
|
||||
- `timeline: TimelineManager` (pure data + commands)
|
||||
- Other managers (project, media, scene, playback)
|
||||
|
||||
- **Timeline Component** (React):
|
||||
- `dragState` (local state via useState)
|
||||
- Props down to children
|
||||
|
||||
- **Timeline Store** (Zustand):
|
||||
- Selection state + methods
|
||||
- Preferences (snapping, ripple editing)
|
||||
- Clipboard state
|
||||
|
||||
### Phase 12: Command Pattern Benefits Identified
|
||||
1. **Self-contained operations** - Each command knows how to undo itself
|
||||
2. **Discoverable API** - All timeline actions are in `/lib/commands/timeline/`
|
||||
3. **Type-safe** - Commands are strongly typed
|
||||
4. **Testable** - Each command can be unit tested independently
|
||||
5. **Scalable** - Adding new operations = creating new command class
|
||||
6. **Clean manager** - TimelineManager becomes orchestrator, not logic holder
|
||||
7. **Reusable** - Commands can be executed from anywhere (UI, scripts, etc.)
|
||||
|
||||
### Summary of Changes Made
|
||||
|
||||
**Files Created:**
|
||||
- `src/lib/commands/base-command.ts` (12 lines)
|
||||
- `src/lib/commands/index.ts` (3 lines)
|
||||
- `src/lib/commands/timeline/index.ts` (4 lines)
|
||||
- `src/lib/commands/timeline/track/` (3 commands)
|
||||
- `src/lib/commands/timeline/element/` (9 commands)
|
||||
- `src/lib/commands/timeline/clipboard/` (1 command)
|
||||
|
||||
**Files Modified:**
|
||||
- `src/core/managers/history-manager.ts` - Refactored to work with Commands, now returns command
|
||||
- `src/core/managers/timeline-manager.ts` - Removed UI state, implemented all methods as command-driven
|
||||
- `src/stores/timeline-store.ts` - From 1800 → 104 lines, now UI state only
|
||||
|
||||
**Files Deleted:**
|
||||
- Old history logic removed (consolidated into HistoryManager)
|
||||
- Removed unused imports from components (cascading changes needed)
|
||||
|
||||
### Remaining Work
|
||||
1. Move `dragState` from store to Timeline component
|
||||
2. Update all component imports and method calls
|
||||
3. Implement remaining stub methods (`checkElementOverlap`, `findOrCreateTrack`, etc.)
|
||||
4. Wire up UI to call new manager methods instead of store methods
|
||||
BIN
image1.png
BIN
image1.png
Binary file not shown.
|
Before Width: | Height: | Size: 900 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { MediaType, MediaFile } from "@/types/media";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
|
|
@ -12,7 +12,7 @@ interface MediaStore {
|
|||
// Actions
|
||||
addMediaFile: (
|
||||
projectId: string,
|
||||
file: Omit<MediaFile, "id">
|
||||
file: Omit<MediaFile, "id">,
|
||||
) => Promise<void>;
|
||||
removeMediaFile: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
|
|
@ -39,7 +39,7 @@ export const getFileType = (file: File): MediaType | null => {
|
|||
|
||||
// Helper function to get image dimensions
|
||||
export const getImageDimensions = (
|
||||
file: File
|
||||
file: File,
|
||||
): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
|
|
@ -60,56 +60,11 @@ export const getImageDimensions = (
|
|||
});
|
||||
};
|
||||
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Seek to 1 second or 10% of duration, whichever is smaller
|
||||
video.currentTime = Math.min(1, video.duration * 0.1);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
|
||||
resolve({ thumbnailUrl, width, height });
|
||||
|
||||
// Cleanup
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
reject(new Error("Could not load video"));
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get media duration
|
||||
export const getMediaDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const element = document.createElement(
|
||||
file.type.startsWith("video/") ? "video" : "audio"
|
||||
file.type.startsWith("video/") ? "video" : "audio",
|
||||
) as HTMLVideoElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
|
|
@ -213,33 +168,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
|||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
|
||||
// Regenerate thumbnails for video items
|
||||
const updatedMediaItems = await Promise.all(
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const { thumbnailUrl, width, height } =
|
||||
await generateVideoThumbnail(item.file);
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
width: width || item.width,
|
||||
height: height || item.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
set({ mediaFiles: updatedMediaItems });
|
||||
set({ mediaFiles: mediaItems });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
|
|
@ -267,7 +196,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
|||
try {
|
||||
const mediaIds = state.mediaFiles.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
|
|
@ -3,11 +3,15 @@ import { create } from "zustand";
|
|||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
|
||||
import { useSceneStore } from "./scene-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { TCanvasSize } from "@/types/editor";
|
||||
import { DEFAULT_BLUR_INTENSITY, DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import {
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_FPS,
|
||||
} from "@/constants/editor-constants";
|
||||
|
||||
export function createMainScene(): TScene {
|
||||
return {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { create } from "zustand";
|
||||
import { TScene } from "@/types/project";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
getActiveScene,
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "@opencut/hooks",
|
||||
"version": "0.0.0",
|
||||
"description": "Hooks package for OpenCut",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./use-file-upload": "./src/use-file-upload.ts",
|
||||
"./use-file-paste": "./src/use-file-paste.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./use-file-upload";
|
||||
export * from "./use-file-paste";
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
## Complete Refactor Plan
|
||||
|
||||
### ✅ COMPLETED
|
||||
|
||||
1. **Created Command Pattern Architecture**
|
||||
- Base `Command` class with `execute()`, `undo()`, `redo()`
|
||||
- 15 timeline commands in `src/lib/commands/timeline/`
|
||||
- All phases implemented (track ops, element ops, toggles, split, paste, etc.)
|
||||
|
||||
2. **Created HistoryManager**
|
||||
- Generic `HistoryManager<T>` for undo/redo tracking
|
||||
- Returns command after execution (for getting return values like IDs)
|
||||
- Completely decoupled from timeline logic
|
||||
|
||||
3. **Refactored TimelineManager**
|
||||
- Removed all UI state (`snappingEnabled`, `rippleEditingEnabled`, `selectedElements`, `dragState`, `clipboard`)
|
||||
- Removed UI-only methods
|
||||
- Now purely command-driven core logic
|
||||
- All operations go through commands + history
|
||||
- Only 315 lines (was 1800+)
|
||||
- Focus: data management, persistence, queries
|
||||
|
||||
4. **Refactored Timeline Store**
|
||||
- Now ONLY UI state (104 lines)
|
||||
- `selectedElements` + selection methods
|
||||
- `snappingEnabled` + toggle
|
||||
- `rippleEditingEnabled` + toggle
|
||||
- `clipboard` + setter
|
||||
- Removed all core logic
|
||||
|
||||
### 🔲 TODO - HIGH PRIORITY
|
||||
|
||||
1. **Move dragState from Store to Timeline Component**
|
||||
- Currently in Zustand (wrong place)
|
||||
- Move to `Timeline` component local state
|
||||
- Pass down via props to `TimelineTrack` and `TimelineElement`
|
||||
- Remove from timeline-store.ts
|
||||
- Eliminates unnecessary global state
|
||||
|
||||
2. **Update All Component Imports**
|
||||
- Remove `dragState`, `startDrag`, `updateDragTime`, `endDrag` from all components reading from store
|
||||
- Update to receive via props instead
|
||||
- Components affected:
|
||||
- `timeline-track.tsx` (producer)
|
||||
- `timeline-element.tsx` (consumer)
|
||||
- `index.tsx` (consumer - snap indicator)
|
||||
|
||||
3. **Update Store Action Calls**
|
||||
- Components calling store methods now need to call `editor.timeline.*` instead
|
||||
- Examples:
|
||||
- `updateElementStartTime()` → `editor.timeline.updateElementStartTime()`
|
||||
- `deleteSelected()` → `editor.timeline.deleteElements()`
|
||||
- `copySelected()` stays in store (UI orchestration)
|
||||
- `pasteAtTime()` → `editor.timeline.pasteAtTime()` after store manages clipboard
|
||||
|
||||
4. **Create useDragState Hook (Optional Later)**
|
||||
- Extract drag logic into custom hook
|
||||
- Would contain: drag state, handlers, listeners
|
||||
- Keep in Timeline for now (simpler), extract later if needed
|
||||
|
||||
### 🔲 TODO - MEDIUM PRIORITY
|
||||
|
||||
5. **Implement Remaining TimelineManager Methods**
|
||||
- `checkElementOverlap()` — utility logic
|
||||
- `findOrCreateTrack()` — utility
|
||||
- `loadProjectTimeline()` — persistence
|
||||
- `saveProjectTimeline()` — persistence
|
||||
- `clearTimeline()` — core operation
|
||||
- `getSortedTracks()` — already in manager but not implemented
|
||||
|
||||
6. **Wire Up Commands to UI**
|
||||
- Update all action calls to create commands + execute
|
||||
- E.g., when user clicks "add track":
|
||||
```typescript
|
||||
const trackId = editor.timeline.addTrack({ type: "media" });
|
||||
```
|
||||
- This internally creates `AddTrackCommand`, executes it through history
|
||||
|
||||
### 🔲 TODO - POLISH
|
||||
|
||||
7. **Remove Duplicate Methods**
|
||||
- `copySelected` exists in both store (UI) and old timeline-store (removed)
|
||||
- Store version should orchestrate: copy → call `editor.timeline.pasteAtTime()`
|
||||
|
||||
8. **Add Type Safety**
|
||||
- Ensure all commands properly typed
|
||||
- Ensure all manager methods properly typed
|
||||
- Fix any remaining linter errors
|
||||
|
||||
### Architecture Summary
|
||||
|
||||
```
|
||||
EditorCore (singleton)
|
||||
├── history: HistoryManager
|
||||
├── timeline: TimelineManager (pure logic)
|
||||
│ ├── _tracks (data)
|
||||
│ ├── Command execution
|
||||
│ └── Persistence
|
||||
├── project: ProjectManager
|
||||
├── media: MediaManager
|
||||
├── scene: SceneManager
|
||||
└── playback: PlaybackManager
|
||||
|
||||
Timeline Component (UI)
|
||||
├── dragState (local)
|
||||
├── TimelineTrackContent
|
||||
│ ├── dragState (via props)
|
||||
│ └── TimelineElement
|
||||
│ └── dragState (via props)
|
||||
|
||||
TimelineStore (Zustand - UI state)
|
||||
├── selectedElements
|
||||
├── snappingEnabled
|
||||
├── rippleEditingEnabled
|
||||
└── clipboard
|
||||
```
|
||||
|
||||
**Key Principle:** Core logic in managers + commands. UI state in store. Ephemeral interaction state in components (dragState).
|
||||
26
refactor.md
26
refactor.md
|
|
@ -12,8 +12,32 @@ Review every point below carefully for files to ensure they follow consistent co
|
|||
10. Use zustand correctly. React component should never use `someStore.getState()`. Instead, use the `useSomeStore` hook.
|
||||
11. Business logic is in `src/lib` folder. Example: zustand store has a method to remove a bookmark, the method should be a wrapper of a function in `src/lib/` that handles the actual logic.
|
||||
12. Booleans must be named like `isSomething` or `hasSomething` or `shouldSomething`. Not `something` or `somethingIs`.
|
||||
13. No text in the codebase ever uses title case. Example: `Hello World` is wrong. It should be `hello world`.
|
||||
13. No text in docs or UI ever uses title case. Example: `Hello World` is wrong. It should be `Hello world`.
|
||||
14. Use `size-10` instead of `h-10 w-10` when the width and height are the same.
|
||||
15. For components that need to subscribe to data from the editor api (`src/core`, `src/managers`), use the `useEditor` hook.
|
||||
16. In react components: store/manager methods should not be passed as props to sub-components. If a sub-component can access the same methods, it should do so. Example:
|
||||
```tsx
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
// ❌ Do NOT do this:
|
||||
function ParentComponent() {
|
||||
const { selectedElements } = useTimelineStore();
|
||||
|
||||
return <ChildComponent selectedElements={selectedElements} />;
|
||||
}
|
||||
|
||||
function ChildComponent({ selectedElements }) {}
|
||||
|
||||
// ✅ Do this:
|
||||
function ParentComponent() {
|
||||
return <ChildComponent />;
|
||||
}
|
||||
|
||||
function ChildComponent() {
|
||||
const { selectedElements } = useTimelineStore();
|
||||
}
|
||||
```
|
||||
17. Components render UI. Domain logic (data transformations, business rules, state mutations) lives in hooks, utilities, or managers. Simple interaction logic (gesture detection, modifier keys) can stay in components if not too many lines of code/complex.
|
||||
|
||||
# Functions
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue