mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
push
This commit is contained in:
+3
-1
@@ -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 tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||
const rulerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||
setCurrentSnapPoint(snapPoint);
|
||||
}, []);
|
||||
|
||||
// Timeline zoom functionality
|
||||
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
|
||||
containerRef: timelineRef,
|
||||
isInTimeline,
|
||||
});
|
||||
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
const {
|
||||
dragState,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
} = useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
// Dynamic timeline width calculation based on playhead position and duration
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
(currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
@@ -69,14 +90,6 @@ export function Timeline() {
|
||||
timelineRef.current?.clientWidth || 1000,
|
||||
);
|
||||
|
||||
// Scroll synchronization and auto-scroll to playhead
|
||||
const rulerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Timeline playhead ruler handlers
|
||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
@@ -88,8 +101,11 @@ export function Timeline() {
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Selection box functionality
|
||||
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
||||
containerRef: tracksContainerRef,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const {
|
||||
selectionBox,
|
||||
handleMouseDown: handleSelectionMouseDown,
|
||||
@@ -99,23 +115,13 @@ export function Timeline() {
|
||||
containerRef: tracksContainerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete: (elements) => {
|
||||
console.log(JSON.stringify({ onSelectionComplete: elements.length }));
|
||||
setSelectedElements(elements);
|
||||
setSelection(elements);
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate snap indicator state
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
);
|
||||
const showSnapIndicator =
|
||||
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
|
||||
|
||||
// Callback to handle snap point changes from TimelineTrackContent
|
||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||
setCurrentSnapPoint(snapPoint);
|
||||
}, []);
|
||||
|
||||
const { handleTimelineMouseDown, handleTimelineContentClick } =
|
||||
useTimelineInteractions({
|
||||
playheadRef,
|
||||
@@ -126,19 +132,10 @@ export function Timeline() {
|
||||
duration,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements,
|
||||
clearSelectedElements: clearSelection,
|
||||
seek,
|
||||
});
|
||||
|
||||
// Update timeline duration when tracks change
|
||||
useEffect(() => {
|
||||
const totalDuration = getTotalDuration();
|
||||
setDuration(
|
||||
Math.max(totalDuration, TIMELINE_CONSTANTS.MIN_DURATION_SECONDS),
|
||||
);
|
||||
}, [tracks, setDuration, getTotalDuration]);
|
||||
|
||||
// --- Scroll synchronization effect ---
|
||||
useScrollSync({
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
@@ -154,7 +151,10 @@ export function Timeline() {
|
||||
onMouseEnter={() => setIsInTimeline(true)}
|
||||
onMouseLeave={() => setIsInTimeline(false)}
|
||||
>
|
||||
<TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} />
|
||||
<TimelineToolbar
|
||||
zoomLevel={zoomLevel}
|
||||
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative flex flex-1 flex-col overflow-hidden"
|
||||
@@ -185,17 +185,11 @@ export function Timeline() {
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
{/* Timeline Header with Ruler */}
|
||||
<div className="bg-panel sticky top-0 z-10 flex">
|
||||
{/* Track Labels Header */}
|
||||
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
|
||||
{/* Empty space */}
|
||||
<span className="text-muted-foreground text-sm font-medium opacity-0">
|
||||
.
|
||||
</span>
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline Ruler */}
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
duration={duration}
|
||||
@@ -209,9 +203,7 @@ export function Timeline() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tracks Area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Track Labels */}
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
@@ -232,12 +224,20 @@ export function Timeline() {
|
||||
{track.muted ? (
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() => toggleTrackMute(track.id)}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() => toggleTrackMute(track.id)}
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Eye className="text-muted-foreground h-4 w-4" />
|
||||
@@ -250,13 +250,11 @@ export function Timeline() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline Tracks Content */}
|
||||
<div
|
||||
className="relative flex-1 overflow-hidden"
|
||||
onWheel={(e) => {
|
||||
// Check if this is horizontal scrolling - if so, don't handle it here
|
||||
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
return; // Let ScrollArea handle horizontal scrolling
|
||||
return;
|
||||
}
|
||||
handleWheel(e);
|
||||
}}
|
||||
@@ -273,6 +271,11 @@ export function Timeline() {
|
||||
containerRef={tracksContainerRef}
|
||||
isActive={selectionBox?.isActive || false}
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dropTarget}
|
||||
tracks={tracks}
|
||||
isVisible={isDragOver}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
@@ -301,22 +304,24 @@ export function Timeline() {
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on a element), deselect all elements
|
||||
if (
|
||||
!(e.target as HTMLElement).closest(
|
||||
".timeline-element",
|
||||
)
|
||||
) {
|
||||
clearSelectedElements();
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TimelineTrackContent
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
dragState={dragState}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -324,7 +329,9 @@ export function Timeline() {
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMute(track.id);
|
||||
editor.timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{track.muted ? "Unmute Track" : "Mute Track"}
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "../audio-waveform";
|
||||
import { TimelineElementProps } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
|
||||
import {
|
||||
getTrackColor,
|
||||
getTrackHeight,
|
||||
isMutableElement,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -28,6 +31,24 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
|
||||
import {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
} from "@/types/timeline";
|
||||
import { ElementDragState } from "@/types/timeline";
|
||||
|
||||
interface TimelineElementProps {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
dragState: ElementDragState;
|
||||
}
|
||||
|
||||
export function TimelineElement({
|
||||
element,
|
||||
@@ -36,11 +57,11 @@ export function TimelineElement({
|
||||
isSelected,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
dragState,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
const {
|
||||
dragState,
|
||||
copySelected,
|
||||
selectedElements,
|
||||
deleteSelected,
|
||||
@@ -58,12 +79,11 @@ export function TimelineElement({
|
||||
: null;
|
||||
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||
|
||||
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||
useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
});
|
||||
const { handleResizeStart } = useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const {
|
||||
isMultipleSelected,
|
||||
@@ -215,7 +235,7 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const isMuted = element.type === "media" && element.muted;
|
||||
const isMuted = isMutableElement(element) && element.muted;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -230,12 +250,9 @@ export function TimelineElement({
|
||||
}}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackElementClasses(
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackColor(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
@@ -266,13 +283,25 @@ export function TimelineElement({
|
||||
<>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "left",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
TooltipProvider,
|
||||
@@ -46,97 +44,41 @@ export function TimelineToolbar({
|
||||
setZoomLevel,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoom: number) => void;
|
||||
setZoomLevel: ({ zoom }: { zoom: number }) => void;
|
||||
}) {
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
clearSelectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { toggleBookmark, isBookmarked } = useSceneStore();
|
||||
const { scenes, currentScene } = useSceneStore();
|
||||
const editor = useEditor();
|
||||
const { selectedElements, clearSelection } = useElementSelection();
|
||||
|
||||
const handleSplitSelected = () => {
|
||||
splitSelected(currentTime);
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedElements.length === 0) return;
|
||||
const canDuplicate = selectedElements.length === 1;
|
||||
if (!canDuplicate) return;
|
||||
|
||||
selectedElements.forEach(({ trackId, elementId }) => {
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
clearSelectedElements();
|
||||
};
|
||||
|
||||
const handleFreezeSelected = () => {
|
||||
toast.info("Freeze frame functionality coming soon!");
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
if (!element) return;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "left",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
if (!element) return;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "right",
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
@@ -150,248 +92,329 @@ export function TimelineToolbar({
|
||||
TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP,
|
||||
);
|
||||
setZoomLevel(newZoomLevel);
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
const currentBookmarked = isBookmarked({ time: currentTime });
|
||||
const hasNoTracks = editor.timeline.getTracks().length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={() => seek(0)}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
{/* Time Display */}
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={duration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={activeProject?.fps ?? DEFAULT_FPS}
|
||||
onTimeChange={seek}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">
|
||||
/
|
||||
</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{tracks.length === 0 && (
|
||||
<>
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("media");
|
||||
addElementToTrack(trackId, {
|
||||
type: "media",
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Add Test Clip
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepRight}
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" disabled>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => deleteSelected()}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => toggleBookmark({ time: currentTime })}
|
||||
>
|
||||
<Bookmark
|
||||
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div>
|
||||
<SplitButton className="border-foreground/10 border">
|
||||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
|
||||
<LayersIcon />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
</SplitButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||
{snappingEnabled ? (
|
||||
<Magnet className="text-primary h-4 w-4" />
|
||||
) : (
|
||||
<Magnet className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleRippleEditing}>
|
||||
<Link
|
||||
className={`h-4 w-4 ${
|
||||
rippleEditingEnabled ? "text-primary" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{rippleEditingEnabled
|
||||
? "Disable Ripple Editing"
|
||||
: "Enable Ripple Editing"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<ToolbarLeftSection
|
||||
hasNoTracks={hasNoTracks}
|
||||
onSplit={handleSplitSelected}
|
||||
onSplitLeft={handleSplitAndKeepLeft}
|
||||
onSplitRight={handleSplitAndKeepRight}
|
||||
onDuplicate={handleDuplicateSelected}
|
||||
/>
|
||||
|
||||
<SceneSelector />
|
||||
|
||||
<ToolbarRightSection
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomChange={(zoom) => setZoomLevel({ zoom })}
|
||||
onZoom={handleZoom}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarLeftSection({
|
||||
hasNoTracks,
|
||||
onSplit,
|
||||
onSplitLeft,
|
||||
onSplitRight,
|
||||
onDuplicate,
|
||||
}: {
|
||||
hasNoTracks: boolean;
|
||||
onSplit: () => void;
|
||||
onSplitLeft: () => void;
|
||||
onSplitRight: () => void;
|
||||
onDuplicate: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useElementSelection();
|
||||
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.isPlaying;
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject?.fps ?? DEFAULT_FPS;
|
||||
const currentBookmarked = editor.scene.isBookmarked({ time: currentTime });
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.toggle()}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.seek({ time: 0 })}
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => handleZoom({ direction: "out" })}
|
||||
|
||||
<TimeDisplay currentTime={currentTime} duration={duration} fps={fps} />
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={onSplit}>
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitRight}
|
||||
>
|
||||
<ArrowRightToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" disabled type="button">
|
||||
<SplitSquareHorizontal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Freeze frame functionality coming soon!")
|
||||
}
|
||||
>
|
||||
<Snowflake className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.timeline.deleteElements({ elements: selectedElements })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.scene.toggleBookmark({ time: currentTime })}
|
||||
>
|
||||
<Bookmark
|
||||
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDisplay({
|
||||
currentTime,
|
||||
duration,
|
||||
fps,
|
||||
}: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={duration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={(time) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scene.getCurrentScene();
|
||||
const scenesCount = editor.scene.getScenes().length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SplitButton className="border-foreground/10 border">
|
||||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight
|
||||
disabled={scenesCount === 1}
|
||||
onClick={() => {}}
|
||||
type="button"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={(values) => setZoomLevel(values[0])}
|
||||
min={TIMELINE_CONSTANTS.ZOOM_MIN}
|
||||
max={TIMELINE_CONSTANTS.ZOOM_MAX}
|
||||
step={TIMELINE_CONSTANTS.ZOOM_STEP}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => handleZoom({ direction: "in" })}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LayersIcon className="size-4" />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
</SplitButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarRightSection({
|
||||
zoomLevel,
|
||||
onZoomChange,
|
||||
onZoom,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
onZoomChange: (zoom: number) => void;
|
||||
onZoom: (options: { direction: "in" | "out" }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Magnet className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Link className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Enable Ripple Editing</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={(values) => onZoomChange(values[0])}
|
||||
min={TIMELINE_CONSTANTS.ZOOM_MIN}
|
||||
max={TIMELINE_CONSTANTS.ZOOM_MAX}
|
||||
step={TIMELINE_CONSTANTS.ZOOM_STEP}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,388 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { toast } from "sonner";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { TimelineElement } from "./timeline-element";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { ElementDragState } from "@/types/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
onElementMouseDown: (params: {
|
||||
e: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
onElementClick: (params: {
|
||||
e: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
dragState,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const {
|
||||
tracks,
|
||||
updateElementStartTime,
|
||||
updateElementStartTimeWithRipple,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
dragState,
|
||||
startDrag: startDragAction,
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
rippleEditingEnabled,
|
||||
} = useTimelineStore();
|
||||
lastMouseXRef,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
}: TimelineTrackContentProps) {
|
||||
const editor = useEditor();
|
||||
const { isSelected, clearSelection } = useElementSelection();
|
||||
|
||||
const { duration } = usePlaybackStore();
|
||||
|
||||
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const lastMouseXRef = useRef(0);
|
||||
|
||||
// Set up mouse event listeners for drag
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
lastMouseXRef.current = e.clientX;
|
||||
|
||||
// On first mouse move during drag, ensure the element is selected
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const isSelected = selectedElements.some(
|
||||
(c) =>
|
||||
c.trackId === dragState.trackId &&
|
||||
c.elementId === dragState.elementId,
|
||||
);
|
||||
|
||||
if (!isSelected) {
|
||||
// Select this element (replacing other selections) since we're dragging it
|
||||
selectElement(dragState.trackId, dragState.elementId, false);
|
||||
}
|
||||
}
|
||||
|
||||
const timelineRect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - timelineRect.left;
|
||||
const mouseTime = Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const finalTime = snapTimeToFrame({
|
||||
time: adjustedTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateDragTime(finalTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
|
||||
const isTrackThatStartedDrag = dragState.trackId === track.id;
|
||||
|
||||
const timelineRect = timelineRef.current?.getBoundingClientRect();
|
||||
if (!timelineRect) {
|
||||
if (isTrackThatStartedDrag) {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime,
|
||||
);
|
||||
}
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isMouseOverThisTrack =
|
||||
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
|
||||
|
||||
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
|
||||
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
if (isMouseOverThisTrack) {
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c) => c.id === dragState.elementId,
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalTime + movingElementDuration;
|
||||
|
||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||
const hasOverlap = targetTrack?.elements.some((existingElement) => {
|
||||
if (
|
||||
dragState.trackId === track.id &&
|
||||
existingElement.id === dragState.elementId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return finalTime < existingEnd && movingElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === track.id) {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.info("Moving elements between tracks is coming soon!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isTrackThatStartedDrag) {
|
||||
// Mouse is not over this track, but this track started the drag
|
||||
// This means user released over ruler/outside - update position within same track
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c) => c.id === dragState.elementId,
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
if (existingElement.id === dragState.elementId) {
|
||||
return false;
|
||||
}
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return finalTime < existingEnd && movingElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTrackThatStartedDrag) {
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.elementId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateElementStartTime,
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: dragState.isDragging,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
const handleElementMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => {
|
||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||
|
||||
// Detect right-click (button 2) and handle selection without starting drag
|
||||
const isRightClick = e.button === 2;
|
||||
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
|
||||
if (isRightClick) {
|
||||
// Handle right-click selection
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id,
|
||||
);
|
||||
|
||||
// If element is not selected, select it (keep other selections if multi-select)
|
||||
if (!isSelected) {
|
||||
selectElement(track.id, element.id, isMultiSelect);
|
||||
}
|
||||
// If element is already selected, keep it selected
|
||||
|
||||
// Don't start drag action for right-clicks
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-selection for left-click with modifiers
|
||||
if (isMultiSelect) {
|
||||
selectElement(track.id, element.id, true);
|
||||
}
|
||||
|
||||
// Calculate the offset from the left edge of the element to where the user clicked
|
||||
const elementElement = e.currentTarget as HTMLElement;
|
||||
const elementRect = elementElement.getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - elementRect.left;
|
||||
const clickOffsetTime =
|
||||
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
startDragAction(
|
||||
element.id,
|
||||
track.id,
|
||||
e.clientX,
|
||||
element.startTime,
|
||||
clickOffsetTime,
|
||||
);
|
||||
};
|
||||
|
||||
const handleElementClick = (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if mouse moved significantly
|
||||
if (mouseDownLocation) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
|
||||
// If it moved more than a few pixels, consider it a drag and not a click.
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
setMouseDownLocation(null); // Reset for next interaction
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection logic for multi-selection (handled in mousedown)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single selection
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!isSelected) {
|
||||
// If element is not selected, select it (replacing other selections)
|
||||
selectElement(track.id, element.id, false);
|
||||
}
|
||||
// If element is already selected, keep it selected (do nothing)
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hover:bg-muted/20 h-full w-full"
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on an element), deselect all elements
|
||||
if (!(e.target as HTMLElement).closest(".timeline-element")) {
|
||||
clearSelectedElements();
|
||||
}
|
||||
}}
|
||||
{...dragProps}
|
||||
>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="track-elements-container relative h-full min-w-full"
|
||||
>
|
||||
<div className="hover:bg-muted/20 size-full" onClick={clearSelection}>
|
||||
<div className="track-elements-container relative h-full min-w-full">
|
||||
{track.elements.length === 0 ? (
|
||||
<div
|
||||
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
|
||||
isDragOver
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDragOver
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop element here"
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
|
||||
) : (
|
||||
<>
|
||||
{track.elements.map((element) => {
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id,
|
||||
);
|
||||
const isElementSelected = isSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<TimelineElement
|
||||
@@ -390,9 +70,14 @@ export function TimelineTrackContent({
|
||||
element={element}
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isSelected}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
isSelected={isElementSelected}
|
||||
onElementMouseDown={(e, el) =>
|
||||
onElementMouseDown({ e, element: el, track })
|
||||
}
|
||||
onElementClick={(e, el) =>
|
||||
onElementClick({ e, element: el, track })
|
||||
}
|
||||
dragState={dragState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { GithubIcon } from "./icons";
|
||||
import { GithubIcon } from "@opencut/ui/icons";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
@@ -18,7 +18,7 @@ export function RenameProjectDialog({
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (name: string) => void;
|
||||
onConfirm: (newName: string) => void;
|
||||
projectName: string;
|
||||
}) {
|
||||
const [name, setName] = useState(projectName);
|
||||
|
||||
@@ -12,12 +12,13 @@ import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { DragData } from "@/types/timeline";
|
||||
import { setAssetDragData } from "@/lib/asset-drag";
|
||||
import type { AssetDragData } from "@/types/assets";
|
||||
|
||||
export interface DraggableMediaItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: DragData;
|
||||
dragData: AssetDragData;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onAddToTimeline?: (currentTime: number) => void;
|
||||
aspectRatio?: number;
|
||||
@@ -80,14 +81,9 @@ export function DraggableMediaItem({
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
// Set drag data
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-item",
|
||||
JSON.stringify(dragData)
|
||||
);
|
||||
setAssetDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
// Set initial position and show custom drag preview
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
@@ -103,13 +99,13 @@ export function DraggableMediaItem({
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("relative group", containerClassName ?? "w-28 h-28")}
|
||||
className={cn("group relative", containerClassName ?? "h-28 w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 p-1 h-auto w-full relative cursor-default",
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
|
||||
className,
|
||||
isHighlighted && highlightClassName
|
||||
isHighlighted && highlightClassName,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
@@ -117,7 +113,7 @@ export function DraggableMediaItem({
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
@@ -133,7 +129,7 @@ export function DraggableMediaItem({
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
<span
|
||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
>
|
||||
@@ -148,24 +144,24 @@ export function DraggableMediaItem({
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn(
|
||||
"relative group w-full",
|
||||
isHighlighted && highlightClassName
|
||||
"group relative w-full",
|
||||
isHighlighted && highlightClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 flex items-center gap-3 cursor-default w-full px-1",
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="w-6 h-6 flex-shrink-0 rounded-[0.35rem] overflow-hidden">
|
||||
<div className="h-6 w-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="text-sm truncate flex-1 w-full">{name}</span>
|
||||
<span className="w-full flex-1 truncate text-sm">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +172,7 @@ export function DraggableMediaItem({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-9999"
|
||||
className="z-9999 pointer-events-none fixed"
|
||||
style={{
|
||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
||||
@@ -185,9 +181,9 @@ export function DraggableMediaItem({
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="relative rounded-md overflow-hidden shadow-2xl ring-3 ring-primary"
|
||||
className="ring-3 ring-primary relative overflow-hidden rounded-md shadow-2xl"
|
||||
>
|
||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{showPlusOnDrag && (
|
||||
@@ -199,7 +195,7 @@ export function DraggableMediaItem({
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -218,8 +214,8 @@ function PlusButton({
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground",
|
||||
className
|
||||
"bg-background hover:bg-panel text-foreground absolute bottom-2 right-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseFilePasteOptions {
|
||||
onFilesPaste: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function useFilePaste({ onFilesPaste }: UseFilePasteOptions) {
|
||||
useEffect(() => {
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
if (!e.clipboardData?.files.length) return;
|
||||
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onFilesPaste(files);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [onFilesPaste]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { hasAssetDragData } from "@/lib/asset-drag";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onFilesSelected?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return (
|
||||
!hasAssetDragData({ dataTransfer }) && dataTransfer.types.includes("Files")
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0 && onFilesSelected) {
|
||||
onFilesSelected(files);
|
||||
}
|
||||
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
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) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
onFilesSelected(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
openFilePicker,
|
||||
fileInputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
style: { display: "none" },
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
-107
@@ -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,295 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { MediaType, MediaFile } from "@/types/media";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
|
||||
interface MediaStore {
|
||||
mediaFiles: MediaFile[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
addMediaFile: (
|
||||
projectId: string,
|
||||
file: Omit<MediaFile, "id">
|
||||
) => Promise<void>;
|
||||
removeMediaFile: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearAllMedia: () => void;
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
export const getFileType = (file: File): MediaType | null => {
|
||||
const { type } = file;
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get image dimensions
|
||||
export const getImageDimensions = (
|
||||
file: File
|
||||
): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
resolve({ width, height });
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.addEventListener("error", () => {
|
||||
reject(new Error("Could not load image"));
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
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"
|
||||
) as HTMLVideoElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
resolve(element.duration);
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Could not load media"));
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.src = URL.createObjectURL(file);
|
||||
element.load();
|
||||
});
|
||||
};
|
||||
|
||||
export const getMediaAspectRatio = (item: MediaFile): number => {
|
||||
if (item.width && item.height) {
|
||||
return item.width / item.height;
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaFiles: [],
|
||||
isLoading: false,
|
||||
|
||||
addMediaFile: async (projectId, file) => {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
set((state) => ({
|
||||
mediaFiles: [...state.mediaFiles, newItem],
|
||||
}));
|
||||
|
||||
// Save to persistent storage in background
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
// Remove from local state if save failed
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
removeMediaFile: async (projectId: string, id: string) => {
|
||||
const state = get();
|
||||
const item = state.mediaFiles.find((media) => media.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Remove from local state immediately
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
|
||||
}));
|
||||
|
||||
// 2) Cascade into the timeline: remove any elements using this media ID
|
||||
const timeline = useTimelineStore.getState();
|
||||
const { tracks, deleteSelected, setSelectedElements } = timeline;
|
||||
|
||||
// Find all elements that reference this media
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
for (const track of tracks) {
|
||||
for (const el of track.elements) {
|
||||
if (el.type === "media" && el.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: el.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are elements to remove, use unified delete function
|
||||
if (elementsToRemove.length > 0) {
|
||||
setSelectedElements(elementsToRemove);
|
||||
deleteSelected();
|
||||
}
|
||||
|
||||
// 3) Remove from persistent storage
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadProjectMedia: async (projectId) => {
|
||||
set({ isLoading: true });
|
||||
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearProjectMedia: async (projectId) => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
|
||||
// Clear persistent storage
|
||||
try {
|
||||
const mediaIds = state.mediaFiles.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMedia: () => {
|
||||
const state = get();
|
||||
|
||||
videoCache.clearAll();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,465 +0,0 @@
|
||||
import { TProject, TScene } from "@/types/project";
|
||||
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 { 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";
|
||||
|
||||
export function createMainScene(): TScene {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name: "Main Scene",
|
||||
isMain: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const createDefaultProject = (name: string): TProject => {
|
||||
const mainScene: TScene = createMainScene();
|
||||
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: DEFAULT_BLUR_INTENSITY,
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
};
|
||||
};
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
invalidProjectIds?: Set<string>;
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
loadProject: (id: string) => Promise<void>;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadAllProjects: () => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
closeProject: () => void;
|
||||
renameProject: (projectId: string, name: string) => Promise<void>;
|
||||
duplicateProject: (projectId: string) => Promise<string>;
|
||||
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
||||
updateBackgroundType: (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number },
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
updateCanvasSize: ({ size }: { size: TCanvasSize }) => Promise<void>;
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string,
|
||||
) => TProject[];
|
||||
isInvalidProjectId: (id: string) => boolean;
|
||||
markProjectIdAsInvalid: (id: string) => void;
|
||||
clearInvalidProjectIds: () => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
invalidProjectIds: new Set<string>(),
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject = createDefaultProject(name);
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
sceneStore.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
// Reload all projects to update the list
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadProject: async (id: string) => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
// Prevent flicker when switching projects - clear all stores
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
sceneStore.initializeScenes({
|
||||
scenes: project.scenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
// Get current scene directly from project data (don't rely on store state)
|
||||
currentScene =
|
||||
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
||||
project.scenes.find((s) => s.isMain) ||
|
||||
project.scenes[0];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline({
|
||||
projectId: id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error; // Re-throw so the editor page can handle it
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
const currentScene = sceneStore.currentScene;
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: activeProject }),
|
||||
timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadAllProjects: async () => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
set({ savedProjects: projects });
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
} finally {
|
||||
set({ isLoading: false, isInitialized: true });
|
||||
}
|
||||
},
|
||||
|
||||
deleteProject: async (id: string) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProjectTimeline({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If deleted active project, close it and clear data
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: null });
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
},
|
||||
|
||||
renameProject: async (id: string, name: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Find the project to rename
|
||||
const projectToRename = savedProjects.find((p) => p.id === id);
|
||||
if (!projectToRename) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...projectToRename,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if same project
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: updatedProject });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
duplicateProject: async (projectId: string) => {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (!project) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Extract the base name (remove any existing numbering)
|
||||
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
// Check for pattern "(number) baseName" in existing projects
|
||||
savedProjects.forEach((p) => {
|
||||
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
if (match && match[2] === baseName) {
|
||||
existingNumbers.push(parseInt(match[1], 10));
|
||||
}
|
||||
});
|
||||
|
||||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectBackground: async (backgroundColor: string) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateBackgroundType: async (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number },
|
||||
) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity !== undefined && {
|
||||
blurIntensity: options.blurIntensity,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectFps: async (fps: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateCanvasSize: async ({ size }: { size: TCanvasSize }) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
canvasSize: size,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
const filteredProjects = savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
||||
const [key, order] = sortOption.split("-");
|
||||
|
||||
if (key !== "createdAt" && key !== "name") {
|
||||
console.warn(`Invalid sort key: ${key}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (aValue === undefined || bValue === undefined) return 0;
|
||||
|
||||
if (order === "asc") {
|
||||
if (aValue < bValue) return -1;
|
||||
if (aValue > bValue) return 1;
|
||||
return 0;
|
||||
}
|
||||
if (aValue > bValue) return -1;
|
||||
if (aValue < bValue) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedProjects;
|
||||
},
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => {
|
||||
const invalidIds = get().invalidProjectIds || new Set();
|
||||
return invalidIds.has(id);
|
||||
},
|
||||
|
||||
markProjectIdAsInvalid: (id: string) => {
|
||||
set((state) => ({
|
||||
invalidProjectIds: new Set([
|
||||
...(state.invalidProjectIds || new Set()),
|
||||
id,
|
||||
]),
|
||||
}));
|
||||
},
|
||||
|
||||
clearInvalidProjectIds: () => {
|
||||
set({ invalidProjectIds: new Set() });
|
||||
},
|
||||
}));
|
||||
@@ -1,12 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { SceneNode } from "@/services/renderer/nodes/root-node";
|
||||
|
||||
interface RendererStore {
|
||||
renderTree: SceneNode | null;
|
||||
setRenderTree: (renderTree: SceneNode | null) => void;
|
||||
}
|
||||
|
||||
export const useRendererStore = create<RendererStore>((set) => ({
|
||||
renderTree: null,
|
||||
setRenderTree: (renderTree) => set({ renderTree }),
|
||||
}));
|
||||
@@ -1,419 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { TScene } from "@/types/project";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
getActiveScene,
|
||||
updateSceneInArray,
|
||||
getMainScene as getMainSceneUtil,
|
||||
ensureMainScene,
|
||||
createScene as createSceneUtil,
|
||||
canDeleteScene,
|
||||
getFallbackSceneAfterDelete,
|
||||
normalizeScenes,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
removeBookmarkFromArray,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
interface SceneStore {
|
||||
currentScene: TScene | null;
|
||||
scenes: TScene[];
|
||||
activeScene: TScene | null;
|
||||
createScene: ({
|
||||
name,
|
||||
isMain,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}) => Promise<string>;
|
||||
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
renameScene: ({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}) => Promise<void>;
|
||||
switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
toggleBookmark: ({ time }: { time: number }) => Promise<void>;
|
||||
isBookmarked: ({ time }: { time: number }) => boolean;
|
||||
removeBookmark: ({ time }: { time: number }) => Promise<void>;
|
||||
loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}) => void;
|
||||
clearScenes: () => void;
|
||||
}
|
||||
|
||||
export const useSceneStore = create<SceneStore>((set, get) => {
|
||||
const updateProjectWithScenes = async ({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
refreshProjectList = false,
|
||||
}: {
|
||||
updatedScenes: TScene[];
|
||||
updatedSceneId?: string;
|
||||
refreshProjectList?: boolean;
|
||||
}) => {
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedScene = updatedSceneId
|
||||
? updatedScenes.find((s) => s.id === updatedSceneId)
|
||||
: get().currentScene;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
set({ scenes: updatedScenes, currentScene: updatedScene || null });
|
||||
|
||||
if (refreshProjectList) {
|
||||
await projectStore.loadAllProjects();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentScene: null,
|
||||
scenes: [],
|
||||
get activeScene(): TScene | null {
|
||||
const { scenes, currentScene } = get();
|
||||
return getActiveScene({
|
||||
scenes,
|
||||
currentSceneId: currentScene?.id || "",
|
||||
});
|
||||
},
|
||||
|
||||
createScene: async ({ name, isMain = false }) => {
|
||||
const { scenes } = get();
|
||||
|
||||
const newScene = createSceneUtil({ name, isMain });
|
||||
const updatedScenes = [...scenes, newScene];
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({ updatedScenes });
|
||||
return newScene.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to create scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes, currentScene } = get();
|
||||
const sceneToDelete = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
|
||||
if (!canDelete) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
|
||||
|
||||
const newCurrentScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: sceneId,
|
||||
currentSceneId: currentScene?.id || null,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: newCurrentScene?.id,
|
||||
});
|
||||
|
||||
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (activeProject) {
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: newCurrentScene.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameScene: async ({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { scenes } = get();
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId,
|
||||
updates: { name, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
switchToScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes } = get();
|
||||
const targetScene = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
const { currentScene } = get();
|
||||
|
||||
if (activeProject && currentScene) {
|
||||
await timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProject) {
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
}
|
||||
|
||||
set({ currentScene: targetScene });
|
||||
},
|
||||
|
||||
toggleBookmark: async ({ time }: { time: number }) => {
|
||||
const { activeScene, scenes, currentScene } = get();
|
||||
if (!activeScene || !currentScene) return;
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
const updatedBookmarks = toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
toast.error("Failed to update bookmarks", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
isBookmarked: ({ time }: { time: number }) => {
|
||||
const { activeScene, currentScene } = get();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeScene || !currentScene || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
return isBookmarkAtTime({ bookmarks, frameTime });
|
||||
},
|
||||
|
||||
removeBookmark: async ({ time }: { time: number }) => {
|
||||
const { activeScene, scenes, currentScene } = get();
|
||||
if (!activeScene || !currentScene) return;
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
const updatedBookmarks = removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
if (updatedBookmarks.length === bookmarks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
toast.error("Failed to remove bookmark", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getMainScene: () => {
|
||||
const { scenes } = get();
|
||||
return getMainSceneUtil({ scenes });
|
||||
},
|
||||
|
||||
getCurrentScene: () => {
|
||||
return get().currentScene;
|
||||
},
|
||||
|
||||
loadProjectScenes: async ({ projectId }: { projectId: string }) => {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (project?.scenes) {
|
||||
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: normalizedScenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
|
||||
set({
|
||||
scenes: normalizedScenes,
|
||||
currentScene,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
set({ scenes: [], currentScene: null });
|
||||
}
|
||||
},
|
||||
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}) => {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const currentScene = currentSceneId
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
|
||||
|
||||
set({
|
||||
scenes: ensuredScenes,
|
||||
currentScene: currentScene || fallbackScene,
|
||||
});
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
storageService
|
||||
.saveProject({ project: updatedProject })
|
||||
.then(() => {
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to save project with background scene:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearScenes: () => {
|
||||
set({
|
||||
scenes: [],
|
||||
currentScene: null,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user