From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/components/editor/audio-waveform.tsx | 162 --- .../editor/dialogs/delete-project-dialog.tsx | 84 ++ .../components/editor/dialogs/migration-dialog.tsx | 44 + .../editor/dialogs/project-info-dialog.tsx | 83 ++ .../editor/dialogs/rename-project-dialog.tsx | 72 + .../components/editor/dialogs/shortcuts-dialog.tsx | 229 ++++ apps/web/src/components/editor/editor-header.tsx | 279 ++-- apps/web/src/components/editor/export-button.tsx | 588 ++++---- .../src/components/editor/layout-guide-overlay.tsx | 30 +- .../components/editor/media-panel/drag-overlay.tsx | 57 - .../src/components/editor/media-panel/index.tsx | 52 - .../web/src/components/editor/media-panel/store.ts | 86 -- .../src/components/editor/media-panel/tabbar.tsx | 99 -- .../editor/media-panel/views/captions.tsx | 303 ----- .../components/editor/media-panel/views/media.tsx | 545 -------- .../editor/media-panel/views/settings.tsx | 332 ----- .../components/editor/media-panel/views/sounds.tsx | 562 -------- .../editor/media-panel/views/stickers.tsx | 612 --------- .../components/editor/media-panel/views/text.tsx | 36 - apps/web/src/components/editor/onboarding.tsx | 227 ++-- apps/web/src/components/editor/panel-base-view.tsx | 85 -- .../components/editor/panel-preset-selector.tsx | 91 -- .../editor/panels/assets/drag-overlay.tsx | 61 + .../editor/panels/assets/draggable-item.tsx | 244 ++++ .../src/components/editor/panels/assets/index.tsx | 52 + .../src/components/editor/panels/assets/tabbar.tsx | 116 ++ .../editor/panels/assets/views/captions.tsx | 152 +++ .../editor/panels/assets/views/media.tsx | 675 ++++++++++ .../editor/panels/assets/views/settings.tsx | 394 ++++++ .../editor/panels/assets/views/sounds.tsx | 574 ++++++++ .../editor/panels/assets/views/stickers.tsx | 615 +++++++++ .../components/editor/panels/assets/views/text.tsx | 46 + .../components/editor/panels/panel-base-view.tsx | 85 ++ .../src/components/editor/panels/preview/index.tsx | 124 ++ .../editor/panels/properties/audio-properties.tsx | 5 + .../components/editor/panels/properties/index.tsx | 68 + .../editor/panels/properties/property-item.tsx | 91 ++ .../editor/panels/properties/text-properties.tsx | 410 ++++++ .../editor/panels/properties/video-properties.tsx | 9 + apps/web/src/components/editor/preview-panel.tsx | 1134 ---------------- .../editor/properties-panel/audio-properties.tsx | 5 - .../components/editor/properties-panel/index.tsx | 70 - .../editor/properties-panel/media-properties.tsx | 5 - .../editor/properties-panel/property-item.tsx | 86 -- .../editor/properties-panel/text-properties.tsx | 370 ----- apps/web/src/components/editor/scenes-view.tsx | 367 ++--- apps/web/src/components/editor/selection-box.tsx | 87 +- apps/web/src/components/editor/snap-indicator.tsx | 77 -- apps/web/src/components/editor/speed-control.tsx | 46 - .../components/editor/timeline/audio-waveform.tsx | 176 +++ .../src/components/editor/timeline/bookmarks.tsx | 111 ++ .../src/components/editor/timeline/drag-line.tsx | 28 + apps/web/src/components/editor/timeline/index.tsx | 1421 +++++++------------- .../components/editor/timeline/snap-indicator.tsx | 52 + .../editor/timeline/timeline-cache-indicator.tsx | 126 -- .../editor/timeline/timeline-element.tsx | 923 +++++++------ .../components/editor/timeline/timeline-marker.tsx | 67 - .../editor/timeline/timeline-playhead.tsx | 244 ++-- .../components/editor/timeline/timeline-ruler.tsx | 83 ++ .../components/editor/timeline/timeline-tick.tsx | 39 + .../editor/timeline/timeline-toolbar.tsx | 696 +++++----- .../components/editor/timeline/timeline-track.tsx | 1300 ++---------------- 62 files changed, 7136 insertions(+), 8756 deletions(-) delete mode 100644 apps/web/src/components/editor/audio-waveform.tsx create mode 100644 apps/web/src/components/editor/dialogs/delete-project-dialog.tsx create mode 100644 apps/web/src/components/editor/dialogs/migration-dialog.tsx create mode 100644 apps/web/src/components/editor/dialogs/project-info-dialog.tsx create mode 100644 apps/web/src/components/editor/dialogs/rename-project-dialog.tsx create mode 100644 apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx delete mode 100644 apps/web/src/components/editor/media-panel/drag-overlay.tsx delete mode 100644 apps/web/src/components/editor/media-panel/index.tsx delete mode 100644 apps/web/src/components/editor/media-panel/store.ts delete mode 100644 apps/web/src/components/editor/media-panel/tabbar.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/captions.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/media.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/settings.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/sounds.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/stickers.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/text.tsx delete mode 100644 apps/web/src/components/editor/panel-base-view.tsx delete mode 100644 apps/web/src/components/editor/panel-preset-selector.tsx create mode 100644 apps/web/src/components/editor/panels/assets/drag-overlay.tsx create mode 100644 apps/web/src/components/editor/panels/assets/draggable-item.tsx create mode 100644 apps/web/src/components/editor/panels/assets/index.tsx create mode 100644 apps/web/src/components/editor/panels/assets/tabbar.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/captions.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/media.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/sounds.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/stickers.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/text.tsx create mode 100644 apps/web/src/components/editor/panels/panel-base-view.tsx create mode 100644 apps/web/src/components/editor/panels/preview/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/audio-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/index.tsx create mode 100644 apps/web/src/components/editor/panels/properties/property-item.tsx create mode 100644 apps/web/src/components/editor/panels/properties/text-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/video-properties.tsx delete mode 100644 apps/web/src/components/editor/preview-panel.tsx delete mode 100644 apps/web/src/components/editor/properties-panel/audio-properties.tsx delete mode 100644 apps/web/src/components/editor/properties-panel/index.tsx delete mode 100644 apps/web/src/components/editor/properties-panel/media-properties.tsx delete mode 100644 apps/web/src/components/editor/properties-panel/property-item.tsx delete mode 100644 apps/web/src/components/editor/properties-panel/text-properties.tsx delete mode 100644 apps/web/src/components/editor/snap-indicator.tsx delete mode 100644 apps/web/src/components/editor/speed-control.tsx create mode 100644 apps/web/src/components/editor/timeline/audio-waveform.tsx create mode 100644 apps/web/src/components/editor/timeline/bookmarks.tsx create mode 100644 apps/web/src/components/editor/timeline/drag-line.tsx create mode 100644 apps/web/src/components/editor/timeline/snap-indicator.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-marker.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-ruler.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-tick.tsx (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx deleted file mode 100644 index 60406f61..00000000 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; - -interface AudioWaveformProps { - audioUrl: string; - height?: number; - className?: string; -} - -const AudioWaveform: React.FC = ({ - audioUrl, - height = 32, - className = "", -}) => { - const waveformRef = useRef(null); - const wavesurfer = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - let mounted = true; - let ws = wavesurfer.current; - - const initWaveSurfer = async () => { - if (!waveformRef.current || !audioUrl) return; - - try { - // Clear any existing instance safely - if (ws) { - // Instead of immediately destroying, just set to null - // We'll destroy it outside this function - wavesurfer.current = null; - } - - // Create a fresh instance - const newWaveSurfer = WaveSurfer.create({ - container: waveformRef.current, - waveColor: "rgba(255, 255, 255, 0.6)", - progressColor: "rgba(255, 255, 255, 0.9)", - cursorColor: "transparent", - barWidth: 2, - barGap: 1, - height, - normalize: true, - interact: false, - }); - - // Assign to ref only if component is still mounted - if (mounted) { - wavesurfer.current = newWaveSurfer; - } else { - // Component unmounted during initialization, clean up - try { - newWaveSurfer.destroy(); - } catch (e) { - // Ignore destroy errors - } - return; - } - - // Event listeners - newWaveSurfer.on("ready", () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); - - newWaveSurfer.on("error", (err) => { - if (mounted) { - console.error("WaveSurfer error:", err); - setError(true); - setIsLoading(false); - } - }); - - await newWaveSurfer.load(audioUrl); - } catch (err) { - if (mounted) { - console.error("Failed to initialize WaveSurfer:", err); - setError(true); - setIsLoading(false); - } - } - }; - - // First safely destroy previous instance if it exists - if (ws) { - // Use this pattern to safely destroy the previous instance - const wsToDestroy = ws; - // Detach from ref immediately - wavesurfer.current = null; - - // Wait a tick to destroy so any pending operations can complete - requestAnimationFrame(() => { - try { - wsToDestroy.destroy(); - } catch (e) { - // Ignore errors during destroy - } - // Only initialize new instance after destroying the old one - if (mounted) { - initWaveSurfer(); - } - }); - } else { - // No previous instance to clean up, initialize directly - initWaveSurfer(); - } - - return () => { - // Mark component as unmounted - mounted = false; - - // Store reference to current wavesurfer instance - const wsToDestroy = wavesurfer.current; - - // Immediately clear the ref to prevent accessing it after unmount - wavesurfer.current = null; - - // If we have an instance to clean up, do it safely - if (wsToDestroy) { - // Delay destruction to avoid race conditions - requestAnimationFrame(() => { - try { - wsToDestroy.destroy(); - } catch (e) { - // Ignore destroy errors - they're expected - } - }); - } - }; - }, [audioUrl, height]); - - if (error) { - return ( -
- Audio unavailable -
- ); - } - - return ( -
- {isLoading && ( -
- Loading... -
- )} -
-
- ); -}; - -export default AudioWaveform; diff --git a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx new file mode 100644 index 00000000..0086384e --- /dev/null +++ b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx @@ -0,0 +1,84 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export function DeleteProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectNames, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + projectNames: string[]; +}) { + const count = projectNames.length; + const isSingle = count === 1; + const singleName = isSingle ? projectNames[0] : null; + + return ( + + { + event.preventDefault(); + event.stopPropagation(); + }} + > + + + {singleName ? ( + <> + {"Delete '"} + + {singleName} + + {"'?"} + + ) : ( + `Delete ${count} projects?` + )} + + + + + Warning + + This will permanently delete{" "} + {singleName ? `"${singleName}"` : `${count} projects`} and all + associated files. + + +
+ + +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/dialogs/migration-dialog.tsx b/apps/web/src/components/editor/dialogs/migration-dialog.tsx new file mode 100644 index 00000000..4c9550c2 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/migration-dialog.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useEditor } from "@/hooks/use-editor"; +import { Loader2 } from "lucide-react"; + +export function MigrationDialog() { + const editor = useEditor(); + const migrationState = editor.project.getMigrationState(); + + if (!migrationState.isMigrating) return null; + + const title = migrationState.projectName + ? "Updating project" + : "Updating projects"; + const description = migrationState.projectName + ? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}` + : `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`; + + return ( + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + + {title} + {description} + + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx new file mode 100644 index 00000000..7b736b23 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -0,0 +1,83 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { TProjectMetadata } from "@/types/project"; +import { formatDate } from "@/utils/date"; +import { formatTimeCode } from "@/lib/time"; +import { Button } from "@/components/ui/button"; + +function InfoRow({ + label, + value, +}: { + label: string; + value: string | React.ReactNode; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function ProjectInfoDialog({ + isOpen, + onOpenChange, + project, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + project: TProjectMetadata; +}) { + const durationFormatted = + project.duration > 0 + ? formatTimeCode({ + timeInSeconds: project.duration, + format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS", + }) + : "0:00"; + + return ( + + event.preventDefault()}> + + + {project.name} + + + + + + + + + {project.id.slice(0, 8)} + + } + /> + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx new file mode 100644 index 00000000..73024d71 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx @@ -0,0 +1,72 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { Label } from "@/components/ui/label"; + +export function RenameProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectName, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (newName: string) => void; + projectName: string; +}) { + const [name, setName] = useState(projectName); + + const handleOpenChange = (open: boolean) => { + if (open) { + setName(projectName); + } + onOpenChange(open); + }; + + return ( + + + + Rename project + + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="Enter a new name" + /> + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx new file mode 100644 index 00000000..565fe705 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + type KeyboardShortcut, + useKeyboardShortcutsHelp, +} from "@/hooks/use-keyboard-shortcuts-help"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function ShortcutsDialog({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [recordingShortcut, setRecordingShortcut] = + useState(null); + + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + resetToDefaults, + isRecording, + } = useKeybindingsStore(); + + const { shortcuts } = useKeyboardShortcutsHelp(); + + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + + useEffect(() => { + if (!isRecording || !recordingShortcut) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const keyString = getKeybindingString(e); + if (keyString) { + const conflict = validateKeybinding( + keyString, + recordingShortcut.action, + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + ); + setRecordingShortcut(null); + return; + } + + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + for (const key of oldKeys) { + removeKeybinding(key); + } + + updateKeybinding(keyString, recordingShortcut.action); + + setIsRecording(false); + setRecordingShortcut(null); + } + }; + + const handleClickOutside = () => { + setRecordingShortcut(null); + setIsRecording(false); + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + isRecording, + ]); + + const handleStartRecording = (shortcut: KeyboardShortcut) => { + setRecordingShortcut(shortcut); + setIsRecording(true); + }; + + return ( + + + + Keyboard shortcuts + + + +
+ {categories.map((category) => ( +
+

+ {category} +

+
+ {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut) => ( + handleStartRecording(shortcut)} + /> + ))} +
+
+ ))} +
+
+ + + +
+
+ ); +} + +function ShortcutItem({ + shortcut, + isRecording, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; +}) { + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; + + return true; + }); + + return ( +
+
+ {shortcut.icon && ( +
{shortcut.icon}
+ )} + {shortcut.description} +
+
+ {displayKeys.map((key: string, index: number) => ( +
+
+ {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + onStartRecording({ shortcut })} + > + {keyPart} + + ); + })} +
+ {index < displayKeys.length - 1 && ( + or + )} +
+ ))} +
+
+ ); +} + +function EditableShortcutKey({ + children, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + isRecording: boolean; + onStartRecording: () => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 5363d0c7..d74d78d7 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,130 +1,187 @@ "use client"; import { Button } from "../ui/button"; -import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react"; -import { HeaderBase } from "../header-base"; -import { useProjectStore } from "@/stores/project-store"; -import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help"; +import { ChevronDown } from "lucide-react"; import { useState } from "react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "../ui/dropdown-menu"; import Link from "next/link"; -import { RenameProjectDialog } from "../rename-project-dialog"; -import { DeleteProjectDialog } from "../delete-project-dialog"; +import { RenameProjectDialog } from "./dialogs/rename-project-dialog"; +import { DeleteProjectDialog } from "./dialogs/delete-project-dialog"; import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; -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"; +import { useEditor } from "@/hooks/use-editor"; +import { + ArrowLeft02Icon, + Edit03Icon, + Delete02Icon, + CommandIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; export function EditorHeader() { - const { activeProject, renameProject, deleteProject } = useProjectStore(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const router = useRouter(); + return ( +
+
+ +
+ +
+ ); +} + +function ProjectDropdown() { + const [openDialog, setOpenDialog] = useState< + "delete" | "rename" | "shortcuts" | null + >(null); + const [isExiting, setIsExiting] = useState(false); + const router = useRouter(); + const editor = useEditor(); + const activeProject = editor.project.getActive(); - const handleNameSave = async (newName: string) => { - console.log("handleNameSave", newName); - if (activeProject && newName.trim() && newName !== activeProject.name) { - try { - await renameProject(activeProject.id, newName.trim()); - setIsRenameDialogOpen(false); - } catch (error) { - console.error("Failed to rename project:", error); - } - } - }; + const handleExit = async () => { + if (isExiting) return; + setIsExiting(true); - const handleDelete = () => { - if (activeProject) { - deleteProject(activeProject.id); - setIsDeleteDialogOpen(false); - router.push("/projects"); - } - }; + 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 leftContent = ( -
- - - - - - - - - Projects - - - setIsRenameDialogOpen(true)} - > - - Rename project - - setIsDeleteDialogOpen(true)} - > - - Delete Project - - - - - - Discord - - - - - - -
- ); + const handleSaveProjectName = async (newName: string) => { + if ( + activeProject && + newName.trim() && + newName !== activeProject.metadata.name + ) { + try { + await editor.project.renameProject({ + id: activeProject.metadata.id, + name: newName.trim(), + }); + } catch (error) { + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } finally { + setOpenDialog(null); + } + } + }; - const rightContent = ( - - ); + const handleDeleteProject = async () => { + if (activeProject) { + try { + await editor.project.deleteProjects({ + ids: [activeProject.metadata.id], + }); + router.push("/projects"); + } catch (error) { + toast.error("Failed to delete project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } finally { + setOpenDialog(null); + } + } + }; - return ( - - ); + return ( + <> + + + + + + + + Exit project + + setOpenDialog("rename")} + > + + Rename project + + setOpenDialog("delete")} + > + + Delete project + + + setOpenDialog("shortcuts")} + > + + Keyboard shortcuts + + + + + Discord + + + + + setOpenDialog(isOpen ? "rename" : null)} + onConfirm={(newName) => handleSaveProjectName(newName)} + projectName={activeProject?.metadata.name || ""} + /> + setOpenDialog(isOpen ? "delete" : null)} + onConfirm={handleDeleteProject} + projectNames={[activeProject?.metadata.name || ""]} + /> + setOpenDialog(isOpen ? "shortcuts" : null)} + /> + + ); } diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 246e8b6f..f79710bf 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,317 +1,347 @@ "use client"; -import { useState } from "react"; -import { TransitionUpIcon } from "../icons"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { Button } from "../ui/button"; -import { Label } from "../ui/label"; -import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; -import { Progress } from "../ui/progress"; -import { Checkbox } from "../ui/checkbox"; -import { cn } from "@/lib/utils"; +import { useState, useRef } from "react"; +import { TransitionTopIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { - exportProject, - getExportMimeType, - getExportFileExtension, - DEFAULT_EXPORT_OPTIONS, -} from "@/lib/export"; -import { useProjectStore } from "@/stores/project-store"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Progress } from "@/components/ui/progress"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/utils/ui"; +import { getExportMimeType, getExportFileExtension } from "@/lib/export"; import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; -import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; -import { PropertyGroup } from "./properties-panel/property-item"; +import { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + type ExportFormat, + type ExportQuality, + type ExportResult, +} from "@/types/export"; +import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; export function ExportButton() { - const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const { activeProject } = useProjectStore(); + const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); + const editor = useEditor(); - const handleExport = () => { - setIsExportPopoverOpen(true); - }; + const handleExport = () => { + setIsExportPopoverOpen(true); + }; - const hasProject = !!activeProject; + const hasProject = !!editor.project.getActive(); - return ( - - - - - {hasProject && } - - ); + return ( + + + + + {hasProject && } + + ); } function ExportPopover({ - onOpenChange, + onOpenChange, }: { - onOpenChange: (open: boolean) => void; + onOpenChange: (open: boolean) => void; }) { - const { activeProject } = useProjectStore(); - const [format, setFormat] = useState( - DEFAULT_EXPORT_OPTIONS.format - ); - const [quality, setQuality] = useState( - DEFAULT_EXPORT_OPTIONS.quality - ); - const [includeAudio, setIncludeAudio] = useState( - DEFAULT_EXPORT_OPTIONS.includeAudio || true - ); - const [isExporting, setIsExporting] = useState(false); - const [progress, setProgress] = useState(0); - const [exportResult, setExportResult] = useState(null); + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format, + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality, + ); + const [includeAudio, setIncludeAudio] = useState( + DEFAULT_EXPORT_OPTIONS.includeAudio || true, + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState(null); + const cancelRequestedRef = useRef(false); + + const handleExport = async () => { + if (!activeProject) return; + + cancelRequestedRef.current = false; + setIsExporting(true); + setProgress(0); + setExportResult(null); + + const result = await editor.project.export({ + options: { + format, + quality, + fps: activeProject.settings.fps, + includeAudio, + onProgress: ({ progress }) => setProgress(progress), + onCancel: () => cancelRequestedRef.current, + }, + }); - const handleExport = async () => { - if (!activeProject) return; + setIsExporting(false); - setIsExporting(true); - setProgress(0); - setExportResult(null); + if (result.cancelled) { + setExportResult(null); + setProgress(0); + return; + } - const result = await exportProject({ - format, - quality, - fps: activeProject.fps, - includeAudio, - onProgress: setProgress, - onCancel: () => false, // TODO: Add cancel functionality - }); + setExportResult(result); - setIsExporting(false); - setExportResult(result); + if (result.success && result.buffer) { + const mimeType = getExportMimeType({ format }); + const extension = getExportFileExtension({ format }); + const blob = new Blob([result.buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); - if (result.success && result.buffer) { - // Download the file - const mimeType = getExportMimeType(format); - const extension = getExportFileExtension(format); - const blob = new Blob([result.buffer], { type: mimeType }); - const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${activeProject.metadata.name}${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); - const a = document.createElement("a"); - a.href = url; - a.download = `${activeProject.name}${extension}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; + const handleClose = () => { + if (!isExporting) { + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; - const handleClose = () => { - if (!isExporting) { - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; + const handleCancel = () => { + cancelRequestedRef.current = true; + }; - return ( - - <> - {exportResult && !exportResult.success ? ( - - ) : ( - <> -
-

- {isExporting ? "Exporting project" : "Export project"} -

- -
+ return ( + + {exportResult && !exportResult.success ? ( + + ) : ( + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+ +
-
- {!isExporting && ( - <> -
- - - setFormat(value as ExportFormat) - } - > -
- - -
-
- - -
-
-
+
+ {!isExporting && ( + <> +
+ + { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > +
+ + +
+
+ + +
+
+
- - - setQuality(value as ExportQuality) - } - > -
- - -
-
- - -
-
- - -
-
- - -
-
-
+ + { + if (isExportQuality(value)) { + setQuality(value); + } + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
- -
- - setIncludeAudio(!!checked) - } - /> - -
-
-
+ +
+ + setIncludeAudio(!!checked) + } + /> + +
+
+
- - - )} + + + )} - {isExporting && ( -
-
-
-

- {Math.round(progress * 100)}% -

-

100%

-
- -
+ {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ + +
+ )} +
+ + )} + + ); +} + +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} - -
- )} -
- - )} - -
- ); +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); } function ExportError({ - error, - onRetry, + error, + onRetry, }: { - error: string; - onRetry: () => void; + error: string; + onRetry: () => void; }) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText(error); - setCopied(true); - setTimeout(() => setCopied(false), 1000); - }; + const handleCopy = async () => { + await navigator.clipboard.writeText(error); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; - return ( -
-
-

Export failed

-

- {error} -

-
+ return ( +
+
+

Export failed

+

{error}

+
-
- - -
-
- ); +
+ + +
+
+ ); } diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx index ff4cde57..0f02da1c 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/layout-guide-overlay.tsx @@ -4,24 +4,24 @@ import { useEditorStore } from "@/stores/editor-store"; import Image from "next/image"; function TikTokGuide() { - return ( -
- TikTok layout guide -
- ); + return ( +
+ TikTok layout guide +
+ ); } export function LayoutGuideOverlay() { - const { layoutGuide } = useEditorStore(); + const { layoutGuide } = useEditorStore(); - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return ; + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return ; - return null; + return null; } diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx deleted file mode 100644 index e1fd257e..00000000 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Upload, Plus, Image } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; - isEmptyState?: boolean; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, - isEmptyState = false, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = (e: React.MouseEvent) => { - if (isProcessing || !onClick) return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - - return ( -
-
- -
- -
-

- {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} -

-
- - {isProcessing && ( -
-
-
-
-
- )} -
- ); -} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx deleted file mode 100644 index 89b53b64..00000000 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import { TabBar } from "./tabbar"; -import { MediaView } from "./views/media"; -import { useMediaPanelStore, Tab } from "./store"; -import { TextView } from "./views/text"; -import { SoundsView } from "./views/sounds"; -import { StickersView } from "./views/stickers"; -import { Separator } from "@/components/ui/separator"; -import { SettingsView } from "./views/settings"; -import { Captions } from "./views/captions"; - -export function MediaPanel() { - const { activeTab } = useMediaPanelStore(); - - const viewMap: Record = { - media: , - sounds: , - text: , - stickers: , - effects: ( -
- Effects view coming soon... -
- ), - transitions: ( -
- Transitions view coming soon... -
- ), - captions: , - filters: ( -
- Filters view coming soon... -
- ), - adjustment: ( -
- Adjustment view coming soon... -
- ), - settings: , - }; - - return ( -
- - -
{viewMap[activeTab]}
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts deleted file mode 100644 index eb645ec3..00000000 --- a/apps/web/src/components/editor/media-panel/store.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - CaptionsIcon, - ArrowLeftRightIcon, - SparklesIcon, - StickerIcon, - MusicIcon, - VideoIcon, - BlendIcon, - SlidersHorizontalIcon, - LucideIcon, - TypeIcon, - SettingsIcon, -} from "lucide-react"; -import { create } from "zustand"; - -export type Tab = - | "media" - | "sounds" - | "text" - | "stickers" - | "effects" - | "transitions" - | "captions" - | "filters" - | "adjustment" - | "settings"; - -export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { - media: { - icon: VideoIcon, - label: "Media", - }, - sounds: { - icon: MusicIcon, - label: "Sounds", - }, - text: { - icon: TypeIcon, - label: "Text", - }, - stickers: { - icon: StickerIcon, - label: "Stickers", - }, - effects: { - icon: SparklesIcon, - label: "Effects", - }, - transitions: { - icon: ArrowLeftRightIcon, - label: "Transitions", - }, - captions: { - icon: CaptionsIcon, - label: "Captions", - }, - filters: { - icon: BlendIcon, - label: "Filters", - }, - adjustment: { - icon: SlidersHorizontalIcon, - label: "Adjustment", - }, - settings: { - icon: SettingsIcon, - label: "Settings", - }, -}; - -interface MediaPanelStore { - activeTab: Tab; - setActiveTab: (tab: Tab) => void; - highlightMediaId: string | null; - requestRevealMedia: (mediaId: string) => void; - clearHighlight: () => void; -} - -export const useMediaPanelStore = create((set) => ({ - activeTab: "media", - setActiveTab: (tab) => set({ activeTab: tab }), - highlightMediaId: null, - requestRevealMedia: (mediaId) => - set({ activeTab: "media", highlightMediaId: mediaId }), - clearHighlight: () => set({ highlightMediaId: null }), -})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx deleted file mode 100644 index e2bfdff7..00000000 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Tab, tabs, useMediaPanelStore } from "./store"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEffect, useRef, useState } from "react"; - -export function TabBar() { - const { activeTab, setActiveTab } = useMediaPanelStore(); - const scrollRef = useRef(null); - const [showTopFade, setShowTopFade] = useState(false); - const [showBottomFade, setShowBottomFade] = useState(false); - - const checkScrollPosition = () => { - const element = scrollRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - setShowTopFade(scrollTop > 0); - setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); - }; - - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - checkScrollPosition(); - element.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(element); - - return () => { - element.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, []); - - return ( -
-
- {(Object.keys(tabs) as Tab[]).map((tabKey) => { - const tab = tabs[tabKey]; - return ( -
setActiveTab(tabKey)} - key={tabKey} - > - - - - - -
- {tab.label} -
-
-
-
- ); - })} -
- - - -
- ); -} - -function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) { - return ( -
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx deleted file mode 100644 index fbe5b418..00000000 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { PropertyGroup } from "../../properties-panel/property-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { Language, LanguageSelect } from "@/components/language-select"; -import { useState, useRef, useEffect } from "react"; -import { extractTimelineAudio } from "@/lib/mediabunny-utils"; -import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import { Loader2, Shield, Trash2, Upload } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { TextElement } from "@/types/timeline"; - -export const languages: Language[] = [ - { code: "US", name: "English" }, - { code: "ES", name: "Spanish" }, - { code: "IT", name: "Italian" }, - { code: "FR", name: "French" }, - { code: "DE", name: "German" }, - { code: "PT", name: "Portuguese" }, - { code: "RU", name: "Russian" }, - { code: "JP", name: "Japanese" }, - { code: "CN", name: "Chinese" }, -]; - -const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted"; - -export function Captions() { - const [selectedCountry, setSelectedCountry] = useState("auto"); - const [isProcessing, setIsProcessing] = useState(false); - const [processingStep, setProcessingStep] = useState(""); - const [error, setError] = useState(null); - const [showPrivacyDialog, setShowPrivacyDialog] = useState(false); - const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false); - const containerRef = useRef(null); - const { insertTrackAt, addElementToTrack } = useTimelineStore(); - - // Check if user has already accepted privacy on mount - useEffect(() => { - const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true"; - setHasAcceptedPrivacy(hasAccepted); - }, []); - - const handleGenerateTranscript = async () => { - try { - setIsProcessing(true); - setError(null); - setProcessingStep("Extracting audio..."); - - const audioBlob = await extractTimelineAudio(); - - setProcessingStep("Encrypting audio..."); - - // Encrypt the audio with a random key (zero-knowledge) - const audioBuffer = await audioBlob.arrayBuffer(); - const encryptionResult = await encryptWithRandomKey(audioBuffer); - - // Convert encrypted data to blob for upload - const encryptedBlob = new Blob([encryptionResult.encryptedData]); - - setProcessingStep("Uploading..."); - const uploadResponse = await fetch("/api/get-upload-url", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileExtension: "wav" }), - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json(); - throw new Error(error.message || "Failed to get upload URL"); - } - - const { uploadUrl, fileName } = await uploadResponse.json(); - - // Upload to R2 - await fetch(uploadUrl, { - method: "PUT", - body: encryptedBlob, - }); - - setProcessingStep("Transcribing..."); - - // Call Modal transcription API with encryption parameters - const transcriptionResponse = await fetch("/api/transcribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: fileName, - language: - selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(), - // Send the raw encryption key and IV (zero-knowledge) - decryptionKey: arrayBufferToBase64(encryptionResult.key), - iv: arrayBufferToBase64(encryptionResult.iv), - }), - }); - - if (!transcriptionResponse.ok) { - const error = await transcriptionResponse.json(); - throw new Error(error.message || "Transcription failed"); - } - - const { text, segments } = await transcriptionResponse.json(); - - console.log("Transcription completed:", { text, segments }); - - const shortCaptions: Array<{ - text: string; - startTime: number; - duration: number; - }> = []; - - let globalEndTime = 0; // Track the end time of the last caption globally - - segments.forEach((segment: any) => { - const words = segment.text.trim().split(/\s+/); - const segmentDuration = segment.end - segment.start; - const wordsPerSecond = words.length / segmentDuration; - - // Split into chunks of 2-4 words - const chunks: string[] = []; - for (let i = 0; i < words.length; i += 3) { - chunks.push(words.slice(i, i + 3).join(" ")); - } - - // Calculate timing for each chunk to place them sequentially - let chunkStartTime = segment.start; - chunks.forEach((chunk) => { - const chunkWords = chunk.split(/\s+/).length; - const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk - - let adjustedStartTime = chunkStartTime; - - // Prevent overlapping: if this caption would start before the last one ends, - // start it right after the last one ends - if (adjustedStartTime < globalEndTime) { - adjustedStartTime = globalEndTime; - } - - shortCaptions.push({ - text: chunk, - startTime: adjustedStartTime, - duration: chunkDuration, - }); - - // Update global end time - globalEndTime = adjustedStartTime + chunkDuration; - - // Next chunk starts when this one ends (for within-segment timing) - chunkStartTime += chunkDuration; - }); - }); - - // Create a single track for all captions - const captionTrackId = insertTrackAt("text", 0); - - // Add all caption elements to the same track - shortCaptions.forEach((caption, index) => { - addElementToTrack(captionTrackId, { - ...DEFAULT_TEXT_ELEMENT, - name: `Caption ${index + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, // Larger for captions - fontWeight: "bold", // Bold for captions - } as TextElement); - }); - - console.log( - `✅ ${shortCaptions.length} short-form caption chunks added to timeline!` - ); - } catch (error) { - console.error("Transcription failed:", error); - setError( - error instanceof Error ? error.message : "An unexpected error occurred" - ); - } finally { - setIsProcessing(false); - setProcessingStep(""); - } - }; - - return ( - - - - - -
- {error && ( -
-

{error}

-
- )} - - - - - - - - - Audio Processing Notice - - -

- To generate captions, we need to process your timeline audio - using speech-to-text technology. -

- -
-
- - - Zero-knowledge encryption - we cannot decrypt your files - even if we wanted to - -
- -
- - - Encryption keys generated randomly in your browser, never - stored anywhere - -
- -
- - - Audio encrypted before upload - raw audio never leaves - your device - -
- -
- - - Everything permanently deleted within seconds after - transcription - -
-
- -

- True zero-knowledge privacy: Encryption keys - are generated randomly in your browser and never stored - anywhere. It's cryptographically impossible for us, our cloud - providers, or anyone else to decrypt your audio files. -

-
-
- - - - -
-
-
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx deleted file mode 100644 index 07a44a7c..00000000 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ /dev/null @@ -1,545 +0,0 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore } from "@/stores/media-store"; -import { MediaFile } from "@/types/media"; -import { - ArrowDown01, - CloudUpload, - Grid2X2, - Image, - List, - Loader2, - Music, - Video, -} from "lucide-react"; -import { useRef, useState, useMemo } from "react"; -import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { usePanelStore } from "@/stores/panel-store"; -import { useMediaPanelStore } from "../store"; - -function MediaItemWithContextMenu({ - item, - children, - onRemove, -}: { - item: MediaFile; - children: React.ReactNode; - onRemove: (e: React.MouseEvent, id: string) => Promise; -}) { - return ( - - {children} - - Export clips - onRemove(e, item.id)} - > - Delete - - - - ); -} - -export function MediaView() { - const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { mediaViewMode, setMediaViewMode } = usePanelStore(); - const fileInputRef = useRef(null); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( - "name" - ); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const { highlightMediaId, clearHighlight } = useMediaPanelStore(); - const { highlightedId, registerElement } = useHighlightScroll( - highlightMediaId, - clearHighlight - ); - - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles(files, (p) => - setProgress(p) - ); - for (const item of processedItems) { - await addMediaFile(activeProject.id, item); - } - } catch (error) { - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - await removeMediaFile(activeProject.id, id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const filteredMediaItems = useMemo(() => { - let filtered = mediaFiles.filter((item) => { - if (item.ephemeral) return false; - return true; - }); - - filtered.sort((a, b) => { - let valueA: string | number; - let valueB: string | number; - - switch (sortBy) { - case "name": - valueA = a.name.toLowerCase(); - valueB = b.name.toLowerCase(); - break; - case "type": - valueA = a.type; - valueB = b.type; - break; - case "duration": - valueA = a.duration || 0; - valueB = b.duration || 0; - break; - case "size": - valueA = a.file.size; - valueB = b.file.size; - break; - default: - return 0; - } - - if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; - if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); - - return filtered; - }, [mediaFiles, sortBy, sortOrder]); - - const previewComponents = useMemo(() => { - const previews = new Map(); - - filteredMediaItems.forEach((item) => { - let preview: React.ReactNode; - - if (item.type === "image") { - preview = ( -
- {item.name} -
- ); - } else if (item.type === "video") { - if (item.thumbnailUrl) { - preview = ( -
- {item.name} -
-
- {item.duration && ( -
- {formatDuration(item.duration)} -
- )} -
- ); - } else { - preview = ( -
-
- ); - } - } else if (item.type === "audio") { - preview = ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } else { - preview = ( -
- - Unknown -
- ); - } - - previews.set(item.id, preview); - }); - - return previews; - }, [filteredMediaItems]); - - const renderPreview = (item: MediaFile) => previewComponents.get(item.id); - - return ( - <> - {/* Hidden file input for uploading media */} - - -
-
- {/* Search and filter controls */} -
- -
- - - - - - -

- {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} -

-
- - - - - - - - - { - if (sortBy === "name") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("name"); - setSortOrder("asc"); - } - }} - > - Name{" "} - {sortBy === "name" && - (sortOrder === "asc" ? "↑" : "↓")} - - { - if (sortBy === "type") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("type"); - setSortOrder("asc"); - } - }} - > - Type{" "} - {sortBy === "type" && - (sortOrder === "asc" ? "↑" : "↓")} - - { - if (sortBy === "duration") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("duration"); - setSortOrder("asc"); - } - }} - > - Duration{" "} - {sortBy === "duration" && - (sortOrder === "asc" ? "↑" : "↓")} - - { - if (sortBy === "size") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("size"); - setSortOrder("asc"); - } - }} - > - File Size{" "} - {sortBy === "size" && - (sortOrder === "asc" ? "↑" : "↓")} - - - - -

- Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) -

-
-
-
-
-
-
-
- -
-
- {isDragOver || filteredMediaItems.length === 0 ? ( - - ) : mediaViewMode === "grid" ? ( - - ) : ( - - )} -
-
-
- - ); -} - -function GridView({ - filteredMediaItems, - renderPreview, - handleRemove, - highlightedId, - registerElement, -}: { - filteredMediaItems: MediaFile[]; - renderPreview: (item: MediaFile) => React.ReactNode; - handleRemove: (e: React.MouseEvent, id: string) => Promise; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - const { addElementAtTime } = useTimelineStore(); - - return ( -
- {filteredMediaItems.map((item) => ( -
registerElement(item.id, el)}> - - - addElementAtTime(item, currentTime) - } - rounded={false} - variant="card" - isHighlighted={highlightedId === item.id} - /> - -
- ))} -
- ); -} - -function ListView({ - filteredMediaItems, - renderPreview, - handleRemove, - highlightedId, - registerElement, -}: { - filteredMediaItems: MediaFile[]; - renderPreview: (item: MediaFile) => React.ReactNode; - handleRemove: (e: React.MouseEvent, id: string) => Promise; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - const { addElementAtTime } = useTimelineStore(); - - return ( -
- {filteredMediaItems.map((item) => ( -
registerElement(item.id, el)}> - - - addElementAtTime(item, currentTime) - } - variant="compact" - isHighlighted={highlightedId === item.id} - /> - -
- ))} -
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx deleted file mode 100644 index be54a9c4..00000000 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ /dev/null @@ -1,332 +0,0 @@ -"use client"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, - PropertyGroup, -} from "../../properties-panel/property-item"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import type { BlurIntensity } from "@/types/project"; -import { useEditorStore } from "@/stores/editor-store"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; -import Image from "next/image"; -import { cn } from "@/lib/utils"; -import { colors } from "@/data/colors/solid"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { PipetteIcon, PlusIcon } from "lucide-react"; -import { useMemo, memo, useCallback } from "react"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; - -export function SettingsView() { - return ; -} - -function ProjectSettingsTabs() { - return ( - - -
- ), - }, - { - value: "background", - label: "Background", - content: ( -
-
- -
-
- - -
- - {/* Another UI, looks so beautiful i don't wanna remove it */} - {/*
- -
*/} -
- ), - }, - ]} - className="flex flex-col justify-between h-full p-0" - /> - ); -} - -function ProjectInfoView() { - const { activeProject, updateProjectFps, updateCanvasSize } = - useProjectStore(); - const { canvasPresets } = useEditorStore(); - const { getDisplayName } = useAspectRatio(); - - const handleAspectRatioChange = (value: string) => { - const preset = canvasPresets.find((p) => p.name === value); - if (preset) { - updateCanvasSize( - { width: preset.width, height: preset.height }, - "preset" - ); - } - }; - - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - updateProjectFps(fps); - }; - - return ( -
- - Name - - {activeProject?.name || "Untitled project"} - - - - - Aspect ratio - - - - - - - Frame rate - - - - -
- ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( -
- {`Blur -
- - {blur.label} - -
-
- ) -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - handleColorSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - handleColorSelect: (bg: string) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg, index) => ( -
handleColorSelect(bg)} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - handleColorSelect, - useBackgroundColor, - ] - ); - } -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -function BackgroundView() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - const blurLevels = useMemo>( - () => [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ], - [] - ); - - const handleBlurSelect = useCallback( - async (blurIntensity: BlurIntensity) => { - await updateBackgroundType("blur", { blurIntensity }); - }, - [updateBackgroundType] - ); - - const handleColorSelect = useCallback( - async (color: string) => { - await updateBackgroundType("color", { backgroundColor: color }); - }, - [updateBackgroundType] - ); - - const currentBlurIntensity = activeProject?.blurIntensity || 8; - const isBlurBackground = activeProject?.backgroundType === "blur"; - const currentBackgroundColor = activeProject?.backgroundColor || "#000000"; - const isColorBackground = activeProject?.backgroundType === "color"; - - const blurPreviews = useMemo( - () => - blurLevels.map((blur) => ( - handleBlurSelect(blur.value)} - /> - )), - [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] - ); - - return ( -
- -
{blurPreviews}
-
- - -
-
- -
- -
-
- - -
- -
-
- - -
- -
-
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx deleted file mode 100644 index 700eaea7..00000000 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ /dev/null @@ -1,562 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState, useMemo, useEffect } from "react"; -import { Separator } from "@/components/ui/separator"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - PlayIcon, - PauseIcon, - HeartIcon, - PlusIcon, - ListFilter, -} from "lucide-react"; -import { useSoundsStore } from "@/stores/sounds-store"; -import { useSoundSearch } from "@/hooks/use-sound-search"; -import type { SoundEffect, SavedSound } from "@/types/sounds"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, -} from "@/components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; - -export function SoundsView() { - return ( -
- -
- - Sound effects - Songs - Saved - -
- - - - - - - - - - -
-
- ); -} - -function SoundEffectsView() { - const { - topSoundEffects, - isLoading, - searchQuery, - setSearchQuery, - scrollPosition, - setScrollPosition, - loadSavedSounds, - isSoundSaved, - toggleSavedSound, - showCommercialOnly, - toggleCommercialFilter, - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - const { - results: searchResults, - isLoading: isSearching, - loadMore, - hasNextPage, - isLoadingMore, - } = useSoundSearch(searchQuery, showCommercialOnly); - - // Audio playback state - const [playingId, setPlayingId] = useState(null); - const [audioElement, setAudioElement] = useState( - null - ); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: loadMore, - hasMore: hasNextPage, - isLoading: isLoadingMore || isSearching, - }); - - useEffect(() => { - loadSavedSounds(); - - if (!hasLoaded) { - let ignore = false; - - const fetchTopSounds = async () => { - try { - if (!ignore) { - setLoading(true); - setError(null); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads" - ); - - if (!ignore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects(data.results); - setHasLoaded(true); - - setCurrentPage(1); - setHasNextPage(!!data.next); - setTotalCount(data.count); - } - } catch (error) { - if (!ignore) { - console.error("Failed to fetch top sounds:", error); - setError( - error instanceof Error ? error.message : "Failed to load sounds" - ); - } - } finally { - if (!ignore) { - setLoading(false); - } - } - }; - - const timeoutId = setTimeout(fetchTopSounds, 100); - - return () => { - clearTimeout(timeoutId); - ignore = true; - }; - } - - if (scrollAreaRef.current && scrollPosition > 0) { - const timeoutId = setTimeout(() => { - scrollAreaRef.current?.scrollTo({ top: scrollPosition }); - }, 100); - - return () => clearTimeout(timeoutId); - } - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); - - const handleScrollWithPosition = (event: React.UIEvent) => { - const { scrollTop } = event.currentTarget; - setScrollPosition(scrollTop); - handleScroll(event); - }; - - const displayedSounds = useMemo(() => { - const sounds = searchQuery ? searchResults : topSoundEffects; - return sounds; - }, [searchQuery, searchResults, topSoundEffects]); - - const playSound = (sound: SoundEffect) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - // Stop previous sound - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", (e) => { - setPlayingId(null); - }); - audio.play().catch((error) => { - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - return ( -
-
- setSearchQuery(e.target.value)} - showClearIcon - onClear={() => setSearchQuery("")} - /> - - - - - - - Show only commercially licensed - -
- {showCommercialOnly - ? "Only showing sounds licensed for commercial use" - : "Showing all sounds regardless of license"} -
-
-
-
- -
- -
- {isLoading && !searchQuery && ( -
- Loading sounds... -
- )} - {isSearching && searchQuery && ( -
Searching...
- )} - {displayedSounds.map((sound) => ( - playSound(sound)} - isSaved={isSoundSaved(sound.id)} - onToggleSaved={() => toggleSavedSound(sound)} - /> - ))} - {!isLoading && !isSearching && displayedSounds.length === 0 && ( -
- {searchQuery ? "No sounds found" : "No sounds available"} -
- )} - {isLoadingMore && ( -
- Loading more sounds... -
- )} -
-
-
-
- ); -} - -function SavedSoundsView() { - const { - savedSounds, - isLoadingSavedSounds, - savedSoundsError, - loadSavedSounds, - isSoundSaved, - toggleSavedSound, - clearSavedSounds, - } = useSoundsStore(); - - // Audio playback state - const [playingId, setPlayingId] = useState(null); - const [audioElement, setAudioElement] = useState( - null - ); - - // Clear confirmation dialog state - const [showClearDialog, setShowClearDialog] = useState(false); - - // Load saved sounds when tab becomes active - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - const playSound = (sound: SavedSound) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - // Stop previous sound - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", (e) => { - setPlayingId(null); - }); - audio.play().catch((error) => { - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - // Convert SavedSound to SoundEffect for compatibility with AudioItem - const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({ - id: savedSound.id, - name: savedSound.name, - description: "", - url: "", - previewUrl: savedSound.previewUrl, - downloadUrl: savedSound.downloadUrl, - duration: savedSound.duration, - filesize: 0, - type: "audio", - channels: 0, - bitrate: 0, - bitdepth: 0, - samplerate: 0, - username: savedSound.username, - tags: savedSound.tags, - license: savedSound.license, - created: savedSound.savedAt, - downloads: 0, - rating: 0, - ratingCount: 0, - }); - - if (isLoadingSavedSounds) { - return ( -
-
- Loading saved sounds... -
-
- ); - } - - if (savedSoundsError) { - return ( -
-
- Error: {savedSoundsError} -
-
- ); - } - - if (savedSounds.length === 0) { - return ( -
- -
-

No saved sounds

-

- Click the heart icon on any sound to save it here -

-
-
- ); - } - - return ( -
-
-

- {savedSounds.length} saved{" "} - {savedSounds.length === 1 ? "sound" : "sounds"} -

- - - - - - - Clear all saved sounds? - - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - - - - - - - - -
- -
- -
- {savedSounds.map((sound) => ( - playSound(sound)} - isSaved={isSoundSaved(sound.id)} - onToggleSaved={() => - toggleSavedSound(convertToSoundEffect(sound)) - } - /> - ))} -
-
-
-
- ); -} - -function SongsView() { - return
Songs
; -} - -interface AudioItemProps { - sound: SoundEffect; - isPlaying: boolean; - onPlay: () => void; - isSaved: boolean; - onToggleSaved: () => void; -} - -function AudioItem({ - sound, - isPlaying, - onPlay, - isSaved, - onToggleSaved, -}: AudioItemProps) { - const { addSoundToTimeline } = useSoundsStore(); - - const handleClick = () => { - onPlay(); - }; - - const handleSaveClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleSaved(); - }; - - const handleAddToTimeline = async (e: React.MouseEvent) => { - e.stopPropagation(); - await addSoundToTimeline(sound); - }; - - return ( -
-
-
- {isPlaying ? ( - - ) : ( - - )} -
- -
-

{sound.name}

- - {sound.username} - -
- -
- - -
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx deleted file mode 100644 index f67450cc..00000000 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ /dev/null @@ -1,612 +0,0 @@ -"use client"; - -import { useEffect, useState, useMemo } from "react"; -import { useStickersStore } from "@/stores/stickers-store"; -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 { - Loader2, - Grid3X3, - Hash, - Smile, - Clock, - X, - Sparkles, - ArrowRight, - StickerIcon, -} from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -import { - getIconSvgUrl, - buildIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; -import { cn } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import Image from "next/image"; -import type { MediaFile } from "@/types/media"; -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { InputWithBack } from "@/components/ui/input-with-back"; -import { StickerCategory } from "@/stores/stickers-store"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; - -export function StickersView() { - const { selectedCategory, setSelectedCategory } = useStickersStore(); - - return ( - { - if (["all", "general", "brands", "emoji"].includes(v)) { - setSelectedCategory(v as StickerCategory); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: , - content: , - }, - { - value: "general", - label: "Icons", - icon: , - content: , - }, - { - value: "brands", - label: "Brands", - icon: , - content: , - }, - { - value: "emoji", - label: "Emoji", - icon: , - content: , - }, - ]} - className="flex flex-col h-full p-0 overflow-hidden" - /> - ); -} - -function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, -}: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; -}) { - return ( -
- {icons.map((iconName) => ( - - ))} -
- ); -} - -function CollectionGrid({ - collections, - onSelectCollection, -}: { - collections: Array<{ - prefix: string; - name: string; - total: number; - category?: string; - }>; - onSelectCollection: (prefix: string) => void; -}) { - return ( -
- {collections.map((collection) => ( - onSelectCollection(collection.prefix)} - /> - ))} -
- ); -} - -function EmptyView({ message }: { message: string }) { - return ( -
- -
-

No stickers found

-

{message}

-
-
- ); -} - -function StickersContentView({ category }: { category: StickerCategory }) { - const { activeProject } = useProjectStore(); - const { addElementAtTime } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - const { addMediaFile } = useMediaStore(); - const { - searchQuery, - selectedCollection, - viewMode, - collections, - currentCollection, - searchResults, - recentStickers, - isLoadingCollections, - isLoadingCollection, - isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, - clearRecentStickers, - setSelectedCategory, - addingSticker, - } = useStickersStore(); - - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - const filteredCollections = useMemo(() => { - if (category === "all") { - return Object.entries(collections).map(([prefix, collection]) => ({ - prefix, - name: collection.name, - total: collection.total, - category: collection.category, - })); - } - - const collectionList = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - name: c.name, - total: collection.total, - } - : null; - }) - .filter(Boolean) as Array<{ - prefix: string; - name: string; - total: number; - }>; - }, [collections, category]); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: () => setCollectionsToShow((prev) => prev + 20), - hasMore: filteredCollections.length > collectionsToShow, - isLoading: isLoadingCollections, - enabled: viewMode === "browse" && !selectedCollection && category === "all", - }); - - useEffect(() => { - if (Object.keys(collections).length === 0) { - loadCollections(); - } - }, []); - - useEffect(() => { - const timer = setTimeout(() => { - if (localSearchQuery !== searchQuery) { - setSearchQuery(localSearchQuery); - if (localSearchQuery.trim()) { - searchStickers(localSearchQuery); - } - } - }, 500); - - return () => clearTimeout(timer); - }, [localSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline(iconName); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { - if (viewMode === "search" && searchResults) { - return searchResults.icons; - } - - if (viewMode === "collection" && currentCollection) { - const icons: string[] = []; - - if (currentCollection.uncategorized) { - icons.push( - ...currentCollection.uncategorized.map( - (name) => `${currentCollection.prefix}:${name}` - ) - ); - } - - if (currentCollection.categories) { - Object.values(currentCollection.categories).forEach((categoryIcons) => { - icons.push( - ...categoryIcons.map( - (name) => `${currentCollection.prefix}:${name}` - ) - ); - }); - } - - return icons.slice(0, 100); - } - - return []; - }, [viewMode, searchResults, currentCollection]); - - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); - } - }, [isInCollection]); - - return ( -
-
- { - if (!expanded && isInCollection) { - setSelectedCollection(null); - } - }} - placeholder={ - category === "all" - ? "Search all stickers" - : category === "general" - ? "Search icons" - : category === "brands" - ? "Search brands" - : "Search Emojis" - } - value={localSearchQuery} - onChange={setLocalSearchQuery} - disableAnimation={true} - /> -
- -
- -
- {recentStickers.length > 0 && viewMode === "browse" && ( -
-
- - Recent - - - - - - -

Clear recent stickers

-
-
-
-
- -
- )} - - {viewMode === "collection" && selectedCollection && ( -
- {isLoadingCollection ? ( -
- -
- ) : showCollectionItems ? ( - - ) : ( -
- -
- )} -
- )} - - {viewMode === "search" && ( -
- {isSearching ? ( -
- -
- ) : searchResults?.icons.length ? ( - <> -
- - {searchResults.total} results - -
- - - ) : searchQuery ? ( -
- - {category !== "all" && ( - - )} -
- ) : null} -
- )} - - {viewMode === "browse" && !selectedCollection && ( -
- {isLoadingCollections ? ( -
- -
- ) : ( - <> - {category !== "all" && ( -
-

- Popular{" "} - {category === "general" - ? "Icon Sets" - : category === "brands" - ? "Brand Icons" - : "Emoji Sets"} -

- -
- )} - - {category === "all" && filteredCollections.length > 0 && ( -
- -
- )} - - )} -
- )} -
-
-
-
- ); -} - -interface CollectionItemProps { - title: string; - subtitle: string; - onClick: () => void; -} - -function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { - return ( - - ); -} - -interface StickerItemProps { - iconName: string; - onAdd: (iconName: string) => void; - isAdding?: boolean; - capSize?: boolean; -} - -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); - - useEffect(() => { - setImageError(false); - setHostIndex(0); - }, [iconName]); - - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; - - const preview = imageError ? ( -
- - {displayName} - -
- ) : ( -
- {displayName} { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } - }} - loading="lazy" - unoptimized - /> -
- ); - - return ( - - -
- onAdd(iconName)} - aspectRatio={1} - showLabel={false} - rounded={true} - variant="card" - className="" - containerClassName="w-full" - isDraggable={false} - /> - {isAdding && ( -
- -
- )} -
-
- -
-

{displayName}

-

{collectionPrefix}

-
-
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx deleted file mode 100644 index 22fc2413..00000000 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; - -export function TextView() { - return ( - - - Default text -
- } - dragData={{ - id: "temp-text-id", - type: DEFAULT_TEXT_ELEMENT.type, - name: DEFAULT_TEXT_ELEMENT.name, - content: DEFAULT_TEXT_ELEMENT.content, - }} - aspectRatio={1} - onAddToTimeline={(currentTime) => - useTimelineStore.getState().addElementAtTime( - { - ...DEFAULT_TEXT_ELEMENT, - id: "temp-text-id", - }, - currentTime - ) - } - showLabel={false} - /> - - ); -} diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx index 8ee60690..c60c2de5 100644 --- a/apps/web/src/components/editor/onboarding.tsx +++ b/apps/web/src/components/editor/onboarding.tsx @@ -1,138 +1,137 @@ "use client"; -import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; -import { Button } from "../ui/button"; import { ArrowRightIcon } from "lucide-react"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import ReactMarkdown from "react-markdown"; +import { SOCIAL_LINKS } from "@/constants/site-constants"; +import { useLocalStorage } from "@/hooks/storage/use-local-storage"; +import { Button } from "../ui/button"; +import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog"; export function Onboarding() { - const [step, setStep] = useState(0); - const [isOpen, setIsOpen] = useState(false); + const [step, setStep] = useState(0); + const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({ + key: "hasSeenOnboarding", + defaultValue: false, + }); - useEffect(() => { - const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding"); - if (!hasSeenOnboarding) { - setIsOpen(true); - } - }, []); + const isOpen = !hasSeenOnboarding; - const handleNext = () => { - setStep(step + 1); - }; + const handleNext = () => { + setStep(step + 1); + }; - const handleClose = () => { - setIsOpen(false); - localStorage.setItem("hasSeenOnboarding", "true"); - }; + const handleClose = () => { + setHasSeenOnboarding({ value: true }); + }; - const getStepTitle = () => { - switch (step) { - case 0: - return "Welcome to OpenCut Beta! 🎉"; - case 1: - return "⚠️ This is a super early beta!"; - case 2: - return "🦋 Have fun testing!"; - default: - return "OpenCut Onboarding"; - } - }; + const getStepTitle = () => { + switch (step) { + case 0: + return "Welcome to OpenCut Beta! 🎉"; + case 1: + return "⚠️ This is a super early beta!"; + case 2: + return "🦋 Have fun testing!"; + default: + return "OpenCut Onboarding"; + } + }; - const renderStepContent = () => { - switch (step) { - case 0: - return ( -
-
- - <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." /> - </div> - <NextButton onClick={handleNext}>Next</NextButton> - </div> - ); - case 1: - return ( - <div className="space-y-5"> - <div className="space-y-3"> - <Title title={getStepTitle()} /> - <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." /> - <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" /> - <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" /> - </div> - <NextButton onClick={handleNext}>Next</NextButton> - </div> - ); - case 2: - return ( - <div className="space-y-5"> - <div className="space-y-3"> - <Title title={getStepTitle()} /> - <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." /> - </div> - <NextButton onClick={handleClose}>Finish</NextButton> - </div> - ); - default: - return null; - } - }; + const renderStepContent = () => { + switch (step) { + case 0: + return ( + <div className="space-y-5"> + <div className="space-y-3"> + <Title title="Welcome to OpenCut Beta! 🎉" /> + <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." /> + </div> + <NextButton onClick={handleNext}>Next</NextButton> + </div> + ); + case 1: + return ( + <div className="space-y-5"> + <div className="space-y-3"> + <Title title={getStepTitle()} /> + <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." /> + <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" /> + <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" /> + </div> + <NextButton onClick={handleNext}>Next</NextButton> + </div> + ); + case 2: + return ( + <div className="space-y-5"> + <div className="space-y-3"> + <Title title={getStepTitle()} /> + <Description + description={`Join our [Discord](${SOCIAL_LINKS.discord}), chat with cool people and share feedback to help make OpenCut the best editor ever.`} + /> + </div> + <NextButton onClick={handleClose}>Finish</NextButton> + </div> + ); + default: + return null; + } + }; - return ( - <Dialog open={isOpen} onOpenChange={handleClose}> - <DialogContent className="sm:max-w-[425px] !outline-none pt-2"> - <DialogTitle> - <span className="sr-only">{getStepTitle()}</span> - </DialogTitle> - {renderStepContent()} - </DialogContent> - </Dialog> - ); + return ( + <Dialog open={isOpen} onOpenChange={handleClose}> + <DialogContent className="sm:max-w-[425px]"> + <DialogTitle> + <span className="sr-only">{getStepTitle()}</span> + </DialogTitle> + <DialogBody> + {renderStepContent()} + </DialogBody> + </DialogContent> + </Dialog> + ); } function Title({ title }: { title: string }) { - return <h2 className="text-lg md:text-xl font-bold">{title}</h2>; -} - -function Subtitle({ subtitle }: { subtitle: string }) { - return <h3 className="text-lg font-medium">{subtitle}</h3>; + return <h2 className="text-lg font-bold md:text-xl">{title}</h2>; } function Description({ description }: { description: string }) { - return ( - <div className="text-muted-foreground"> - <ReactMarkdown - components={{ - p: ({ children }) => <p className="mb-0">{children}</p>, - a: ({ href, children }) => ( - <a - href={href} - target="_blank" - rel="noopener noreferrer" - className="text-foreground hover:text-foreground/80 underline" - > - {children} - </a> - ), - }} - > - {description} - </ReactMarkdown> - </div> - ); + return ( + <div className="text-muted-foreground"> + <ReactMarkdown + components={{ + p: ({ children }) => <p className="mb-0">{children}</p>, + a: ({ href, children }) => ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className="text-foreground hover:text-foreground/80 underline" + > + {children} + </a> + ), + }} + > + {description} + </ReactMarkdown> + </div> + ); } function NextButton({ - children, - onClick, + children, + onClick, }: { - children: React.ReactNode; - onClick: () => void; + children: React.ReactNode; + onClick: () => void; }) { - return ( - <Button onClick={onClick} variant="default" className="w-full"> - {children} - <ArrowRightIcon className="w-4 h-4" /> - </Button> - ); + return ( + <Button onClick={onClick} variant="default" className="w-full"> + {children} + <ArrowRightIcon className="size-4" /> + </Button> + ); } diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx deleted file mode 100644 index 6e92c616..00000000 --- a/apps/web/src/components/editor/panel-base-view.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/lib/utils"; - -interface PanelBaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - value?: string; - onValueChange?: (value: string) => void; - tabs?: { - value: string; - label: string; - icon?: React.ReactNode; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.RefObject<HTMLDivElement>; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <ScrollArea className="flex-1"> - <div className={cn("p-5", className)}>{children}</div> - </ScrollArea> - ); -} - -export function PanelBaseView({ - children, - defaultTab, - value, - onValueChange, - tabs, - className = "", - ref, -}: PanelBaseViewProps) { - return ( - <div className={cn("h-full flex flex-col", className)} ref={ref}> - {!tabs || tabs.length === 0 ? ( - <ViewContent className={className}>{children}</ViewContent> - ) : ( - <Tabs - defaultValue={defaultTab} - value={value} - onValueChange={onValueChange} - className="flex flex-col h-full" - > - <div className="sticky top-0 z-10 bg-panel"> - <div className="px-3 pt-3.5 pb-0"> - <TabsList> - {tabs.map((tab) => ( - <TabsTrigger key={tab.value} value={tab.value}> - {tab.icon ? ( - <span className="inline-flex items-center mr-1"> - {tab.icon} - </span> - ) : null} - {tab.label} - </TabsTrigger> - ))} - </TabsList> - </div> - <Separator className="mt-3.5" /> - </div> - {tabs.map((tab) => ( - <TabsContent - key={tab.value} - value={tab.value} - className="mt-0 flex-1 flex flex-col min-h-0" - > - <ViewContent className={className}>{tab.content}</ViewContent> - </TabsContent> - ))} - </Tabs> - )} - </div> - ); -} diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx deleted file mode 100644 index af22a171..00000000 --- a/apps/web/src/components/editor/panel-preset-selector.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; -import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; - -const PRESET_LABELS: Record<PanelPreset, string> = { - default: "Default", - media: "Media", - inspector: "Inspector", - "vertical-preview": "Vertical Preview", -}; - -const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = { - default: "Media, preview, and inspector on top row, timeline on bottom", - media: "Full height media on left, preview and inspector on top row", - inspector: "Full height inspector on right, media and preview on top row", - "vertical-preview": "Full height preview on right for vertical videos", -}; - -export function PanelPresetSelector() { - const { activePreset, setActivePreset, resetPreset } = usePanelStore(); - - const handlePresetChange = (preset: PanelPreset) => { - setActivePreset(preset); - }; - - const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { - event.stopPropagation(); - resetPreset(preset); - }; - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="secondary" - size="sm" - className="h-8 px-2 flex items-center gap-1 text-xs" - title="Panel Presets" - > - <LayoutPanelTop className="h-4 w-4" /> - <ChevronDown className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="w-64"> - <div className="px-3 py-2 text-xs font-medium text-muted-foreground"> - Panel Presets - </div> - <DropdownMenuSeparator /> - {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( - <DropdownMenuItem - key={preset} - onClick={() => handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" - > - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-medium text-sm"> - {PRESET_LABELS[preset]} - </span> - {activePreset === preset && ( - <div className="h-2 w-2 rounded-full bg-primary" /> - )} - </div> - <p className="text-xs text-muted-foreground mt-0.5 leading-tight"> - {PRESET_DESCRIPTIONS[preset]} - </p> - </div> - <Button - variant="secondary" - size="icon" - className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100" - onClick={(e) => handleResetPreset(preset, e)} - title={`Reset ${PRESET_LABELS[preset]} preset`} - > - <RotateCcw className="h-3 w-3" /> - </Button> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx new file mode 100644 index 00000000..c6b0861c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -0,0 +1,61 @@ +import { HugeiconsIcon } from "@hugeicons/react"; +import { UploadIcon } from "@hugeicons/core-free-icons"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = ({ + event, + }: { + event: React.MouseEvent<HTMLButtonElement>; + }) => { + if (isProcessing || !onClick) return; + event.preventDefault(); + event.stopPropagation(); + onClick(); + }; + + return ( + <button + className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" + type="button" + disabled={isProcessing || !onClick} + onClick={(event) => handleClick({ event })} + > + <div className="flex items-center justify-center"> + <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" /> + </div> + + <div className="space-y-2"> + <p className="text-muted-foreground max-w-sm text-xs"> + {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} + </p> + </div> + + {isProcessing && ( + <div className="w-full max-w-xs"> + <div className="bg-muted/50 h-2 w-full rounded-full"> + <div + className="bg-primary h-2 rounded-full" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx new file mode 100644 index 00000000..10cb1f8c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { clearDragData, setDragData } from "@/lib/drag-data"; +import type { TimelineDragData } from "@/types/drag"; +import { cn } from "@/utils/ui"; + +export interface DraggableItemProps { + name: string; + preview: ReactNode; + dragData: TimelineDragData; + onDragStart?: ({ e }: { e: React.DragEvent }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + aspectRatio?: number; + className?: string; + containerClassName?: string; + shouldShowPlusOnDrag?: boolean; + shouldShowLabel?: boolean; + isRounded?: boolean; + variant?: "card" | "compact"; + isDraggable?: boolean; + isHighlighted?: boolean; +} + +export function DraggableItem({ + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + containerClassName, + shouldShowPlusOnDrag = true, + shouldShowLabel = true, + isRounded = true, + variant = "card", + isDraggable = true, + isHighlighted = false, +}: DraggableItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef<HTMLDivElement>(null); + const editor = useEditor(); + const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`; + + const handleAddToTimeline = () => { + onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); + }; + + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + + useEffect(() => { + if (!isDragging) return; + + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener("dragover", handleDragOver); + + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); + + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); + + setDragData({ dataTransfer: e.dataTransfer, dragData }); + e.dataTransfer.effectAllowed = "copy"; + + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); + + onDragStart?.({ e }); + }; + + const handleDragEnd = () => { + setIsDragging(false); + clearDragData(); + }; + + return ( + <> + {variant === "card" ? ( + <div + ref={dragRef} + className={cn("group relative", containerClassName ?? "size-28")} + > + <div + className={cn( + "relative flex h-auto w-full cursor-default flex-col gap-1 p-1", + className, + isHighlighted && highlightClassName, + )} + > + <AspectRatio + ratio={aspectRatio} + className={cn( + "bg-panel-accent relative overflow-hidden", + isRounded && "rounded-sm", + isDraggable && "[&::-webkit-drag-ghost]:opacity-0", + )} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} + > + {preview} + {!isDragging && ( + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> + )} + </AspectRatio> + {shouldShowLabel && ( + <span + className="text-muted-foreground w-full truncate text-left text-[0.7rem]" + title={name} + > + <span className="sr-only">{name}</span> + <span aria-hidden="true"> + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + </span> + </span> + )} + </div> + </div> + ) : ( + <div + ref={dragRef} + className={cn( + "group relative w-full", + isHighlighted && highlightClassName, + )} + > + <button + type="button" + className={cn( + "flex h-8 w-full cursor-default items-center gap-3 px-1", + isDraggable && "[&::-webkit-drag-ghost]:opacity-0", + className, + )} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} + > + <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> + {preview} + </div> + <span className="w-full flex-1 truncate text-sm text-left">{name}</span> + </button> + </div> + )} + + {isDraggable && + isDragging && + typeof document !== "undefined" && + createPortal( + <div + className="pointer-events-none fixed z-9999" + style={{ + left: dragPosition.x - 40, + top: dragPosition.y - 40, + }} + > + <div className="w-[80px]"> + <AspectRatio + ratio={1} + className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" + > + <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> + {preview} + </div> + {shouldShowPlusOnDrag && ( + <PlusButton + onClick={handleAddToTimeline} + tooltipText="Add to timeline or drag to position" + /> + )} + </AspectRatio> + </div> + </div>, + document.body, + )} + </> + ); +} + +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( + <Button + size="icon" + className={cn( + "bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5", + className, + )} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} + title={tooltipText} + > + <Plus /> + </Button> + ); + + if (tooltipText) { + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent> + <p>{tooltipText}</p> + </TooltipContent> + </Tooltip> + ); + } + + return button; +} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx new file mode 100644 index 00000000..9161cb1d --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Separator } from "@/components/ui/separator"; +import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { TabBar } from "./tabbar"; +import { Captions } from "./views/captions"; +import { MediaView } from "./views/media"; +import { SettingsView } from "./views/settings"; +import { SoundsView } from "./views/sounds"; +import { StickersView } from "./views/stickers"; +import { TextView } from "./views/text"; + +export function AssetsPanel() { + const { activeTab } = useAssetsPanelStore(); + + const viewMap: Record<Tab, React.ReactNode> = { + media: <MediaView />, + sounds: <SoundsView />, + text: <TextView />, + stickers: <StickersView />, + effects: ( + <div className="text-muted-foreground p-4"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="text-muted-foreground p-4"> + Transitions view coming soon... + </div> + ), + captions: <Captions />, + filters: ( + <div className="text-muted-foreground p-4"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="text-muted-foreground p-4"> + Adjustment view coming soon... + </div> + ), + settings: <SettingsView />, + }; + + return ( + <div className="bg-panel flex h-full"> + <TabBar /> + <Separator orientation="vertical" /> + <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx new file mode 100644 index 00000000..2c950fdc --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/utils/ui"; +import { + TAB_KEYS, + tabs, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; + +export function TabBar() { + const { activeTab, setActiveTab } = useAssetsPanelStore(); + const scrollRef = useRef<HTMLDivElement>(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const checkScrollPosition = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + setShowTopFade(scrollTop > 0); + setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); + }, []); + + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + + checkScrollPosition(); + element.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, [checkScrollPosition]); + + return ( + <div className="relative flex"> + <div + ref={scrollRef} + className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" + > + {TAB_KEYS.map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <Tooltip key={tabKey} delayDuration={10}> + <TooltipTrigger asChild> + <button + type="button" + aria-label={tab.label} + className={cn( + "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75", + activeTab === tabKey + ? "text-primary !opacity-100" + : "text-muted-foreground", + )} + onClick={() => setActiveTab(tabKey)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setActiveTab(tabKey); + } + }} + > + <tab.icon className=" " /> + </button> + </TooltipTrigger> + <TooltipContent + side="right" + align="center" + variant="sidebar" + sideOffset={8} + > + <div className="text-foreground text-sm leading-none font-medium"> + {tab.label} + </div> + </TooltipContent> + </Tooltip> + ); + })} + </div> + + <FadeOverlay direction="top" show={showTopFade} /> + <FadeOverlay direction="bottom" show={showBottomFade} /> + </div> + ); +} + +function FadeOverlay({ + direction, + show, +}: { + direction: "top" | "bottom"; + show: boolean; +}) { + return ( + <div + className={cn( + "pointer-events-none absolute right-0 left-0 h-6", + direction === "top" && show + ? "from-panel top-0 bg-gradient-to-b to-transparent" + : "from-panel bottom-0 bg-gradient-to-t to-transparent", + )} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx new file mode 100644 index 00000000..306bad2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -0,0 +1,152 @@ +import { Button } from "@/components/ui/button"; +import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState, useRef } from "react"; +import { extractTimelineAudio } from "@/lib/media/mediabunny"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants"; +import type { + TranscriptionLanguage, + TranscriptionProgress, +} from "@/types/transcription"; +import { transcriptionService } from "@/services/transcription"; +import { decodeAudioToFloat32 } from "@/lib/media/audio"; +import { buildCaptionChunks } from "@/lib/transcription/caption"; +import { Spinner } from "@/components/ui/spinner"; + +export function Captions() { + const [selectedLanguage, setSelectedLanguage] = + useState<TranscriptionLanguage>("auto"); + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState(""); + const [error, setError] = useState<string | null>(null); + const containerRef = useRef<HTMLDivElement>(null); + const editor = useEditor(); + + const handleProgress = (progress: TranscriptionProgress) => { + if (progress.status === "loading-model") { + setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); + } else if (progress.status === "transcribing") { + setProcessingStep("Transcribing..."); + } + }; + + const handleGenerateTranscript = async () => { + try { + setIsProcessing(true); + setError(null); + setProcessingStep("Extracting audio..."); + + const audioBlob = await extractTimelineAudio({ + tracks: editor.timeline.getTracks(), + mediaAssets: editor.media.getAssets(), + totalDuration: editor.timeline.getTotalDuration(), + }); + + setProcessingStep("Preparing audio..."); + const { samples } = await decodeAudioToFloat32({ audioBlob }); + + const result = await transcriptionService.transcribe({ + audioData: samples, + language: selectedLanguage === "auto" ? undefined : selectedLanguage, + onProgress: handleProgress, + }); + + setProcessingStep("Generating captions..."); + const captionChunks = buildCaptionChunks({ segments: result.segments }); + + const captionTrackId = editor.timeline.addTrack({ + type: "text", + index: 0, + }); + + for (let i = 0; i < captionChunks.length; i++) { + const caption = captionChunks[i]; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: captionTrackId }, + element: { + ...DEFAULT_TEXT_ELEMENT, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }); + } + } catch (error) { + console.error("Transcription failed:", error); + setError( + error instanceof Error ? error.message : "An unexpected error occurred", + ); + } finally { + setIsProcessing(false); + setProcessingStep(""); + } + }; + + const handleLanguageChange = ({ value }: { value: string }) => { + if (value === "auto") { + setSelectedLanguage("auto"); + return; + } + + const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( + (language) => language.code === value, + ); + if (!matchedLanguage) return; + setSelectedLanguage(matchedLanguage.code); + }; + + return ( + <BaseView + ref={containerRef} + className="flex h-full flex-col justify-between" + > + <PropertyGroup title="Language"> + <Select + value={selectedLanguage} + onValueChange={(value) => handleLanguageChange({ value })} + > + <SelectTrigger className="bg-panel-accent h-8 w-full text-xs"> + <SelectValue placeholder="Select a language" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="auto">Auto detect</SelectItem> + {TRANSCRIPTION_LANGUAGES.map((language) => ( + <SelectItem key={language.code} value={language.code}> + {language.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyGroup> + + <div className="flex flex-col gap-4"> + {error && ( + <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> + <p className="text-destructive text-sm">{error}</p> + </div> + )} + + <Button + className="w-full" + onClick={handleGenerateTranscript} + disabled={isProcessing} + > + {isProcessing && <Spinner className="mr-1" />} + {isProcessing ? processingStep : "Generate transcript"} + </Button> + </div> + </BaseView> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx new file mode 100644 index 00000000..76ae0064 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -0,0 +1,675 @@ +"use client"; + +import Image from "next/image"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { useFileUpload } from "@/hooks/use-file-upload"; +import { useRevealItem } from "@/hooks/use-reveal-item"; +import { processMediaAssets } from "@/lib/media/processing"; +import { + buildImageElement, + buildUploadAudioElement, + buildVideoElement, +} from "@/lib/timeline/element-utils"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import type { MediaAsset } from "@/types/assets"; +import type { CreateTimelineElement } from "@/types/timeline"; +import { cn } from "@/utils/ui"; +import { + CloudUploadIcon, + GridViewIcon, + LeftToRightListDashIcon, + SortingOneNineIcon, + Image02Icon, + MusicNote03Icon, + Video01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export function MediaView() { + const editor = useEditor(); + const mediaFiles = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = + useAssetsPanelStore(); + const { highlightedId, registerElement } = useRevealItem( + highlightMediaId, + clearHighlight, + ); + + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( + "name", + ); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + + const processFiles = async ({ files }: { files: FileList }) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedAssets = await processMediaAssets({ + files, + onProgress: (progress: { progress: number }) => + setProgress(progress.progress), + }); + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + } + } catch (error) { + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps, openFilePicker, fileInputProps } = + useFileUpload({ + accept: "image/*,video/*,audio/*", + multiple: true, + onFilesSelected: (files) => processFiles({ files }), + }); + + const handleRemove = async ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => { + event.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + await editor.media.removeMediaAsset({ + projectId: activeProject.metadata.id, + id, + }); + }; + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }): boolean => { + const element = createElementFromMedia({ asset, startTime }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + return true; + }; + + const filteredMediaItems = useMemo(() => { + const filtered = mediaFiles.filter((item) => !item.ephemeral); + + filtered.sort((a, b) => { + let valueA: string | number; + let valueB: string | number; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + + return filtered; + }, [mediaFiles, sortBy, sortOrder]); + + const previewComponents = useMemo(() => { + const previews = new Map<string, React.ReactNode>(); + + filteredMediaItems.forEach((item) => { + previews.set(item.id, <MediaPreview item={item} />); + previews.set( + `compact-${item.id}`, + <MediaPreview item={item} variant="compact" />, + ); + }); + + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); + const renderCompactPreview = (item: MediaAsset) => + previewComponents.get(`compact-${item.id}`); + + return ( + <> + <input {...fileInputProps} /> + + <div + className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="bg-panel py-2 px-4 flex items-center justify-between border-b"> + <span className="text-muted-foreground text-sm">Assets</span> + <div className="flex items-center gap-0"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="text" + onClick={() => + setMediaViewMode( + mediaViewMode === "grid" ? "list" : "grid", + ) + } + disabled={isProcessing} + className="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="text" + disabled={isProcessing} + className="items-center justify-center" + > + <HugeiconsIcon icon={SortingOneNineIcon} /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={openFilePicker} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5 ml-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Upload + </Button> + </div> + </div> + + <div className="scrollbar-thin size-full overflow-y-auto pt-1"> + <div className="w-full flex-1 p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={openFilePicker} + /> + ) : mediaViewMode === "grid" ? ( + <GridView + items={filteredMediaItems} + renderPreview={renderPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + ) : ( + <ListView + items={filteredMediaItems} + renderPreview={renderCompactPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + )} + </div> + </div> + </div> + </> + ); +} + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaAsset; + children: React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; +}) { + return ( + <ContextMenu> + <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(event) => onRemove({ event, id: item.id })} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} + +function GridView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {items.map((item) => ( + <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <DraggableItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + onAddToTimeline({ asset: item, startTime: currentTime }) + } + isRounded={false} + variant="card" + isHighlighted={highlightedId === item.id} + /> + </MediaItemWithContextMenu> + </div> + ))} + </div> + ); +} + +function ListView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( + <div className="space-y-1"> + {items.map((item) => ( + <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <DraggableItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + onAddToTimeline({ asset: item, startTime: currentTime }) + } + variant="compact" + isHighlighted={highlightedId === item.id} + /> + </MediaItemWithContextMenu> + </div> + ))} + </div> + ); +} + +const formatDuration = ({ duration }: { duration: number }) => { + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; +}; + +function MediaDurationBadge({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + <div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white"> + {formatDuration({ duration })} + </div> + ); +} + +function MediaDurationLabel({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + <span className="text-xs opacity-70">{formatDuration({ duration })}</span> + ); +} + +function MediaTypePlaceholder({ + icon, + label, + duration, + variant, +}: { + icon: IconSvgElement; + label: string; + duration?: number; + variant: "muted" | "bordered"; +}) { + const iconClassName = cn("size-6", variant === "bordered" && "mb-1"); + + return ( + <div + className={cn( + "text-muted-foreground flex size-full flex-col items-center justify-center rounded", + variant === "muted" ? "bg-muted/30" : "border", + )} + > + <HugeiconsIcon icon={icon} className={iconClassName} /> + <span className="text-xs">{label}</span> + <MediaDurationLabel duration={duration} /> + </div> + ); +} + +function MediaPreview({ + item, + variant = "grid", +}: { + item: MediaAsset; + variant?: "grid" | "compact"; +}) { + const shouldShowDurationBadge = variant === "grid"; + + if (item.type === "image") { + return ( + <div className="relative flex size-full items-center justify-center"> + <Image + src={item.url ?? ""} + alt={item.name} + fill + sizes="100vw" + className="object-cover" + loading="lazy" + unoptimized + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative size-full"> + <Image + src={item.thumbnailUrl} + alt={item.name} + fill + sizes="100vw" + className="rounded object-cover" + loading="lazy" + unoptimized + /> + {shouldShowDurationBadge ? ( + <MediaDurationBadge duration={item.duration} /> + ) : null} + </div> + ); + } + + return ( + <MediaTypePlaceholder + icon={Video01Icon} + label="Video" + duration={item.duration} + variant="muted" + /> + ); + } + + if (item.type === "audio") { + return ( + <MediaTypePlaceholder + icon={MusicNote03Icon} + label="Audio" + duration={item.duration} + variant="bordered" + /> + ); + } + + return ( + <MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" /> + ); +} + +function SortMenuItem({ + label, + sortKey, + currentSortBy, + currentSortOrder, + onSort, +}: { + label: string; + sortKey: "name" | "type" | "duration" | "size"; + currentSortBy: string; + currentSortOrder: "asc" | "desc"; + onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; +}) { + const isActive = currentSortBy === sortKey; + const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; + + return ( + <DropdownMenuItem onClick={() => onSort({ key: sortKey })}> + {label} {arrow} + </DropdownMenuItem> + ); +} + +function createElementFromMedia({ + asset, + startTime, +}: { + asset: MediaAsset; + startTime: number; +}): CreateTimelineElement { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + + switch (asset.type) { + case "video": + return buildVideoElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "image": + return buildImageElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "audio": + return buildUploadAudioElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + default: + throw new Error(`Unsupported media type: ${asset.type}`); + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx new file mode 100644 index 00000000..b8c54510 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -0,0 +1,394 @@ +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { useEditorStore } from "@/stores/editor-store"; +import type { TProject } from "@/types/project"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/property-item"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { DropperIcon } from "@hugeicons/core-free-icons"; + +export function SettingsView() { + return <ProjectSettingsTabs />; +} + +function ProjectSettingsTabs() { + return ( + <BaseView + defaultTab="project-info" + tabs={[ + { + value: "project-info", + label: "Project info", + content: ( + <div className="p-5"> + <ProjectInfoView /> + </div> + ), + }, + { + value: "background", + label: "Background", + content: ( + <div className="flex h-full flex-col justify-between"> + <div className="flex-1 p-5"> + <BackgroundView /> + </div> + {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg"> + <Separator /> + <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none"> + Custom background + <PlusIcon /> + </Button> + </div> */} + + {/* another ui */} + {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0"> + <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full"> + <span className="text-sm">Custom</span> + <PlusIcon className="" /> + </Button> + </div> */} + </div> + ), + }, + ]} + className="flex h-full flex-col justify-between p-0" + /> + ); +} + +function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { + const { canvasSize } = activeProject.settings; + + return { + width: canvasSize.width, + height: canvasSize.height, + }; +} + +function ProjectInfoView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { canvasPresets } = useEditorStore(); + + const findPresetIndexByAspectRatio = ({ + presets, + targetAspectRatio, + }: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; + }) => { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; + }; + + const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); + const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; + const presetIndex = findPresetIndexByAspectRatio({ + presets: canvasPresets, + targetAspectRatio: currentAspectRatio, + }); + const originalPresetValue = "original"; + const selectedPresetValue = + presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + + const handleAspectRatioChange = ({ value }: { value: string }) => { + if (value === originalPresetValue) { + const canvasSize = originalCanvasSize ?? currentCanvasSize; + editor.project.updateSettings({ + settings: { canvasSize }, + }); + return; + } + const index = parseInt(value, 10); + const preset = canvasPresets[index]; + if (preset) { + editor.project.updateSettings({ settings: { canvasSize: preset } }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }; + + return ( + <div className="flex flex-col gap-4"> + <PropertyItem direction="column"> + <PropertyItemLabel>Name</PropertyItemLabel> + <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Aspect ratio</PropertyItemLabel> + <PropertyItemValue> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} + > + <SelectTrigger className="bg-panel-accent"> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Frame rate</PropertyItemLabel> + <PropertyItemValue> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="bg-panel-accent"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( + <button + className={cn( + "border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border", + isSelected && "border-primary border-2", + )} + onClick={onSelect} + type="button" + aria-label={`Select ${blur.label} blur`} + > + <Image + src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + loading="eager" + /> + <div className="absolute right-1 bottom-1 left-1 text-center"> + <span className="rounded bg-black/50 px-1 text-xs text-white"> + {blur.label} + </span> + </div> + </button> + ), +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: ({ bg }: { bg: string }) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg, index) => ( + <button + key={`${index}-${bg}`} + className={cn( + "border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border", + isColorBackground && + bg === currentBackgroundColor && + "border-primary border-2", + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => handleColorSelect({ bg })} + type="button" + aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); + + const handleBlurSelect = useCallback( + async ({ blurIntensity }: { blurIntensity: number }) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async ({ color }: { color: string }) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const currentBlurIntensity = + activeProject.settings.background.type === "blur" + ? activeProject.settings.background.blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = + activeProject.settings.background.type === "color" + ? activeProject.settings.background.color + : DEFAULT_COLOR; + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + return ( + <div className="flex h-full flex-col gap-4"> + <PropertyGroup title="Blur" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div> + </PropertyGroup> + + <PropertyGroup title="Colors" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border"> + <HugeiconsIcon icon={DropperIcon} className="size-4" /> + </div> + <BackgroundPreviews + backgrounds={colors} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + useBackgroundColor={true} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Pattern craft" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <BackgroundPreviews + backgrounds={patternCraftGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Syntax UI" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <BackgroundPreviews + backgrounds={syntaxUIGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + /> + </div> + </PropertyGroup> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx new file mode 100644 index 00000000..84a5d0bf --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -0,0 +1,574 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { useSoundSearch } from "@/hooks/use-sound-search"; +import { useSoundsStore } from "@/stores/sounds-store"; +import type { SavedSound, SoundEffect } from "@/types/sounds"; +import { cn } from "@/utils/ui"; +import { + FavouriteIcon, + FilterMailIcon, + PauseIcon, + PlayIcon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +export function SoundsView() { + return ( + <div className="flex h-full flex-col"> + <Tabs defaultValue="sound-effects" className="flex h-full flex-col"> + <div className="px-3 pt-4 pb-0"> + <TabsList> + <TabsTrigger value="sound-effects">Sound effects</TabsTrigger> + <TabsTrigger value="songs">Songs</TabsTrigger> + <TabsTrigger value="saved">Saved</TabsTrigger> + </TabsList> + </div> + <Separator className="my-4" /> + <TabsContent + value="sound-effects" + className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SoundEffectsView /> + </TabsContent> + <TabsContent + value="saved" + className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SavedSoundsView /> + </TabsContent> + <TabsContent + value="songs" + className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SongsView /> + </TabsContent> + </Tabs> + </div> + ); +} + +function SoundEffectsView() { + const { + topSoundEffects, + isLoading, + searchQuery, + setSearchQuery, + scrollPosition, + setScrollPosition, + loadSavedSounds, + showCommercialOnly, + toggleCommercialFilter, + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + } = useSoundsStore(); + const { + results: searchResults, + isLoading: isSearching, + loadMore, + hasNextPage, + isLoadingMore, + } = useSoundSearch({ + query: searchQuery, + commercialOnly: showCommercialOnly, + }); + + const [playingId, setPlayingId] = useState<number | null>(null); + const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( + null, + ); + + const { scrollAreaRef, handleScroll } = useInfiniteScroll({ + onLoadMore: loadMore, + hasMore: hasNextPage, + isLoading: isLoadingMore || isSearching, + }); + + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + useEffect(() => { + if (hasLoaded) { + return; + } + + let shouldIgnore = false; + + const fetchTopSounds = async () => { + try { + if (!shouldIgnore) { + setLoading({ loading: true }); + setError({ error: null }); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads", + ); + + if (!shouldIgnore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects({ sounds: data.results }); + setHasLoaded({ loaded: true }); + + setCurrentPage({ page: 1 }); + setHasNextPage({ hasNext: !!data.next }); + setTotalCount({ count: data.count }); + } + } catch (error) { + if (!shouldIgnore) { + console.error("Failed to fetch top sounds:", error); + setError({ + error: + error instanceof Error ? error.message : "Failed to load sounds", + }); + } + } finally { + if (!shouldIgnore) { + setLoading({ loading: false }); + } + } + }; + + const timeoutId = setTimeout(fetchTopSounds, 100, {}); + + return () => { + shouldIgnore = true; + clearTimeout(timeoutId); + }; + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); + + useEffect(() => { + if (!scrollAreaRef.current || scrollPosition <= 0) { + return; + } + + const restoreScrollPosition = () => { + scrollAreaRef.current?.scrollTo({ top: scrollPosition }); + }; + + const timeoutId = setTimeout(restoreScrollPosition, 100, {}); + + return () => clearTimeout(timeoutId); + }, [scrollPosition, scrollAreaRef]); + + const handleScrollWithPosition = ({ + currentTarget, + }: React.UIEvent<HTMLDivElement>) => { + const { scrollTop } = currentTarget; + setScrollPosition({ position: scrollTop }); + handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>); + }; + + const displayedSounds = searchQuery ? searchResults : topSoundEffects; + + const playSound = ({ sound }: { sound: SoundEffect }) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", () => { + setPlayingId(null); + }); + audio.play().catch((error) => { + console.error("Failed to play sound preview:", error); + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + return ( + <div className="mt-1 flex h-full flex-col gap-5"> + <div className="flex items-center gap-3"> + <Input + placeholder="Search sound effects" + className="bg-panel-accent w-full" + containerClassName="w-full" + value={searchQuery} + onChange={({ currentTarget }) => + setSearchQuery({ query: currentTarget.value }) + } + showClearIcon + onClear={() => setSearchQuery({ query: "" })} + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="icon" + className={cn(showCommercialOnly && "text-primary")} + > + <HugeiconsIcon icon={FilterMailIcon} /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuCheckboxItem + checked={showCommercialOnly} + onCheckedChange={() => toggleCommercialFilter()} + > + Show only commercially licensed + </DropdownMenuCheckboxItem> + <div className="text-muted-foreground px-2 py-1.5 text-xs"> + {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} + </div> + </DropdownMenuContent> + </DropdownMenu> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea + className="h-full flex-1" + ref={scrollAreaRef} + onScrollCapture={handleScrollWithPosition} + > + <div className="flex flex-col gap-4"> + {isLoading && !searchQuery && ( + <div className="text-muted-foreground text-sm"> + Loading sounds... + </div> + )} + {isSearching && searchQuery && ( + <div className="text-muted-foreground text-sm">Searching...</div> + )} + {displayedSounds.map((sound) => ( + <AudioItem + key={sound.id} + sound={sound} + isPlaying={playingId === sound.id} + onPlay={playSound} + /> + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( + <div className="text-muted-foreground text-sm"> + {searchQuery ? "No sounds found" : "No sounds available"} + </div> + )} + {isLoadingMore && ( + <div className="text-muted-foreground py-4 text-center text-sm"> + Loading more sounds... + </div> + )} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + clearSavedSounds, + } = useSoundsStore(); + + const [playingId, setPlayingId] = useState<number | null>(null); + const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( + null, + ); + + const [showClearDialog, setShowClearDialog] = useState(false); + + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + const playSound = ({ sound }: { sound: SoundEffect }) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", () => { + setPlayingId(null); + }); + audio.play().catch((error) => { + console.error("Failed to play sound preview:", error); + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + const convertToSoundEffect = ({ + savedSound, + }: { + savedSound: SavedSound; + }): SoundEffect => ({ + id: savedSound.id, + name: savedSound.name, + description: "", + url: "", + previewUrl: savedSound.previewUrl, + downloadUrl: savedSound.downloadUrl, + duration: savedSound.duration, + filesize: 0, + type: "audio", + channels: 0, + bitrate: 0, + bitdepth: 0, + samplerate: 0, + username: savedSound.username, + tags: savedSound.tags, + license: savedSound.license, + created: savedSound.savedAt, + downloads: 0, + rating: 0, + ratingCount: 0, + }); + + if (isLoadingSavedSounds) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-muted-foreground text-sm"> + Loading saved sounds... + </div> + </div> + ); + } + + if (savedSoundsError) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-destructive text-sm"> + Error: {savedSoundsError} + </div> + </div> + ); + } + + if (savedSounds.length === 0) { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={FavouriteIcon} + className="text-muted-foreground size-10" + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No saved sounds</p> + <p className="text-muted-foreground text-sm text-balance"> + Click the heart icon on any sound to save it here + </p> + </div> + </div> + ); + } + + return ( + <div className="mt-1 flex h-full flex-col gap-5"> + <div className="flex items-center justify-between"> + <p className="text-muted-foreground text-sm"> + {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} + </p> + <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> + <DialogTrigger asChild> + <Button + variant="text" + size="sm" + className="text-muted-foreground hover:text-destructive h-auto !opacity-100" + > + Clear all + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Clear all saved sounds?</DialogTitle> + <DialogDescription> + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="text" onClick={() => setShowClearDialog(false)}> + Cancel + </Button> + <Button + variant="destructive" + onClick={async ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + await clearSavedSounds(); + setShowClearDialog(false); + }} + > + Clear all sounds + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea className="h-full flex-1"> + <div className="flex flex-col gap-4"> + {savedSounds.map((sound) => ( + <AudioItem + key={sound.id} + sound={convertToSoundEffect({ savedSound: sound })} + isPlaying={playingId === sound.id} + onPlay={playSound} + /> + ))} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SongsView() { + return <div>Songs</div>; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: ({ sound }: { sound: SoundEffect }) => void; +} + +function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { + const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = + useSoundsStore(); + const isSaved = isSoundSaved({ soundId: sound.id }); + + const handleClick = () => { + onPlay({ sound }); + }; + + const handleSaveClick = ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + toggleSavedSound({ soundEffect: sound }); + }; + + const handleAddToTimeline = async ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + await addSoundToTimeline({ sound }); + }; + + return ( + <div className="group flex items-center gap-3 opacity-100 hover:opacity-75"> + <button + type="button" + className="flex min-w-0 flex-1 items-center gap-3 text-left" + onClick={handleClick} + > + <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md"> + <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" /> + {isPlaying ? ( + <HugeiconsIcon icon={PauseIcon} className="size-5" /> + ) : ( + <HugeiconsIcon icon={PlayIcon} className="size-5" /> + )} + </div> + + <div className="min-w-0 flex-1 overflow-hidden"> + <p className="truncate text-sm font-medium">{sound.name}</p> + <span className="text-muted-foreground block truncate text-xs"> + {sound.username} + </span> + </div> + </button> + + <div className="flex items-center gap-3 pr-2"> + <Button + variant="text" + size="icon" + className="text-muted-foreground hover:text-foreground w-auto !opacity-100" + onClick={handleAddToTimeline} + title="Add to timeline" + > + <HugeiconsIcon icon={PlusSignIcon} /> + </Button> + <Button + variant="text" + size="icon" + className={`hover:text-foreground w-auto !opacity-100 ${ + isSaved + ? "text-red-500 hover:text-red-600" + : "text-muted-foreground" + }`} + onClick={handleSaveClick} + title={isSaved ? "Remove from saved" : "Save sound"} + > + <HugeiconsIcon + icon={FavouriteIcon} + className={`${isSaved ? "fill-current" : ""}`} + /> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx new file mode 100644 index 00000000..6b48e775 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -0,0 +1,615 @@ +"use client"; + +import Image from "next/image"; +import type { CSSProperties } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { Button } from "@/components/ui/button"; +import { InputWithBack } from "@/components/ui/input-with-back"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { + buildIconSvgUrl, + getIconSvgUrl, + ICONIFY_HOSTS, + POPULAR_COLLECTIONS, +} from "@/lib/iconify-api"; +import { useStickersStore } from "@/stores/stickers-store"; +import type { StickerCategory } from "@/types/stickers"; +import { cn } from "@/utils/ui"; +import { + ArrowRightIcon, + HappyIcon, + ClockIcon, + LayoutGridIcon, + MultiplicationSignIcon, + SparklesIcon, + HashtagIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Spinner } from "@/components/ui/spinner"; + +function isStickerCategory(value: string): value is StickerCategory { + return STICKER_CATEGORIES.includes(value as StickerCategory); +} + +export function StickersView() { + const { selectedCategory, setSelectedCategory } = useStickersStore(); + + return ( + <BaseView + value={selectedCategory} + onValueChange={(v) => { + if (isStickerCategory(v)) { + setSelectedCategory({ category: v }); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, + content: <StickersContentView category="all" />, + }, + { + value: "general", + label: "Icons", + icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, + content: <StickersContentView category="general" />, + }, + { + value: "brands", + label: "Brands", + icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, + content: <StickersContentView category="brands" />, + }, + { + value: "emoji", + label: "Emoji", + icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, + content: <StickersContentView category="emoji" />, + }, + ]} + className="flex h-full flex-col overflow-hidden p-0" + /> + ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, + capSize = false, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; + capSize?: boolean; +}) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + gridTemplateColumns: capSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" + : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", + "--sticker-min": "96px", + ...(capSize ? { "--sticker-max": "160px" } : {}), + }; + + return ( + <div className="grid gap-2" style={gridStyle}> + {icons.map((iconName) => ( + <StickerItem + key={iconName} + iconName={iconName} + onAdd={onAdd} + isAdding={addingSticker === iconName} + capSize={capSize} + /> + ))} + </div> + ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: ({ prefix }: { prefix: string }) => void; +}) { + return ( + <div className="grid grid-cols-1 gap-2"> + {collections.map((collection) => ( + <CollectionItem + key={collection.prefix} + title={collection.name} + subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} + onClick={() => onSelectCollection({ prefix: collection.prefix })} + /> + ))} + </div> + ); +} + +function EmptyView({ message }: { message: string }) { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={HappyIcon} + className="text-muted-foreground size-10" + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No stickers found</p> + <p className="text-muted-foreground text-sm text-balance">{message}</p> + </div> + </div> + ); +} + +function StickersContentView({ category }: { category: StickerCategory }) { + const { + searchQuery, + selectedCollection, + viewMode, + collections, + currentCollection, + searchResults, + recentStickers, + isLoadingCollections, + isLoadingCollection, + isSearching, + setSearchQuery, + setSelectedCollection, + loadCollections, + searchStickers, + addStickerToTimeline, + clearRecentStickers, + setSelectedCategory, + addingSticker, + } = useStickersStore(); + + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + const [collectionsToShow, setCollectionsToShow] = useState(20); + const [showCollectionItems, setShowCollectionItems] = useState(false); + + const filteredCollections = useMemo(() => { + if (category === "all") { + return Object.entries(collections).map(([prefix, collection]) => ({ + prefix, + name: collection.name, + total: collection.total, + category: collection.category, + })); + } + + const collectionList = + POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; + if (!collectionList) return []; + + return collectionList + .map((c) => { + const collection = collections[c.prefix]; + return collection + ? { + prefix: c.prefix, + name: c.name, + total: collection.total, + } + : null; + }) + .filter(Boolean) as Array<{ + prefix: string; + name: string; + total: number; + }>; + }, [collections, category]); + + const { scrollAreaRef, handleScroll } = useInfiniteScroll({ + onLoadMore: () => setCollectionsToShow((prev) => prev + 20), + hasMore: filteredCollections.length > collectionsToShow, + isLoading: isLoadingCollections, + enabled: viewMode === "browse" && !selectedCollection && category === "all", + }); + + useEffect(() => { + if (Object.keys(collections).length === 0) { + loadCollections(); + } + }, [collections, loadCollections]); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery({ query: localSearchQuery }); + if (localSearchQuery.trim()) { + searchStickers({ query: localSearchQuery }); + } + } + }, 500); + + return () => clearTimeout(timer); + }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); + + const handleAddSticker = async (iconName: string) => { + try { + await addStickerToTimeline({ iconName }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; + + const iconsToDisplay = useMemo(() => { + if (viewMode === "search" && searchResults) { + return searchResults.icons; + } + + if (viewMode === "collection" && currentCollection) { + const icons: string[] = []; + + if (currentCollection.uncategorized) { + icons.push( + ...currentCollection.uncategorized.map( + (name) => `${currentCollection.prefix}:${name}`, + ), + ); + } + + if (currentCollection.categories) { + Object.values(currentCollection.categories).forEach((categoryIcons) => { + icons.push( + ...categoryIcons.map( + (name) => `${currentCollection.prefix}:${name}`, + ), + ); + }); + } + + return icons.slice(0, 100); + } + + return []; + }, [viewMode, searchResults, currentCollection]); + + const isInCollection = viewMode === "collection" && !!selectedCollection; + + useEffect(() => { + if (isInCollection) { + setShowCollectionItems(false); + const timer = setTimeout(() => setShowCollectionItems(true), 350); + return () => clearTimeout(timer); + } else { + setShowCollectionItems(false); + } + }, [isInCollection]); + + return ( + <div className="mt-1 flex h-full flex-col gap-5 p-4"> + <div className="space-y-3"> + <InputWithBack + isExpanded={isInCollection} + setIsExpanded={(expanded) => { + if (!expanded && isInCollection) { + setSelectedCollection({ collection: null }); + } + }} + placeholder={ + category === "all" + ? "Search all stickers" + : category === "general" + ? "Search icons" + : category === "brands" + ? "Search brands" + : "Search Emojis" + } + value={localSearchQuery} + onChange={setLocalSearchQuery} + disableAnimation={true} + /> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea + className="h-full flex-1" + ref={scrollAreaRef} + onScrollCapture={handleScroll} + > + <div className="flex h-full flex-col gap-4"> + {recentStickers.length > 0 && viewMode === "browse" && ( + <div className="h-full"> + <div className="mb-2 flex items-center gap-2"> + <HugeiconsIcon + icon={ClockIcon} + className="text-muted-foreground size-4" + /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={clearRecentStickers} + className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" + > + <HugeiconsIcon + icon={MultiplicationSignIcon} + className="text-muted-foreground size-3" + /> + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid + icons={recentStickers.slice(0, 12)} + onAdd={handleAddSticker} + addingSticker={addingSticker} + capSize + /> + </div> + )} + + {viewMode === "collection" && selectedCollection && ( + <div className="h-full"> + {isLoadingCollection ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : showCollectionItems ? ( + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + ) : ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + )} + </div> + )} + + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : searchResults?.icons.length ? ( + <> + <div className="mb-3 flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> + </div> + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + capSize + /> + </> + ) : searchQuery ? ( + <div className="flex flex-col items-center justify-center gap-3 py-8"> + <EmptyView + message={`No stickers found for "${searchQuery}"`} + /> + {category !== "all" && ( + <Button + variant="outline" + onClick={() => { + const q = localSearchQuery || searchQuery; + if (q) { + setSearchQuery({ query: q }); + } + setSelectedCategory({ category: "all" }); + if (q) { + searchStickers({ query: q }); + } + }} + > + Search in all icons + </Button> + )} + </div> + ) : null} + </div> + )} + + {viewMode === "browse" && !selectedCollection && ( + <div className="h-full space-y-4"> + {isLoadingCollections ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : ( + <> + {category !== "all" && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </div> + )} + + {category === "all" && filteredCollections.length > 0 && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections.slice( + 0, + collectionsToShow, + )} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </div> + )} + </> + )} + </div> + )} + </div> + </ScrollArea> + </div> + </div> + ); +} + +interface CollectionItemProps { + title: string; + subtitle: string; + onClick: () => void; +} + +function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { + return ( + <Button + variant="outline" + className="h-auto justify-between rounded-md py-2" + onClick={onClick} + > + <div className="text-left"> + <p className="font-medium">{title}</p> + <p className="text-muted-foreground text-xs">{subtitle}</p> + </div> + <HugeiconsIcon icon={ArrowRightIcon} className="size-4" /> + </Button> + ); +} + +interface StickerItemProps { + iconName: string; + onAdd: (iconName: string) => void; + isAdding?: boolean; + capSize?: boolean; +} + +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { + const [imageError, setImageError] = useState(false); + const [hostIndex, setHostIndex] = useState(0); + + useEffect(() => { + if (!iconName) { + return; + } + setImageError(false); + setHostIndex(0); + }, [iconName]); + + const displayName = iconName.split(":")[1] || iconName; + const collectionPrefix = iconName.split(":")[0]; + + const preview = imageError ? ( + <div className="flex size-full items-center justify-center p-2"> + <span className="text-muted-foreground text-center text-xs break-all"> + {displayName} + </span> + </div> + ) : ( + <div className="flex size-full items-center justify-center p-4"> + <Image + src={ + hostIndex === 0 + ? getIconSvgUrl(iconName, { width: 64, height: 64 }) + : buildIconSvgUrl( + ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], + iconName, + { width: 64, height: 64 }, + ) + } + alt={displayName} + width={64} + height={64} + className="size-full object-contain" + style={ + capSize + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } + : undefined + } + onError={() => { + const next = hostIndex + 1; + if (next < ICONIFY_HOSTS.length) { + setHostIndex(next); + } else { + setImageError(true); + } + }} + loading="lazy" + unoptimized + /> + </div> + ); + + return ( + <Tooltip> + <TooltipTrigger asChild> + <div + className={cn( + "relative", + isAdding && "pointer-events-none opacity-50", + )} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: iconName, + type: "sticker", + name: displayName, + iconName, + }} + onAddToTimeline={() => onAdd(iconName)} + aspectRatio={1} + shouldShowLabel={false} + isRounded={true} + variant="card" + className="" + containerClassName="w-full" + /> + {isAdding && ( + <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> + <Spinner className="size-6 text-white" /> + </div> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{displayName}</p> + <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + </div> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx new file mode 100644 index 00000000..6ccf5d98 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -0,0 +1,46 @@ +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { buildTextElement } from "@/lib/timeline/element-utils"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULT_TEXT_ELEMENT, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <BaseView> + <DraggableItem + name="Default text" + preview={ + <div className="bg-panel-accent flex size-full items-center justify-center rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: "temp-text-id", + type: DEFAULT_TEXT_ELEMENT.type, + name: DEFAULT_TEXT_ELEMENT.name, + content: DEFAULT_TEXT_ELEMENT.content, + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + </BaseView> + ); +} diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx new file mode 100644 index 00000000..b16d1db6 --- /dev/null +++ b/apps/web/src/components/editor/panels/panel-base-view.tsx @@ -0,0 +1,85 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/utils/ui"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + icon?: React.ReactNode; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject<HTMLDivElement>; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <ScrollArea className="flex-1"> + <div className={cn("p-5", className)}>{children}</div> + </ScrollArea> + ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( + <div className={cn("flex h-full flex-col", className)} ref={ref}> + {!tabs || tabs.length === 0 ? ( + <ViewContent className={className}>{children}</ViewContent> + ) : ( + <Tabs + defaultValue={defaultTab} + value={value} + onValueChange={onValueChange} + className="flex h-full flex-col" + > + <div className="bg-panel sticky top-0 z-10"> + <div className="px-3 pt-3 pb-0"> + <TabsList> + {tabs.map((tab) => ( + <TabsTrigger key={tab.value} value={tab.value}> + {tab.icon ? ( + <span className="mr-1 inline-flex items-center"> + {tab.icon} + </span> + ) : null} + {tab.label} + </TabsTrigger> + ))} + </TabsList> + </div> + <Separator className="mt-3" /> + </div> + {tabs.map((tab) => ( + <TabsContent + key={tab.value} + value={tab.value} + className="mt-0 flex min-h-0 flex-1 flex-col" + > + <ViewContent className={className}>{tab.content}</ViewContent> + </TabsContent> + ))} + </Tabs> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx new file mode 100644 index 00000000..5ed9db00 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useEditor } from "@/hooks/use-editor"; +import { useRafLoop } from "@/hooks/use-raf-loop"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import type { RootNode } from "@/services/renderer/nodes/root-node"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { getLastFrameTime } from "@/lib/time"; + +function usePreviewSize() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + return { + width: activeProject?.settings.canvasSize.width, + height: activeProject?.settings.canvasSize.height, + }; +} + +function RenderTreeController() { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { width, height } = usePreviewSize(); + + useDeepCompareEffect(() => { + if (!activeProject) return; + + const duration = editor.timeline.getTotalDuration(); + const renderTree = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize: { width, height }, + background: activeProject.settings.background, + }); + + editor.renderer.setRenderTree({ renderTree }); + }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); + + return null; +} + +export function PreviewPanel() { + return ( + <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm"> + <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2"> + <PreviewCanvas /> + <RenderTreeController /> + </div> + </div> + ); +} + +function PreviewCanvas() { + const ref = useRef<HTMLCanvasElement>(null); + const lastFrameRef = useRef(-1); + const lastSceneRef = useRef<RootNode | null>(null); + const renderingRef = useRef(false); + const { width, height } = usePreviewSize(); + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + const renderer = useMemo(() => { + return new CanvasRenderer({ + width, + height, + fps: activeProject.settings.fps, + }); + }, [width, height, activeProject.settings.fps]); + + const renderTree = editor.renderer.getRenderTree(); + + const render = useCallback(() => { + if (ref.current && renderTree && !renderingRef.current) { + const time = editor.playback.getCurrentTime(); + const lastFrameTime = getLastFrameTime({ + duration: renderTree.duration, + fps: renderer.fps, + }); + const renderTime = Math.min(time, lastFrameTime); + const frame = Math.floor(renderTime * renderer.fps); + + if ( + frame !== lastFrameRef.current || + renderTree !== lastSceneRef.current + ) { + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: ref.current, + }) + .then(() => { + renderingRef.current = false; + }); + } + } + }, [renderer, renderTree, editor.playback]); + + useRafLoop(render); + + return ( + <canvas + ref={ref} + width={width} + height={height} + className="block max-h-full max-w-full border" + style={{ + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx new file mode 100644 index 00000000..0b83a4b6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -0,0 +1,5 @@ +import type { AudioElement } from "@/types/timeline"; + +export function AudioProperties({ _element }: { _element: AudioElement }) { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx new file mode 100644 index 00000000..3a552b93 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AudioProperties } from "./audio-properties"; +import { VideoProperties } from "./video-properties"; +import { TextProperties } from "./text-properties"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; + +export function PropertiesPanel() { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + + return ( + <> + {selectedElements.length > 0 ? ( + <ScrollArea className="bg-panel h-full rounded-sm"> + {elementsWithTracks.map(({ track, element }) => { + if (element.type === "text") { + return ( + <div key={element.id}> + <TextProperties element={element} trackId={track.id} /> + </div> + ); + } + if (element.type === "audio") { + return <AudioProperties key={element.id} _element={element} />; + } + if (element.type === "video" || element.type === "image") { + return ( + <div key={element.id}> + <VideoProperties _element={element} /> + </div> + ); + } + return null; + })} + </ScrollArea> + ) : ( + <EmptyView /> + )} + </> + ); +} + +function EmptyView() { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={Settings05Icon} + className="text-muted-foreground/75 size-10" + strokeWidth={1} + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">It's empty here</p> + <p className="text-muted-foreground text-sm text-balance"> + Click an element on the timeline to edit its properties + </p> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx new file mode 100644 index 00000000..6d2fe444 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( + <div + className={cn( + "flex gap-2", + direction === "row" + ? "items-center justify-between gap-6" + : "flex-col gap-1.5", + className, + )} + > + {children} + </div> + ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <span className={cn("text-muted-foreground text-xs", className)}> + {children} + </span> + ); +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cn("flex-1 text-sm", className)}>{children}</div>; +} + +interface PropertyGroupProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; + className?: string; + titleClassName?: string; +} + +export function PropertyGroup({ + title, + children, + defaultExpanded = true, + className, + titleClassName, +}: PropertyGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + <PropertyItem direction="column" className={cn("gap-3", className)}> + <button + type="button" + className="flex items-center gap-1.5 cursor-pointer" + onClick={() => setIsExpanded(!isExpanded)} + > + <PropertyItemLabel className={cn(titleClassName)}> + {title} + </PropertyItemLabel> + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn("size-3", !isExpanded && "-rotate-90")} + /> + </button> + {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} + </PropertyItem> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx new file mode 100644 index 00000000..f8c9bf87 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -0,0 +1,410 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { FontFamily } from "@/constants/font-constants"; +import type { TextElement } from "@/types/timeline"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn } from "@/utils/ui"; +import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { LayoutGridIcon } from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef<HTMLDivElement>(null); + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString(), + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString(), + ); + + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const handleFontSizeChange = ({ value }: { value: string }) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + } + }; + + const handleFontSizeBlur = () => { + const parsed = parseInt(fontSizeInput, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + setFontSizeInput(fontSize.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + }; + + const handleOpacityChange = ({ value }: { value: string }) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + } + }; + + const handleOpacityBlur = () => { + const parsed = parseInt(opacityInput, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + setOpacityInput(opacityPercent.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + }; + + const handleColorChange = ({ color }: { color: string }) => { + if (color !== "transparent") { + lastSelectedColor.current = color; + } + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }); + }; + + const handleTransparentToggle = ({ + isTransparent, + }: { + isTransparent: boolean; + }) => { + const newColor = isTransparent ? "transparent" : lastSelectedColor.current; + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }); + }; + + return ( + <PanelBaseView + defaultTab="transform" + value={activeTab} + onValueChange={(v) => { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + ref={containerRef} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( + <div className="space-y-6"></div> + ) : ( + <div className="space-y-6"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="bg-panel-accent min-h-20" + onChange={(e) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { content: e.target.value }, + }) + } + /> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }, + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }, + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }, + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }, + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize: value }, + }); + setFontSizeInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => + handleFontSizeChange({ value: e.target.value }) + } + onBlur={handleFontSizeBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }); + }} + containerRef={containerRef} + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => + handleOpacityChange({ value: e.target.value }) + } + onBlur={handleOpacityBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <ColorPicker + value={capitalizeFirstLetter({ + string: + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", ""), + })} + onChange={(color) => + handleColorChange({ color: `#${color}` }) + } + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => + handleTransparentToggle({ + isTransparent: + element.backgroundColor !== "transparent", + }) + } + className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" + > + <HugeiconsIcon + icon={LayoutGridIcon} + className={cn( + "text-foreground", + element.backgroundColor === "transparent" && + "text-primary", + )} + /> + </Button> + </TooltipTrigger> + <TooltipContent>Transparent background</TooltipContent> + </Tooltip> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ), + }))} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx new file mode 100644 index 00000000..bf6d92d8 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -0,0 +1,9 @@ +import type { ImageElement, VideoElement } from "@/types/timeline"; + +export function VideoProperties({ + _element, +}: { + _element: VideoElement | ImageElement; +}) { + return <div className="space-y-4 p-5">Video properties</div>; +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx deleted file mode 100644 index 36f6a2ba..00000000 --- a/apps/web/src/components/editor/preview-panel.tsx +++ /dev/null @@ -1,1134 +0,0 @@ -"use client"; - -import { useTimelineStore } from "@/stores/timeline-store"; -import { TimelineElement, TimelineTrack } from "@/types/timeline"; -import { useMediaStore } from "@/stores/media-store"; -import { MediaFile } from "@/types/media"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useEditorStore } from "@/stores/editor-store"; -import { Button } from "@/components/ui/button"; -import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; -import { useState, useRef, useEffect, useCallback } from "react"; -import { renderTimelineFrame } from "@/lib/timeline-renderer"; -import { cn } from "@/lib/utils"; -import { formatTimeCode } from "@/lib/time"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; -import { useFrameCache } from "@/hooks/use-frame-cache"; -import { useSceneStore } from "@/stores/scene-store"; -import { - DEFAULT_CANVAS_SIZE, - DEFAULT_FPS, - useProjectStore, -} from "@/stores/project-store"; -import { TextElementDragState } from "@/types/editor"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; -import { LayoutGuideOverlay } from "./layout-guide-overlay"; -import { Label } from "../ui/label"; -import { SocialsIcon } from "../icons"; -import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; - -interface ActiveElement { - element: TimelineElement; - track: TimelineTrack; - mediaItem: MediaFile | null; -} - -export function PreviewPanel() { - const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); - const { mediaFiles } = useMediaStore(); - const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); - const { isPlaying, volume, muted } = usePlaybackStore(); - const { activeProject } = useProjectStore(); - const { currentScene } = useSceneStore(); - const previewRef = useRef<HTMLDivElement>(null); - const canvasRef = useRef<HTMLCanvasElement>(null); - const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = - useFrameCache(); - const lastFrameTimeRef = useRef(0); - const renderSeqRef = useRef(0); - const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>( - null - ); - - const audioContextRef = useRef<AudioContext | null>(null); - const audioGainRef = useRef<GainNode | null>(null); - const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map()); - const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); - const containerRef = useRef<HTMLDivElement>(null); - const [previewDimensions, setPreviewDimensions] = useState({ - width: 0, - height: 0, - }); - const [isExpanded, setIsExpanded] = useState(false); - - const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; - const [dragState, setDragState] = useState<TextElementDragState>({ - isDragging: false, - elementId: null, - trackId: null, - startX: 0, - startY: 0, - initialElementX: 0, - initialElementY: 0, - currentX: 0, - currentY: 0, - elementWidth: 0, - elementHeight: 0, - }); - - useEffect(() => { - const updatePreviewSize = () => { - if (!containerRef.current) return; - - let availableWidth, availableHeight; - - if (isExpanded) { - const controlsHeight = 80; - const marginSpace = 24; - availableWidth = window.innerWidth - marginSpace; - availableHeight = window.innerHeight - controlsHeight - marginSpace; - } else { - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); - const gap = parseFloat(computedStyle.gap) || 16; - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; - - availableWidth = container.width - paddingLeft - paddingRight; - availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); - } - - const targetRatio = canvasSize.width / canvasSize.height; - const containerRatio = availableWidth / availableHeight; - let width, height; - - if (containerRatio > targetRatio) { - height = availableHeight * (isExpanded ? 0.95 : 1); - width = height * targetRatio; - } else { - width = availableWidth * (isExpanded ? 0.95 : 1); - height = width / targetRatio; - } - - setPreviewDimensions({ width, height }); - }; - - updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - if (isExpanded) { - window.addEventListener("resize", updatePreviewSize); - } - - return () => { - resizeObserver.disconnect(); - if (isExpanded) { - window.removeEventListener("resize", updatePreviewSize); - } - }; - }, [canvasSize.width, canvasSize.height, isExpanded]); - - useEffect(() => { - const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === "Escape" && isExpanded) { - setIsExpanded(false); - } - }; - - if (isExpanded) { - document.addEventListener("keydown", handleEscapeKey); - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - - return () => { - document.removeEventListener("keydown", handleEscapeKey); - document.body.style.overflow = ""; - }; - }, [isExpanded]); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!dragState.isDragging) return; - - const deltaX = e.clientX - dragState.startX; - const deltaY = e.clientY - dragState.startY; - - const scaleRatio = previewDimensions.width / canvasSize.width; - const newX = dragState.initialElementX + deltaX / scaleRatio; - const newY = dragState.initialElementY + deltaY / scaleRatio; - - const halfWidth = dragState.elementWidth / scaleRatio / 2; - const halfHeight = dragState.elementHeight / scaleRatio / 2; - - const constrainedX = Math.max( - -canvasSize.width / 2 + halfWidth, - Math.min(canvasSize.width / 2 - halfWidth, newX) - ); - const constrainedY = Math.max( - -canvasSize.height / 2 + halfHeight, - Math.min(canvasSize.height / 2 - halfHeight, newY) - ); - - setDragState((prev) => ({ - ...prev, - currentX: constrainedX, - currentY: constrainedY, - })); - }; - - const handleMouseUp = () => { - if (dragState.isDragging && dragState.trackId && dragState.elementId) { - updateTextElement(dragState.trackId, dragState.elementId, { - x: dragState.currentX, - y: dragState.currentY, - }); - } - setDragState((prev) => ({ ...prev, isDragging: false })); - }; - - if (dragState.isDragging) { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - document.body.style.cursor = "grabbing"; - document.body.style.userSelect = "none"; - } - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - }, [dragState, previewDimensions, canvasSize, updateTextElement]); - - // Clear the frame cache when background settings change since they affect rendering - useEffect(() => { - invalidateCache(); - }, [ - mediaFiles, - activeProject?.backgroundColor, - activeProject?.backgroundType, - invalidateCache, - ]); - - const handleTextMouseDown = ( - e: React.MouseEvent<HTMLDivElement>, - element: any, - trackId: string - ) => { - e.preventDefault(); - e.stopPropagation(); - - const rect = e.currentTarget.getBoundingClientRect(); - - setDragState({ - isDragging: true, - elementId: element.id, - trackId, - startX: e.clientX, - startY: e.clientY, - initialElementX: element.x, - initialElementY: element.y, - currentX: element.x, - currentY: element.y, - elementWidth: rect.width, - elementHeight: rect.height, - }); - }; - - const toggleExpanded = useCallback(() => { - setIsExpanded((prev) => !prev); - }, []); - - const hasAnyElements = tracks.some((track) => track.elements.length > 0); - const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType; - const getActiveElements = (): ActiveElement[] => { - const activeElements: ActiveElement[] = []; - - // Iterate tracks from bottom to top so topmost track renders last (on top) - [...tracks].reverse().forEach((track) => { - track.elements.forEach((element) => { - if (element.hidden) return; - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime >= elementStart && currentTime < elementEnd) { - let mediaItem = null; - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null - : mediaFiles.find((item) => item.id === element.mediaId) || - null; - } - activeElements.push({ element, track, mediaItem }); - } - }); - }); - - return activeElements; - }; - - const activeElements = getActiveElements(); - - // Ensure first frame after mount/seek renders immediately - useEffect(() => { - const onSeek = () => { - lastFrameTimeRef.current = -Infinity; - renderSeqRef.current++; - }; - window.addEventListener("playback-seek", onSeek as EventListener); - lastFrameTimeRef.current = -Infinity; - return () => { - window.removeEventListener("playback-seek", onSeek as EventListener); - }; - }, []); - - // Web Audio: schedule only on play/pause/seek/volume/mute changes - useEffect(() => { - const stopAll = () => { - for (const src of playingSourcesRef.current) { - try { - src.stop(); - } catch {} - } - playingSourcesRef.current.clear(); - }; - - type WebAudioWindow = Window & { - AudioContext?: typeof AudioContext; - webkitAudioContext?: typeof AudioContext; - }; - const ensureAudioGraph = async () => { - if (!audioContextRef.current) { - const win = window as WebAudioWindow; - const Ctx = win.AudioContext ?? win.webkitAudioContext; - if (!Ctx) return; - audioContextRef.current = new Ctx(); - } - if (!audioGainRef.current) { - audioGainRef.current = audioContextRef.current!.createGain(); - audioGainRef.current.connect(audioContextRef.current!.destination); - } - if (audioContextRef.current!.state === "suspended") { - try { - await audioContextRef.current!.resume(); - } catch {} - } - const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume)); - audioGainRef.current!.gain.setValueAtTime( - gainValue, - audioContextRef.current!.currentTime - ); - }; - - const scheduleNow = async () => { - await ensureAudioGraph(); - const audioCtx = audioContextRef.current!; - const gain = audioGainRef.current!; - - const tracksSnapshot = useTimelineStore.getState().tracks; - const mediaList = mediaFiles; - const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); - const playbackNow = usePlaybackStore.getState().currentTime; - - const audible: Array<{ - id: string; - elementStart: number; - trimStart: number; - trimEnd: number; - duration: number; - muted: boolean; - trackMuted: boolean; - }> = []; - const uniqueIds = new Set<string>(); - for (const track of tracksSnapshot) { - for (const element of track.elements) { - if (element.type !== "media") continue; - const media = idToMedia.get(element.mediaId); - if (!media || media.type !== "audio") continue; - const visibleDuration = - element.duration - element.trimStart - element.trimEnd; - if (visibleDuration <= 0) continue; - const localTime = playbackNow - element.startTime + element.trimStart; - if (localTime < 0 || localTime >= visibleDuration) continue; - audible.push({ - id: media.id, - elementStart: element.startTime, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - duration: element.duration, - muted: !!element.muted, - trackMuted: !!track.muted, - }); - uniqueIds.add(media.id); - } - } - - if (audible.length === 0) return; - - // Decode buffers as needed - const decodePromises: Array<Promise<void>> = []; - for (const id of uniqueIds) { - if (!audioBuffersRef.current.has(id)) { - const mediaItem = idToMedia.get(id); - if (!mediaItem) continue; - const p = (async () => { - const arr = await mediaItem.file.arrayBuffer(); - const buf = await audioCtx.decodeAudioData(arr.slice(0)); - audioBuffersRef.current.set(id, buf); - })(); - decodePromises.push(p); - } - } - await Promise.all(decodePromises); - - const startAt = audioCtx.currentTime + 0.02; - for (const entry of audible) { - if (entry.muted || entry.trackMuted) continue; - const buffer = audioBuffersRef.current.get(entry.id); - if (!buffer) continue; - const visibleDuration = - entry.duration - entry.trimStart - entry.trimEnd; - const localTime = Math.max( - 0, - playbackNow - entry.elementStart + entry.trimStart - ); - const playDuration = Math.max(0, visibleDuration - localTime); - if (playDuration <= 0) continue; - const src = audioCtx.createBufferSource(); - src.buffer = buffer; - src.connect(gain); - try { - src.start(startAt, localTime, playDuration); - playingSourcesRef.current.add(src); - } catch {} - } - }; - - const onSeek = () => { - if (!isPlaying) return; - for (const src of playingSourcesRef.current) { - try { - src.stop(); - } catch {} - } - playingSourcesRef.current.clear(); - void scheduleNow(); - }; - - // Apply volume/mute changes immediately - void ensureAudioGraph(); - - // Start/stop on play state changes - for (const src of playingSourcesRef.current) { - try { - src.stop(); - } catch {} - } - playingSourcesRef.current.clear(); - if (isPlaying) { - void scheduleNow(); - } - - window.addEventListener("playback-seek", onSeek as EventListener); - return () => { - window.removeEventListener("playback-seek", onSeek as EventListener); - for (const src of playingSourcesRef.current) { - try { - src.stop(); - } catch {} - } - playingSourcesRef.current.clear(); - }; - }, [isPlaying, volume, muted, mediaFiles]); - - // Canvas: draw current frame with caching - useEffect(() => { - const draw = async () => { - const canvas = canvasRef.current; - if (!canvas) return; - const mainCtx = canvas.getContext("2d"); - if (!mainCtx) return; - - // Set canvas internal resolution to avoid blurry scaling - const displayWidth = Math.max(1, Math.floor(previewDimensions.width)); - const displayHeight = Math.max(1, Math.floor(previewDimensions.height)); - if (canvas.width !== displayWidth || canvas.height !== displayHeight) { - canvas.width = displayWidth; - canvas.height = displayHeight; - } - - // Throttle rendering to project FPS during playback only - const fps = activeProject?.fps || DEFAULT_FPS; - const minDelta = 1 / fps; - if (isPlaying) { - if (currentTime - lastFrameTimeRef.current < minDelta) { - return; - } - lastFrameTimeRef.current = currentTime; - } - - const cachedFrame = getCachedFrame( - currentTime, - tracks, - mediaFiles, - activeProject, - currentScene?.id - ); - if (cachedFrame) { - mainCtx.putImageData(cachedFrame, 0, 0); - - // Pre-render nearby frames in background - if (!isPlaying) { - // Only during scrubbing to avoid interfering with playback - preRenderNearbyFrames( - currentTime, - tracks, - mediaFiles, - activeProject, - async (time: number) => { - const tempCanvas = document.createElement("canvas"); - tempCanvas.width = displayWidth; - tempCanvas.height = displayHeight; - const tempCtx = tempCanvas.getContext("2d"); - if (!tempCtx) - throw new Error("Failed to create temp canvas context"); - - await renderTimelineFrame({ - ctx: tempCtx, - time, - canvasWidth: displayWidth, - canvasHeight: displayHeight, - tracks, - mediaFiles, - backgroundType: activeProject?.backgroundType, - blurIntensity: activeProject?.blurIntensity, - backgroundColor: - activeProject?.backgroundType === "blur" - ? undefined - : activeProject?.backgroundColor || "#000000", - projectCanvasSize: canvasSize, - }); - - return tempCtx.getImageData(0, 0, displayWidth, displayHeight); - }, - currentScene?.id, - 3 - ); - } else { - // Small lookahead while playing - preRenderNearbyFrames( - currentTime, - tracks, - mediaFiles, - activeProject, - async (time: number) => { - const tempCanvas = document.createElement("canvas"); - tempCanvas.width = displayWidth; - tempCanvas.height = displayHeight; - const tempCtx = tempCanvas.getContext("2d"); - if (!tempCtx) - throw new Error("Failed to create temp canvas context"); - - await renderTimelineFrame({ - ctx: tempCtx, - time, - canvasWidth: displayWidth, - canvasHeight: displayHeight, - tracks, - mediaFiles, - backgroundType: activeProject?.backgroundType, - blurIntensity: activeProject?.blurIntensity, - backgroundColor: - activeProject?.backgroundType === "blur" - ? undefined - : activeProject?.backgroundColor || "#000000", - projectCanvasSize: canvasSize, - }); - - return tempCtx.getImageData(0, 0, displayWidth, displayHeight); - }, - currentScene?.id, - 1 - ); - } - return; - } - - // Cache miss - render from scratch - if (!offscreenCanvasRef.current) { - const hasOffscreen = - typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) - .OffscreenCanvas !== "undefined"; - if (hasOffscreen) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas( - displayWidth, - displayHeight - ) as OffscreenCanvas; - } else { - const c = document.createElement("canvas"); - c.width = displayWidth; - c.height = displayHeight; - offscreenCanvasRef.current = c; - } - } - // Ensure size matches - if ( - offscreenCanvasRef.current && - (offscreenCanvasRef.current as HTMLCanvasElement).getContext - ) { - const c = offscreenCanvasRef.current as HTMLCanvasElement; - if (c.width !== displayWidth || c.height !== displayHeight) { - c.width = displayWidth; - c.height = displayHeight; - } - } else { - const c = offscreenCanvasRef.current as OffscreenCanvas; - // @ts-ignore width/height exist on OffscreenCanvas in modern browsers - if ( - (c as unknown as { width: number }).width !== displayWidth || - (c as unknown as { height: number }).height !== displayHeight - ) { - // @ts-ignore - (c as unknown as { width: number }).width = displayWidth; - // @ts-ignore - (c as unknown as { height: number }).height = displayHeight; - } - } - const offscreenCanvas = offscreenCanvasRef.current as - | HTMLCanvasElement - | OffscreenCanvas; - const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext - ? (offscreenCanvas as HTMLCanvasElement).getContext("2d") - : (offscreenCanvas as OffscreenCanvas).getContext("2d"); - if (!offCtx) return; - - await renderTimelineFrame({ - ctx: offCtx as CanvasRenderingContext2D, - time: currentTime, - canvasWidth: displayWidth, - canvasHeight: displayHeight, - tracks, - mediaFiles, - backgroundType: activeProject?.backgroundType, - blurIntensity: activeProject?.blurIntensity, - backgroundColor: - activeProject?.backgroundType === "blur" - ? undefined - : activeProject?.backgroundColor || "#000000", - projectCanvasSize: canvasSize, - }); - - const imageData = (offCtx as CanvasRenderingContext2D).getImageData( - 0, - 0, - displayWidth, - displayHeight - ); - cacheFrame( - currentTime, - imageData, - tracks, - mediaFiles, - activeProject, - currentScene?.id - ); - - // Blit offscreen to visible canvas - mainCtx.clearRect(0, 0, displayWidth, displayHeight); - if ((offscreenCanvas as HTMLCanvasElement).getContext) { - mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0); - } else { - mainCtx.drawImage( - offscreenCanvas as unknown as CanvasImageSource, - 0, - 0 - ); - } - }; - - void draw(); - }, [ - activeElements, - currentTime, - previewDimensions.width, - previewDimensions.height, - canvasSize.width, - canvasSize.height, - activeProject?.backgroundType, - activeProject?.backgroundColor, - getCachedFrame, - cacheFrame, - preRenderNearbyFrames, - isPlaying, - ]); - - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test" // Exclude test elements - ); - }; - - const blurBackgroundElements = getBlurBackgroundElements(); - - // Render blur background layer (handled by canvas now) - const renderBlurBackground = () => null; - - // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. - const renderElement = (_elementData: ActiveElement) => null; - - return ( - <> - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0" - > - <div className="flex-1" /> - {shouldRenderPreview ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - background: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - <canvas - ref={canvasRef} - style={{ - position: "absolute", - left: 0, - top: 0, - width: previewDimensions.width, - height: previewDimensions.height, - }} - aria-label="Video preview canvas" - /> - {activeElements.length === 0 ? ( - <></> - ) : ( - activeElements.map((elementData) => renderElement(elementData)) - )} - <LayoutGuideOverlay /> - </div> - ) : null} - - <div className="flex-1" /> - - <PreviewToolbar - hasAnyElements={hasAnyElements} - onToggleExpanded={toggleExpanded} - isExpanded={isExpanded} - currentTime={currentTime} - setCurrentTime={setCurrentTime} - toggle={toggle} - getTotalDuration={getTotalDuration} - /> - </div> - </div> - - {isExpanded && ( - <FullscreenPreview - previewDimensions={previewDimensions} - activeProject={activeProject} - renderBlurBackground={renderBlurBackground} - activeElements={activeElements} - renderElement={renderElement} - blurBackgroundElements={blurBackgroundElements} - hasAnyElements={hasAnyElements} - toggleExpanded={toggleExpanded} - currentTime={currentTime} - setCurrentTime={setCurrentTime} - toggle={toggle} - getTotalDuration={getTotalDuration} - /> - )} - </> - ); -} - -function FullscreenToolbar({ - hasAnyElements, - onToggleExpanded, - currentTime, - setCurrentTime, - toggle, - getTotalDuration, -}: { - hasAnyElements: boolean; - onToggleExpanded: () => void; - currentTime: number; - setCurrentTime: (time: number) => void; - toggle: () => void; - getTotalDuration: () => number; -}) { - const { isPlaying, seek } = usePlaybackStore(); - const { activeProject } = useProjectStore(); - const [isDragging, setIsDragging] = useState(false); - - const totalDuration = getTotalDuration(); - const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; - - const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { - if (!hasAnyElements) return; - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, clickX / rect.width)); - const newTime = percentage * totalDuration; - setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); - }; - - const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => { - if (!hasAnyElements) return; - e.preventDefault(); - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - setIsDragging(true); - - const handleMouseMove = (moveEvent: MouseEvent) => { - moveEvent.preventDefault(); - const dragX = moveEvent.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, dragX / rect.width)); - const newTime = percentage * totalDuration; - setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); - }; - - const handleMouseUp = () => { - setIsDragging(false); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.userSelect = ""; - }; - - document.body.style.userSelect = "none"; - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - handleMouseMove(e.nativeEvent); - }; - - const skipBackward = () => { - const newTime = Math.max(0, currentTime - 1); - setCurrentTime(newTime); - }; - - const skipForward = () => { - const newTime = Math.min(totalDuration, currentTime + 1); - setCurrentTime(newTime); - }; - - return ( - <div - data-toolbar - className="flex items-center gap-2 p-1 pt-2 w-full text-foreground relative" - > - <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-foreground/90"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={activeProject?.fps || DEFAULT_FPS} - onTimeChange={seek} - disabled={!hasAnyElements} - className="text-foreground/90 hover:bg-white/10" - /> - <span className="opacity-50">/</span> - <span> - {formatTimeCode( - totalDuration, - "HH:MM:SS:FF", - activeProject?.fps || DEFAULT_FPS - )} - </span> - </div> - - <div className="flex items-center gap-1"> - <Button - variant="text" - size="icon" - onClick={skipBackward} - disabled={!hasAnyElements} - className="h-auto p-0 text-foreground" - title="Skip backward 1s" - > - <SkipBack className="h-3 w-3" /> - </Button> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0 text-foreground hover:text-foreground/80" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> - <Button - variant="text" - size="icon" - onClick={skipForward} - disabled={!hasAnyElements} - className="h-auto p-0 text-foreground hover:text-foreground/80" - title="Skip forward 1s" - > - <SkipForward className="h-3 w-3" /> - </Button> - </div> - - <div className="flex-1 flex items-center gap-2"> - <div - className={cn( - "relative h-1 rounded-full cursor-pointer flex-1 bg-foreground/20", - !hasAnyElements && "opacity-50 cursor-not-allowed" - )} - onClick={hasAnyElements ? handleTimelineClick : undefined} - onMouseDown={hasAnyElements ? handleTimelineDrag : undefined} - style={{ userSelect: "none" }} - > - <div - className={cn( - "absolute top-0 left-0 h-full rounded-full bg-foreground", - !isDragging && "duration-100" - )} - style={{ width: `${progress}%` }} - /> - <div - className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-foreground border border-black/20" - style={{ left: `${progress}%` }} - /> - </div> - </div> - - <Button - variant="text" - size="icon" - className="size-4! text-foreground/80 hover:text-foreground" - onClick={onToggleExpanded} - title="Exit fullscreen (Esc)" - > - <Expand className="size-4!" /> - </Button> - </div> - ); -} - -function FullscreenPreview({ - previewDimensions, - activeProject, - renderBlurBackground, - activeElements, - renderElement, - blurBackgroundElements, - hasAnyElements, - toggleExpanded, - currentTime, - setCurrentTime, - toggle, - getTotalDuration, -}: { - previewDimensions: { width: number; height: number }; - activeProject: any; - renderBlurBackground: () => React.ReactNode; - activeElements: ActiveElement[]; - renderElement: (elementData: ActiveElement, index: number) => React.ReactNode; - blurBackgroundElements: ActiveElement[]; - hasAnyElements: boolean; - toggleExpanded: () => void; - currentTime: number; - setCurrentTime: (time: number) => void; - toggle: () => void; - getTotalDuration: () => number; -}) { - return ( - <div className="fixed inset-0 z-9999 flex flex-col"> - <div className="flex-1 flex items-center justify-center bg-background"> - <div - className="relative overflow-hidden border border-border m-3" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - background: - activeProject?.backgroundType === "blur" - ? "#1a1a1a" - : activeProject?.backgroundColor || "#1a1a1a", - }} - > - {renderBlurBackground()} - {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-white/60"> - No elements at current time - </div> - ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) - )} - <LayoutGuideOverlay /> - </div> - </div> - <div className="p-4 bg-background"> - <FullscreenToolbar - hasAnyElements={hasAnyElements} - onToggleExpanded={toggleExpanded} - currentTime={currentTime} - setCurrentTime={setCurrentTime} - toggle={toggle} - getTotalDuration={getTotalDuration} - /> - </div> - </div> - ); -} - -function PreviewToolbar({ - hasAnyElements, - onToggleExpanded, - isExpanded, - currentTime, - setCurrentTime, - toggle, - getTotalDuration, -}: { - hasAnyElements: boolean; - onToggleExpanded: () => void; - isExpanded: boolean; - currentTime: number; - setCurrentTime: (time: number) => void; - toggle: () => void; - getTotalDuration: () => number; -}) { - const { isPlaying } = usePlaybackStore(); - const { layoutGuide, toggleLayoutGuide } = useEditorStore(); - - if (isExpanded) { - return ( - <FullscreenToolbar - {...{ - hasAnyElements, - onToggleExpanded, - currentTime, - setCurrentTime, - toggle, - getTotalDuration, - }} - /> - ); - } - - return ( - <div - data-toolbar - className="flex justify-end gap-2 h-auto pb-5 pr-5 pt-4 w-full" - > - <div className="flex items-center gap-2"> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> - <Popover> - <PopoverTrigger asChild> - <Button - variant="text" - size="icon" - className="h-auto p-0 mr-1" - title="Toggle layout guide" - > - <SocialsIcon className="!size-6" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-80"> - <div className="grid gap-4"> - <div className="space-y-2"> - <h4 className="font-medium leading-none">Layout guide</h4> - <p className="text-sm text-muted-foreground"> - Show platform-specific layout guides to help align your - content with interface elements like profile pictures, - usernames, and interaction buttons. - </p> - </div> - <div className="grid gap-2"> - <div className="flex items-center space-x-2"> - <Checkbox - id="none" - checked={layoutGuide.platform === null} - onCheckedChange={() => - toggleLayoutGuide(layoutGuide.platform || "tiktok") - } - /> - <Label htmlFor="none">None</Label> - </div> - {Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => ( - <div key={platform} className="flex items-center space-x-2"> - <Checkbox - id={platform} - checked={layoutGuide.platform === platform} - onCheckedChange={() => - toggleLayoutGuide(platform as PlatformLayout) - } - /> - <Label htmlFor={platform}>{label}</Label> - </div> - ))} - </div> - </div> - </PopoverContent> - </Popover> - <Button - variant="text" - size="icon" - className="size-4!" - onClick={onToggleExpanded} - title="Enter fullscreen" - > - <Expand className="size-4!" /> - </Button> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx deleted file mode 100644 index 8d69e36a..00000000 --- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MediaElement } from "@/types/timeline"; - -export function AudioProperties({ element }: { element: MediaElement }) { - return <div className="space-y-4 p-5">Audio properties</div>; -} diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx deleted file mode 100644 index 150f9512..00000000 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { ScrollArea } from "../../ui/scroll-area"; -import { AudioProperties } from "./audio-properties"; -import { MediaProperties } from "./media-properties"; -import { TextProperties } from "./text-properties"; -import { SquareSlashIcon } from "lucide-react"; - -export function PropertiesPanel() { - const { selectedElements, tracks } = useTimelineStore(); - const { mediaFiles } = useMediaStore(); - - return ( - <> - {selectedElements.length > 0 ? ( - <ScrollArea className="h-full bg-panel rounded-sm"> - {selectedElements.map(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (element?.type === "text") { - return ( - <div key={elementId}> - <TextProperties element={element} trackId={trackId} /> - </div> - ); - } - if (element?.type === "media") { - const mediaFile = mediaFiles.find( - (file) => file.id === element.mediaId - ); - - if (mediaFile?.type === "audio") { - return <AudioProperties key={elementId} element={element} />; - } - - return ( - <div key={elementId}> - <MediaProperties element={element} /> - </div> - ); - } - return null; - })} - </ScrollArea> - ) : ( - <EmptyView /> - )} - </> - ); -} - -function EmptyView() { - return ( - <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> - <SquareSlashIcon - className="w-10 h-10 text-muted-foreground" - strokeWidth={1.5} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">It’s empty here</p> - <p className="text-sm text-muted-foreground text-balance"> - Click an element on the timeline to edit its properties - </p> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx deleted file mode 100644 index 4ea28bb2..00000000 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MediaElement } from "@/types/timeline"; - -export function MediaProperties({ element }: { element: MediaElement }) { - return <div className="space-y-4 p-5">Media properties</div>; -} diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx deleted file mode 100644 index f7d5dbda..00000000 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { cn } from "@/lib/utils"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( - <div - className={cn( - "flex gap-2", - direction === "row" - ? "items-center justify-between gap-6" - : "flex-col gap-1.5", - className - )} - > - {children} - </div> - ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <label className={cn("text-xs text-muted-foreground", className)}> - {children} - </label> - ); -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={cn("flex-1 text-sm", className)}>{children}</div>; -} - -interface PropertyGroupProps { - title: string; - children: React.ReactNode; - defaultExpanded?: boolean; - className?: string; - titleClassName?: string; -} - -export function PropertyGroup({ - title, - children, - defaultExpanded = true, - className, - titleClassName, -}: PropertyGroupProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - return ( - <PropertyItem direction="column" className={cn("gap-3", className)}> - <div - className="flex items-center gap-1.5 cursor-pointer" - onClick={() => setIsExpanded(!isExpanded)} - > - <PropertyItemLabel className={cn("cursor-pointer", titleClassName)}> - {title} - </PropertyItemLabel> - <ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} /> - </div> - {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} - </PropertyItem> - ); -} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx deleted file mode 100644 index deaee157..00000000 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import { FontFamily } from "@/constants/font-constants"; -import { TextElement } from "@/types/timeline"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useState, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panel-base-view"; -import { - TEXT_PROPERTIES_TABS, - isTextPropertiesTab, - useTextPropertiesStore, -} from "@/stores/text-properties-store"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { cn, uppercase } from "@/lib/utils"; -import { Grid2x2 } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -export function TextProperties({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const { updateTextElement } = useTimelineStore(); - const { activeTab, setActiveTab } = useTextPropertiesStore(); - const containerRef = useRef<HTMLDivElement>(null); - // Local state for input values to allow temporary empty/invalid states - const [fontSizeInput, setFontSizeInput] = useState( - element.fontSize.toString() - ); - const [opacityInput, setOpacityInput] = useState( - Math.round(element.opacity * 100).toString() - ); - - // Track the last selected color for toggling - const lastSelectedColor = useRef("#000000"); - - const parseAndValidateNumber = ( - value: string, - min: number, - max: number, - fallback: number - ): number => { - const parsed = parseInt(value, 10); - if (isNaN(parsed)) return fallback; - return Math.max(min, Math.min(max, parsed)); - }; - - const handleFontSizeChange = (value: string) => { - setFontSizeInput(value); - - if (value.trim() !== "") { - const fontSize = parseAndValidateNumber(value, 8, 300, element.fontSize); - updateTextElement(trackId, element.id, { fontSize }); - } - }; - - const handleFontSizeBlur = () => { - const fontSize = parseAndValidateNumber( - fontSizeInput, - 8, - 300, - element.fontSize - ); - setFontSizeInput(fontSize.toString()); - updateTextElement(trackId, element.id, { fontSize }); - }; - - const handleOpacityChange = (value: string) => { - setOpacityInput(value); - - if (value.trim() !== "") { - const opacityPercent = parseAndValidateNumber( - value, - 0, - 100, - Math.round(element.opacity * 100) - ); - updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 }); - } - }; - - const handleOpacityBlur = () => { - const opacityPercent = parseAndValidateNumber( - opacityInput, - 0, - 100, - Math.round(element.opacity * 100) - ); - setOpacityInput(opacityPercent.toString()); - updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 }); - }; - - // Update last selected color when a new color is picked - const handleColorChange = (color: string) => { - if (color !== "transparent") { - lastSelectedColor.current = color; - } - updateTextElement(trackId, element.id, { backgroundColor: color }); - }; - - // Toggle between transparent and last selected color - const handleTransparentToggle = (isTransparent: boolean) => { - const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - updateTextElement(trackId, element.id, { backgroundColor: newColor }); - }; - - return ( - <PanelBaseView - defaultTab="transform" - value={activeTab} - onValueChange={(v) => { - if (isTextPropertiesTab(v)) setActiveTab(v); - }} - ref={containerRef} - tabs={TEXT_PROPERTIES_TABS.map((t) => ({ - value: t.value, - label: t.label, - content: - t.value === "transform" ? ( - <div className="space-y-6"></div> - ) : ( - <div className="space-y-6"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="min-h-18 resize-none bg-panel-accent" - onChange={(e) => - updateTextElement(trackId, element.id, { - content: e.target.value, - }) - } - /> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - updateTextElement(trackId, element.id, { - fontFamily: value, - }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={8} - max={300} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { - fontSize: value, - }); - setFontSizeInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeInput} - min={8} - max={300} - onChange={(e) => handleFontSizeChange(e.target.value)} - onBlur={handleFontSizeBlur} - className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent - [appearance:textfield] - [&::-webkit-outer-spin-button]:appearance-none - [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase( - (element.color || "FFFFFF").replace("#", "") - )} - onChange={(color) => { - updateTextElement(trackId, element.id, { - color: `#${color}`, - }); - }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { - opacity: value / 100, - }); - setOpacityInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={opacityInput} - min={0} - max={100} - onChange={(e) => handleOpacityChange(e.target.value)} - onBlur={handleOpacityBlur} - className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent - [appearance:textfield] - [&::-webkit-outer-spin-button]:appearance-none - [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Background</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <ColorPicker - value={uppercase( - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : (element.backgroundColor || "#000000").replace( - "#", - "" - ) - )} - onChange={(color) => handleColorChange(`#${color}`)} - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "opacity-50 pointer-events-none" - : "" - } - /> - - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="icon" - onClick={() => - handleTransparentToggle( - element.backgroundColor !== "transparent" - ) - } - className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden" - > - <Grid2x2 - className={cn( - "text-foreground", - element.backgroundColor === "transparent" && - "text-primary" - )} - /> - </Button> - </TooltipTrigger> - <TooltipContent>Transparent background</TooltipContent> - </Tooltip> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - ), - }))} - /> - ); -} diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx index b5432b53..f7576377 100644 --- a/apps/web/src/components/editor/scenes-view.tsx +++ b/apps/web/src/components/editor/scenes-view.tsx @@ -1,198 +1,219 @@ "use client"; import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; -import { useSceneStore } from "@/stores/scene-store"; import { Check, ListCheck, Trash2 } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/utils/ui"; import { useState } from "react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogTrigger, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogTrigger, } from "@/components/ui/dialog"; +import { canDeleteScene, getMainScene } from "@/lib/scenes"; +import { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; export function ScenesView({ children }: { children: React.ReactNode }) { - const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore(); - const [isSelectMode, setIsSelectMode] = useState(false); - const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set()); + const editor = useEditor(); + const scenes = editor.scenes.getScenes(); + const currentScene = editor.scenes.getActiveScene(); + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set()); - const handleSceneSwitch = async (sceneId: string) => { - if (isSelectMode) { - toggleSceneSelection(sceneId); - return; - } + const handleSceneSwitch = async (sceneId: string) => { + if (isSelectMode) { + toggleSceneSelection({ sceneId }); + return; + } - try { - await switchToScene({ sceneId }); - } catch (error) { - console.error("Failed to switch scene:", error); - } - }; + try { + await editor.scenes.switchToScene({ sceneId }); + } catch (error) { + console.error("Failed to switch scene:", error); + } + }; - const toggleSceneSelection = (sceneId: string) => { - setSelectedScenes((prev) => { - const newSet = new Set(prev); - if (newSet.has(sceneId)) { - newSet.delete(sceneId); - } else { - newSet.add(sceneId); - } - return newSet; - }); - }; + const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => { + setSelectedScenes((prev) => { + const newSet = new Set(prev); + if (newSet.has(sceneId)) { + newSet.delete(sceneId); + } else { + newSet.add(sceneId); + } + return newSet; + }); + }; - const handleSelectMode = () => { - setIsSelectMode(!isSelectMode); - setSelectedScenes(new Set()); - }; + const handleSelectMode = () => { + setIsSelectMode(!isSelectMode); + setSelectedScenes(new Set()); + }; - const handleDeleteSelected = async () => { - for (const sceneId of selectedScenes) { - const scene = scenes.find((s) => s.id === sceneId); - if (scene && !scene.isMain) { - try { - await deleteScene({ sceneId }); - } catch (error) { - console.error("Failed to delete scene:", error); - } - } - } - setSelectedScenes(new Set()); - setIsSelectMode(false); - }; + const handleDeleteSelected = async () => { + for (const sceneId of selectedScenes) { + const scene = scenes.find((scene) => scene.id === sceneId); + if (!scene) { + continue; + } - return ( - <Sheet> - <SheetTrigger asChild>{children}</SheetTrigger> - <SheetContent> - <SheetHeader> - <SheetTitle> - {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"} - </SheetTitle> - <SheetDescription> - {isSelectMode - ? "Select scenes to delete" - : "Switch between scenes in your project"} - </SheetDescription> - </SheetHeader> - <div className="py-4 flex flex-col gap-4"> - <div className="flex items-center gap-2"> - <Button - className="rounded-md" - variant={isSelectMode ? "default" : "outline"} - size="sm" - onClick={handleSelectMode} - > - <ListCheck /> - {isSelectMode ? "Cancel" : "Select"} - </Button> - {isSelectMode && ( - <DeleteDialog - count={selectedScenes.size} - onDelete={handleDeleteSelected} - disabled={Array.from(selectedScenes).some( - (id) => scenes.find((s) => s.id === id)?.isMain - )} - > - <Button className="rounded-md" variant="destructive" size="sm"> - <Trash2 /> - Delete ({selectedScenes.size}) - </Button> - </DeleteDialog> - )} - </div> - {scenes.length === 0 ? ( - <div className="text-sm text-muted-foreground"> - No scenes available - </div> - ) : ( - <div className="space-y-2"> - {scenes.map((scene) => ( - <Button - key={scene.id} - variant="outline" - className={cn( - "w-full justify-between font-normal", - currentScene?.id === scene.id && - !isSelectMode && - "border-primary !text-primary", - isSelectMode && - selectedScenes.has(scene.id) && - "bg-accent border-foreground/30" - )} - onClick={() => handleSceneSwitch(scene.id)} - > - <span>{scene.name}</span> - <div className="flex items-center gap-2"> - {((isSelectMode && selectedScenes.has(scene.id)) || - (!isSelectMode && currentScene?.id === scene.id)) && ( - <Check className="h-4 w-4" /> - )} - </div> - </Button> - ))} - </div> - )} - </div> - </SheetContent> - </Sheet> - ); + const { canDelete, reason } = canDeleteScene({ scene }); + if (!canDelete) { + toast.error(reason || "Failed to delete scene"); + continue; + } + + try { + await editor.scenes.deleteScene({ sceneId }); + } catch (error) { + console.error("Failed to delete scene:", error); + } + } + setSelectedScenes(new Set()); + setIsSelectMode(false); + }; + + const isMainSceneSelected = (() => { + const mainScene = getMainScene({ scenes }); + return Boolean(mainScene?.id && selectedScenes.has(mainScene.id)); + })(); + + return ( + <Sheet> + <SheetTrigger asChild>{children}</SheetTrigger> + <SheetContent> + <SheetHeader> + <SheetTitle> + {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"} + </SheetTitle> + <SheetDescription> + {isSelectMode + ? "Select scenes to delete" + : "Switch between scenes in your project"} + </SheetDescription> + </SheetHeader> + <div className="flex flex-col gap-4 py-4"> + <div className="flex items-center gap-2"> + <Button + className="rounded-md" + variant={isSelectMode ? "default" : "outline"} + size="sm" + onClick={handleSelectMode} + > + <ListCheck /> + {isSelectMode ? "Cancel" : "Select"} + </Button> + {isSelectMode && ( + <DeleteDialog + count={selectedScenes.size} + onDelete={handleDeleteSelected} + disabled={isMainSceneSelected} + trigger={ + <Button + className="rounded-md" + variant="destructive" + disabled={isMainSceneSelected} + size="sm" + > + <Trash2 /> + Delete ({selectedScenes.size}) + </Button> + } + /> + )} + </div> + {scenes.length === 0 ? ( + <div className="text-muted-foreground text-sm"> + No scenes available + </div> + ) : ( + <div className="space-y-2"> + {scenes.map((scene) => ( + <Button + key={scene.id} + variant="outline" + className={cn( + "w-full justify-between font-normal", + currentScene?.id === scene.id && + !isSelectMode && + "border-primary !text-primary", + isSelectMode && + selectedScenes.has(scene.id) && + "bg-accent border-foreground/30", + )} + onClick={() => handleSceneSwitch(scene.id)} + > + <span>{scene.name}</span> + <div className="flex items-center gap-2"> + {((isSelectMode && selectedScenes.has(scene.id)) || + (!isSelectMode && currentScene?.id === scene.id)) && ( + <Check className="size-4" /> + )} + </div> + </Button> + ))} + </div> + )} + </div> + </SheetContent> + </Sheet> + ); } function DeleteDialog({ - count, - onDelete, - disabled, - children, + count, + onDelete, + disabled, + trigger, }: { - count: number; - onDelete: () => void; - disabled?: boolean; - children: React.ReactNode; + count: number; + onDelete: () => void; + disabled?: boolean; + trigger: React.ReactNode; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); - const handleDelete = () => { - onDelete(); - setOpen(false); - }; + const handleDelete = () => { + onDelete(); + setOpen(false); + }; - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild>{children}</DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Delete Scenes</DialogTitle> - <DialogDescription> - Are you sure you want to delete {count} scene - {count === 1 ? "" : "s"}? This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button variant="outline" onClick={() => setOpen(false)}> - Cancel - </Button> - <Button - variant="destructive" - onClick={handleDelete} - disabled={disabled} - > - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{trigger}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete Scenes</DialogTitle> + <DialogDescription> + Are you sure you want to delete {count} scene + {count === 1 ? "" : "s"}? This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={disabled} + > + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); } diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index ca9d799f..32adbfd8 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -1,55 +1,50 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useMemo } from "react"; interface SelectionBoxProps { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - containerRef: React.RefObject<HTMLElement>; - isActive: boolean; + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject<HTMLElement>; + isActive: boolean; } export function SelectionBox({ - startPos, - currentPos, - containerRef, - isActive, + startPos, + currentPos, + containerRef, + isActive, }: SelectionBoxProps) { - const selectionBoxRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!isActive || !startPos || !currentPos || !containerRef.current) return; - - const container = containerRef.current; - const containerRect = container.getBoundingClientRect(); - - // Calculate relative positions within the container - 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`; - selectionBoxRef.current.style.width = `${width}px`; - selectionBoxRef.current.style.height = `${height}px`; - } - }, [startPos, currentPos, isActive, containerRef]); - - if (!isActive || !startPos || !currentPos) return null; - - return ( - <div - ref={selectionBoxRef} - className="absolute pointer-events-none z-50 bg-foreground/10" - /> - ); + const selectionBoxStyle = useMemo(() => { + if (!isActive || !startPos || !currentPos || !containerRef.current) { + return null; + } + + 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; + + const left = Math.min(startX, currentX); + const top = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + return { + left: `${left}px`, + top: `${top}px`, + width: `${width}px`, + height: `${height}px`, + }; + }, [containerRef, currentPos, isActive, startPos]); + + if (!selectionBoxStyle) return null; + + return ( + <div + style={selectionBoxStyle} + className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border" + /> + ); } diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx deleted file mode 100644 index ac884b71..00000000 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { SnapPoint } from "@/hooks/use-timeline-snapping"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import type { TimelineTrack } from "@/types/timeline"; -import { useState, useEffect } from "react"; - -interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - tracks: TimelineTrack[]; - timelineRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; -} - -export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - tracks, - timelineRef, - trackLabelsRef, - tracksScrollRef, -}: SnapIndicatorProps) { - const [scrollLeft, setScrollLeft] = useState(0); - - // Track scroll position to lock snap indicator to frame - useEffect(() => { - const tracksViewport = tracksScrollRef.current; - - if (!tracksViewport) return; - - const handleScroll = () => { - setScrollLeft(tracksViewport.scrollLeft); - }; - - // Set initial scroll position - setScrollLeft(tracksViewport.scrollLeft); - - tracksViewport.addEventListener("scroll", handleScroll); - return () => tracksViewport.removeEventListener("scroll", handleScroll); - }, [tracksScrollRef]); - - if (!isVisible || !snapPoint) { - return null; - } - - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - - // Calculate position locked to timeline content (accounting for scroll) - const timelinePosition = - snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft; - - return ( - <div - className="absolute pointer-events-none z-90" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", - }} - > - <div className={"w-0.5 h-full bg-primary/40 opacity-80"} /> - </div> - ); -} diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx deleted file mode 100644 index f2b0568d..00000000 --- a/apps/web/src/components/editor/speed-control.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx new file mode 100644 index 00000000..bbc5cb83 --- /dev/null +++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; + +interface AudioWaveformProps { + audioUrl?: string; + audioBuffer?: AudioBuffer; + height?: number; + className?: string; +} + +function extractPeaks({ + buffer, + length = 512, +}: { + buffer: AudioBuffer; + length?: number; +}): number[][] { + const channels = buffer.numberOfChannels; + const peaks: number[][] = []; + + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c); + const step = Math.floor(data.length / length); + const channelPeaks: number[] = []; + + for (let i = 0; i < length; i++) { + const start = i * step; + const end = Math.min(start + step, data.length); + let max = 0; + for (let j = start; j < end; j++) { + const abs = Math.abs(data[j]); + if (abs > max) max = abs; + } + channelPeaks.push(max); + } + peaks.push(channelPeaks); + } + + return peaks; +} + +export function AudioWaveform({ + audioUrl, + audioBuffer, + height = 32, + className = "", +}: AudioWaveformProps) { + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let mounted = true; + const ws = wavesurfer.current; + + const initWaveSurfer = async () => { + if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + + try { + if (ws) { + wavesurfer.current = null; + } + + const newWaveSurfer = WaveSurfer.create({ + container: waveformRef.current, + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", + barWidth: 2, + barGap: 1, + height, + normalize: true, + interact: false, + }); + + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + try { + newWaveSurfer.destroy(); + } catch {} + return; + } + + newWaveSurfer.on("ready", () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); + + newWaveSurfer.on("error", (err) => { + if (mounted) { + console.error("WaveSurfer error:", err); + setError(true); + setIsLoading(false); + } + }); + + if (audioBuffer) { + const peaks = extractPeaks({ buffer: audioBuffer }); + newWaveSurfer.load("", peaks, audioBuffer.duration); + } else if (audioUrl) { + await newWaveSurfer.load(audioUrl); + } + } catch (err) { + if (mounted) { + console.error("Failed to initialize WaveSurfer:", err); + setError(true); + setIsLoading(false); + } + } + }; + + if (ws) { + const wsToDestroy = ws; + wavesurfer.current = null; + + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + if (mounted) { + initWaveSurfer(); + } + }); + } else { + initWaveSurfer(); + } + + return () => { + mounted = false; + + const wsToDestroy = wavesurfer.current; + + wavesurfer.current = null; + + if (wsToDestroy) { + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + }); + } + }; + }, [audioUrl, audioBuffer, height]); + + if (error) { + return ( + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > + <span className="text-foreground/60 text-xs">Audio unavailable</span> + </div> + ); + } + + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-foreground/60 text-xs">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`} + style={{ height }} + /> + </div> + ); +} + +export default AudioWaveform; diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/timeline/bookmarks.tsx new file mode 100644 index 00000000..cfcb1e23 --- /dev/null +++ b/apps/web/src/components/editor/timeline/bookmarks.tsx @@ -0,0 +1,111 @@ +import { useEditor } from "@/hooks/use-editor"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getSnappedSeekTime } from "@/lib/time"; +import { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +interface TimelineBookmarksRowProps { + zoomLevel: number; + dynamicTimelineWidth: number; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineBookmarksRow({ + zoomLevel, + dynamicTimelineWidth, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineBookmarksRowProps) { + const editor = useEditor(); + const activeScene = editor.scenes.getActiveScene(); + + return ( + <div className="relative h-4 flex-1 overflow-hidden"> + <button + className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + aria-label="Timeline ruler" + type="button" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={(event) => { + handleRulerMouseDown(event); + handleRulerTrackingMouseDown(event); + }} + > + {activeScene.bookmarks.map((time: number) => ( + <TimelineBookmark + key={`bookmark-row-${time}`} + time={time} + zoomLevel={zoomLevel} + /> + ))} + </button> + </div> + ); +} + +export function TimelineBookmark({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + + const handleBookmarkActivate = ({ + event, + }: { + event: + | React.MouseEvent<HTMLButtonElement> + | React.KeyboardEvent<HTMLButtonElement>; + }) => { + event.stopPropagation(); + const framesPerSecond = activeProject?.settings.fps ?? 30; + const snappedTime = getSnappedSeekTime({ + rawTime: time, + duration, + fps: framesPerSecond, + }); + editor.playback.seek({ time: snappedTime }); + }; + + return ( + <button + className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + aria-label={`Seek to bookmark at ${time}s`} + type="button" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => handleBookmarkActivate({ event })} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleBookmarkActivate({ event }); + }} + > + <div className="text-primary absolute top-[-1px] left-[-5px]"> + <HugeiconsIcon + icon={Bookmark02Icon} + aria-hidden="true" + className="fill-primary size-3" + /> + </div> + </button> + ); +} diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx new file mode 100644 index 00000000..f31c6e28 --- /dev/null +++ b/apps/web/src/components/editor/timeline/drag-line.tsx @@ -0,0 +1,28 @@ +import { getDropLineY } from "@/lib/timeline/drop-utils"; +import type { TimelineTrack, DropTarget } from "@/types/timeline"; + +interface DragLineProps { + dropTarget: DropTarget | null; + tracks: TimelineTrack[]; + isVisible: boolean; + headerHeight?: number; +} + +export function DragLine({ + dropTarget, + tracks, + isVisible, + headerHeight = 0, +}: DragLineProps) { + if (!isVisible || !dropTarget) return null; + + const y = getDropLineY({ dropTarget, tracks }); + const lineTop = y + headerHeight; + + return ( + <div + className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + style={{ top: `${lineTop}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 4a59fd21..7e2b2b66 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,925 +1,530 @@ "use client"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "../../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - SkipBack, - Video, - Music, - TypeIcon, - Magnet, - Link, - ZoomIn, - ZoomOut, - Bookmark, - Eye, - VolumeOff, - Volume2, -} from "lucide-react"; + Delete02Icon, + TaskAdd02Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, -} from "../../ui/tooltip"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "../../ui/context-menu"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; - -import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; -import { processMediaFiles } from "@/lib/media-processing"; - -import { toast } from "sonner"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; +import { useState, useRef, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; -import { - TimelinePlayhead, - useTimelinePlayheadRuler, -} from "./timeline-playhead"; +import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "../selection-box"; -import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapIndicator } from "../snap-indicator"; -import { SnapPoint } from "@/hooks/use-timeline-snapping"; -import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; -import { TimelineCacheIndicator } from "./timeline-cache-indicator"; -import { TimelineMarker } from "./timeline-marker"; -import { useFrameCache } from "@/hooks/use-frame-cache"; +import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - TIMELINE_CONSTANTS, - snapTimeToFrame, + TIMELINE_CONSTANTS, + TRACK_ICONS, } from "@/constants/timeline-constants"; -import { Slider } from "@/components/ui/slider"; -import { formatTimeCode } from "@/lib/time"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + canTracktHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, + isMainTrack, +} from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; +import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; +import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { TimelineBookmarksRow } from "./bookmarks"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useEditor } from "@/hooks/use-editor"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/lib/actions"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their elements. - // You can drag media here to add it to your project. - // elements can be trimmed, deleted, and moved. - - const { - tracks, - getTotalDuration, - clearSelectedElements, - snappingEnabled, - setSelectedElements, - toggleTrackMute, - dragState, - } = useTimelineStore(); - const { mediaFiles, addMediaFile } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration } = usePlaybackStore(); - const { getRenderStatus } = useFrameCache(); - const [isDragOver, setIsDragOver] = useState(false); - const { addElementToNewTrack } = useTimelineStore(); - const dragCounterRef = useRef(0); - const timelineRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(null); - const [isInTimeline, setIsInTimeline] = useState(false); - - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); - - // Old marquee selection removed - using new SelectionBox component instead - - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000 // Minimum width - ); - - // 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); - const isUpdatingRef = useRef(false); - const lastRulerSync = useRef(0); - const lastTracksSync = useRef(0); - const lastVerticalSync = useRef(0); - - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Selection box functionality - const tracksContainerRef = useRef<HTMLDivElement>(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(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); - }, []); - - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - console.log( - JSON.stringify({ - debug_mousedown: "START", - target_class: target.className, - target_parent_class: target.parentElement?.className, - clientX: e.clientX, - clientY: e.clientY, - timeStamp: e.timeStamp, - }) - ); - - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); - - console.log( - JSON.stringify({ - debug_mousedown: "CHECK", - isTimelineBackground, - hasTimelineElement: !!target.closest(".timeline-element"), - hasPlayhead: !!playheadRef.current?.contains(target), - hasTrackLabels: !!target.closest("[data-track-labels]"), - }) - ); - - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - console.log( - JSON.stringify({ - debug_mousedown: "TRACKED", - mouseTracking: mouseTrackingRef.current, - }) - ); - } else { - console.log( - JSON.stringify({ - debug_mousedown: "IGNORED - not timeline background", - }) - ); - } - }, []); - - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - console.log( - JSON.stringify({ - debug_click: "START", - target: (e.target as HTMLElement).className, - target_parent: (e.target as HTMLElement).parentElement?.className, - mouseTracking: mouseTrackingRef.current, - isSelecting, - justFinishedSelecting, - clickX: e.clientX, - clickY: e.clientY, - timeStamp: e.timeStamp, - }) - ); - - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - - // Reset mouse tracking - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; - - // Only process as click if we tracked a mouse down on timeline background - if (!isMouseDown) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - no mousedown", - mouseTracking: mouseTrackingRef.current, - }) - ); - return; - } - - // Check if mouse moved significantly (indicates drag, not click) - const deltaX = Math.abs(e.clientX - downX); - const deltaY = Math.abs(e.clientY - downY); - const deltaTime = e.timeStamp - downTime; - - if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - movement too large", - deltaX, - deltaY, - deltaTime, - }) - ); - return; - } - - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - selection operation", - isSelecting, - justFinishedSelecting, - }) - ); - return; - } - - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked timeline element", - }) - ); - return; - } - - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked playhead", - }) - ); - return; - } - - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked track labels", - }) - ); - clearSelectedElements(); - return; - } - - // Clear selected elements when clicking empty timeline area - console.log( - JSON.stringify({ - debug_click: "PROCEEDING - clearing elements", - clearingSelectedElements: true, - }) - ); - clearSelectedElements(); - - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); - - console.log( - JSON.stringify({ - debug_click: "CALCULATING POSITION", - isRulerClick, - clientX: e.clientX, - clientY: e.clientY, - target_element: (e.target as HTMLElement).tagName, - target_class: (e.target as HTMLElement).className, - }) - ); - - let mouseX: number; - let scrollLeft = 0; - - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current; - if (!rulerContent) { - console.log( - JSON.stringify({ - debug_click: "ERROR - no ruler container found", - }) - ); - return; - } - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - const tracksContent = tracksScrollRef.current; - if (!tracksContent) { - return; - } - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } - - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); - - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - ] - ); - - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); - - // Old marquee system removed - using new SelectionBox component instead - - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } - - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); - - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - addElementToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaFiles.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - addElementToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } - - try { - const processedItems = await processMediaFiles(e.dataTransfer.files); - for (const processedItem of processedItems) { - await addMediaFile(activeProject.id, processedItem); - const currentMediaFiles = mediaFiles; - const addedItem = currentMediaFiles.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - const trackType: TrackType = - addedItem.type === "audio" ? "audio" : "media"; - const targetTrackId = useTimelineStore - .getState() - .insertTrackAt(trackType, 0); - - useTimelineStore.getState().addElementToTrack(targetTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - const trackLabelsViewport = trackLabelsScrollRef.current; - - if (!rulerViewport || !tracksViewport) return; - - // Horizontal scroll synchronization between ruler and tracks - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; - lastRulerSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; - lastTracksSync.current = now; - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); - - // Vertical scroll synchronization between track labels and tracks content - if (trackLabelsViewport) { - const handleTrackLabelsScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollTop = trackLabelsViewport.scrollTop; - isUpdatingRef.current = false; - }; - const handleTracksVerticalScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - trackLabelsViewport.scrollTop = tracksViewport.scrollTop; - isUpdatingRef.current = false; - }; - - trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); - tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - trackLabelsViewport.removeEventListener( - "scroll", - handleTrackLabelsScroll - ); - tracksViewport.removeEventListener( - "scroll", - handleTracksVerticalScroll - ); - }; - } - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, []); - - return ( - <div - className={ - "h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden" - } - {...dragProps} - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} /> - - {/* Timeline Container */} - <div - className="flex-1 flex flex-col overflow-hidden relative" - ref={timelineRef} - > - <TimelinePlayhead - currentTime={currentTime} - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} - trackLabelsRef={trackLabelsRef} - tracksScrollRef={tracksScrollRef} - isVisible={showSnapIndicator} - /> - {/* Timeline Header with Ruler */} - <div className="flex bg-panel sticky top-0 z-10"> - {/* Track Labels Header */} - <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> - {/* Empty space */} - <span className="text-sm font-medium text-muted-foreground opacity-0"> - . - </span> - </div> - - {/* Timeline Ruler */} - <div - className="flex-1 relative overflow-hidden h-10" - 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 - } - handleWheel(e); - }} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - > - <ScrollArea - className="w-full" - ref={rulerScrollRef} - onScroll={(e) => { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const tracksViewport = tracksScrollRef.current; - if (tracksViewport) { - tracksViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > - <div - ref={rulerRef} - className="relative h-10 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - <TimelineCacheIndicator - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - mediaFiles={mediaFiles} - activeProject={activeProject} - getRenderStatus={getRenderStatus} - /> - {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - - return ( - <TimelineMarker - key={i} - time={time} - zoomLevel={zoomLevel} - interval={interval} - isMainMarker={isMainMarker} - /> - ); - }).filter(Boolean); - })()} - - {/* Bookmark markers */} - {(() => { - const { activeProject } = useProjectStore.getState(); - if (!activeProject?.bookmarks?.length) return null; - - return activeProject.bookmarks.map((bookmarkTime, i) => ( - <div - key={`bookmark-${i}`} - className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" - style={{ - left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - onClick={(e) => { - e.stopPropagation(); - usePlaybackStore.getState().seek(bookmarkTime); - }} - > - <div className="absolute top-[-1px] left-[-5px] text-primary"> - <Bookmark className="h-3 w-3 fill-primary" /> - </div> - </div> - )); - })()} - </div> - </ScrollArea> - </div> - </div> - - {/* Tracks Area */} - <div className="flex-1 flex overflow-hidden"> - {/* Track Labels */} - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel" - data-track-labels - > - <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="flex items-center px-3 group" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> - {track.muted ? ( - <VolumeOff - className="h-4 w-4 text-destructive cursor-pointer" - onClick={() => toggleTrackMute(track.id)} - /> - ) : ( - <Volume2 - className="h-4 w-4 text-muted-foreground cursor-pointer" - onClick={() => toggleTrackMute(track.id)} - /> - )} - <Eye className="h-4 w-4 text-muted-foreground" /> - <TrackIcon track={track} /> - </div> - </div> - ))} - </div> - </ScrollArea> - </div> - )} - - {/* Timeline Tracks Content */} - <div - className="flex-1 relative 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 - } - handleWheel(e); - }} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <ScrollArea - className="w-full h-full" - ref={tracksScrollRef} - onScroll={(e) => { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const rulerViewport = rulerScrollRef.current; - if (rulerViewport) { - rulerViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > - <div - className="relative flex-1" - style={{ - height: `${Math.max( - 200, - Math.min(800, getTotalTracksHeight(tracks)) - )}px`, - width: `${dynamicTimelineWidth}px`, - }} - > - {tracks.length === 0 ? ( - <div /> - ) : ( - <> - {tracks.map((track, index) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute left-0 right-0" - style={{ - top: `${getCumulativeHeightBefore( - tracks, - index - )}px`, - height: `${getTrackHeight(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(); - } - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - onSnapPointChange={handleSnapPointChange} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200"> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - toggleTrackMute(track.id); - }} - > - {track.muted ? "Unmute Track" : "Mute Track"} - </ContextMenuItem> - <ContextMenuItem onClick={(e) => e.stopPropagation()}> - Track settings (soon) - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </> - )} - </div> - </ScrollArea> - </div> - </div> - </div> - </div> - ); + const tracksContainerHeight = { min: 0, max: 800 }; + const { snappingEnabled } = useTimelineStore(); + const { clearElementSelection, setElementSelection } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const tracks = timeline.getTracks(); + const seek = (time: number) => editor.playback.seek({ time }); + + // refs + const timelineRef = useRef<HTMLDivElement>(null); + const timelineHeaderRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const tracksContainerRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + + // state + const [isResizing, setIsResizing] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + const handleResizeStateChange = useCallback( + ({ isResizing: nextIsResizing }: { isResizing: boolean }) => { + setIsResizing(nextIsResizing); + if (!nextIsResizing) { + setCurrentSnapPoint(null); + } + }, + [], + ); + + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); + + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef: tracksScrollRef, + }); + + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef: timelineHeaderRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useSelectionBox({ + containerRef: tracksContainerRef, + onSelectionComplete: (elements) => { + setElementSelection({ elements }); + }, + tracksScrollRef, + zoomLevel, + }); + + const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const contentWidth = + timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + contentWidth + paddingPx, + containerWidth, + ); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + useScrollSync({ + tracksScrollRef, + trackLabelsScrollRef, + }); + + const timelineHeaderHeight = + timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + + return ( + <section + className={ + "bg-panel relative flex h-full flex-col overflow-hidden rounded-sm" + } + {...dragProps} + aria-label="Timeline" + > + <TimelineToolbar + zoomLevel={zoomLevel} + minZoom={minZoomLevel} + setZoomLevel={({ zoom }) => setZoomLevel(zoom)} + /> + + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={timelineRef} + > + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + /> + <div className="flex flex-1 overflow-hidden"> + <div className="bg-panel flex w-28 shrink-0 flex-col border-r"> + <div className="bg-panel flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + <div className="bg-panel flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="bg-panel flex-1 overflow-y-auto" + style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} + > + <ScrollArea className="size-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="group flex items-center px-3" + style={{ + height: `${getTrackHeight({ type: track.type })}px`, + }} + > + <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( + <div className="bg-red-500 size-1.5 rounded-full" /> + )} + {canTracktHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ + on: VolumeHighIcon, + off: VolumeOffIcon, + }} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ + on: ViewIcon, + off: ViewOffSlashIcon, + }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + </div> + + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={timeline.getTracks()} + isVisible={isDragOver} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={timeline.getTracks()} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + <ScrollArea + className="size-full overflow-y-hidden" + ref={tracksScrollRef} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); + }} + onWheel={(event) => { + if ( + event.shiftKey || + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ) { + return; + } + handleWheel(event); + }} + onScroll={() => { + saveScrollPosition(); + }} + > + <div + className="relative" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + > + <div + ref={timelineHeaderRef} + className="bg-panel sticky top-0 z-30 flex flex-col" + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + </div> + <TimelinePlayhead + zoomLevel={zoomLevel} + rulerRef={rulerRef} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <div + className="relative" + style={{ + height: `${Math.max( + tracksContainerHeight.min, + Math.min( + tracksContainerHeight.max, + getTotalTracksHeight({ tracks }), + ), + )}px`, + }} + > + {tracks.length === 0 ? ( + <div /> + ) : ( + tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute right-0 left-0" + style={{ + top: `${getCumulativeHeightBefore({ + tracks, + trackIndex: index, + })}px`, + height: `${getTrackHeight({ + type: track.type, + })}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={handleSnapPointChange} + onResizeStateChange={handleResizeStateChange} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={handleSelectionMouseDown} + shouldIgnoreClick={shouldIgnoreClick} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={VolumeHighIcon} /> + <span> + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={ViewIcon} /> + <span> + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + <HugeiconsIcon icon={Delete02Icon} /> + Delete track + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + )) + )} + </div> + </div> + </ScrollArea> + </div> + </div> + </div> + </section> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( - <Video className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - {track.type === "text" && ( - <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - {track.type === "audio" && ( - <Music className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - </> - ); + return <>{TRACK_ICONS[track.type]}</>; +} + +function TrackToggleIcon({ + isOff, + icons, + onClick, +}: { + isOff: boolean; + icons: { + on: IconSvgElement; + off: IconSvgElement; + }; + onClick: () => void; +}) { + return ( + <> + {isOff ? ( + <HugeiconsIcon + icon={icons.off} + className="text-destructive size-4 cursor-pointer" + onClick={onClick} + /> + ) : ( + <HugeiconsIcon + icon={icons.on} + className="text-muted-foreground size-4 cursor-pointer" + onClick={onClick} + /> + )} + </> + ); } diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/timeline/snap-indicator.tsx new file mode 100644 index 00000000..6bfdf086 --- /dev/null +++ b/apps/web/src/components/editor/timeline/snap-indicator.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; + +interface SnapIndicatorProps { + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, +}: SnapIndicatorProps) { + const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, + }); + + if (!isVisible || !snapPoint) { + return null; + } + + return ( + <div + className="pointer-events-none absolute z-90" + style={{ + left: `${leftPosition}px`, + top: topPosition, + height: `${height}px`, + width: "2px", + }} + > + <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx deleted file mode 100644 index 7def2985..00000000 --- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { TimelineTrack } from "@/types/timeline"; -import { MediaFile } from "@/types/media"; -import { TProject } from "@/types/project"; -import { useSceneStore } from "@/stores/scene-store"; - -interface CacheSegment { - startTime: number; - endTime: number; - cached: boolean; -} - -interface TimelineCacheIndicatorProps { - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - mediaFiles: MediaFile[]; - activeProject: TProject | null; - getRenderStatus: ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ) => "cached" | "not-cached"; -} - -export function TimelineCacheIndicator({ - duration, - zoomLevel, - tracks, - mediaFiles, - activeProject, - getRenderStatus, -}: TimelineCacheIndicatorProps) { - const { currentScene } = useSceneStore(); - - // Calculate cache segments by sampling the timeline - const calculateCacheSegments = (): CacheSegment[] => { - const segments: CacheSegment[] = []; - const sampleRate = 10; // Sample every 0.1 seconds - const totalSamples = Math.ceil(duration * sampleRate); - - if (totalSamples === 0) { - return [{ startTime: 0, endTime: duration, cached: false }]; - } - - let currentSegment: CacheSegment | null = null; - - for (let i = 0; i <= totalSamples; i++) { - const time = i / sampleRate; - const cached = - getRenderStatus( - time, - tracks, - mediaFiles, - activeProject, - currentScene?.id - ) === "cached"; - - if (!currentSegment) { - // Start first segment - currentSegment = { - startTime: time, - endTime: time, - cached, - }; - } else if (currentSegment.cached === cached) { - // Extend current segment - currentSegment.endTime = time; - } else { - // Finish current segment and start new one - segments.push(currentSegment); - currentSegment = { - startTime: time, - endTime: time, - cached, - }; - } - } - - // Add the last segment - if (currentSegment) { - currentSegment.endTime = duration; - segments.push(currentSegment); - } - - return segments; - }; - - const cacheSegments = calculateCacheSegments(); - - return ( - <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none"> - {cacheSegments.map((segment, index) => { - const startX = - segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const endX = - segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const width = endX - startX; - - return ( - <div - key={index} - className={cn( - "absolute top-0 h-px", - segment.cached ? "bg-primary" : "bg-transparent" - )} - style={{ - left: `${startX}px`, - width: `${width}px`, - }} - title={ - segment.cached - ? "Cached (fast playback)" - : "Not cached (will render)" - } - /> - ); - })} - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index ca47d3ca..fd292ad8 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,387 +1,554 @@ "use client"; +import { useEditor } from "@/hooks/use-editor"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import AudioWaveform from "./audio-waveform"; +import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { - Scissors, - Trash2, - Copy, - Search, - RefreshCw, - EyeOff, - Eye, - Volume2, - VolumeX, -} from "lucide-react"; -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import AudioWaveform from "../audio-waveform"; -import { toast } from "sonner"; -import { TimelineElementProps, MediaElement } from "@/types/timeline"; -import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; + getTrackClasses, + getTrackHeight, + canElementHaveAudio, + canElementBeHidden, + hasMediaId, +} from "@/lib/timeline"; import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, -} from "@/constants/timeline-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, } from "../../ui/context-menu"; -import { useMediaPanelStore } from "../media-panel/store"; +import type { + TimelineElement as TimelineElementType, + TimelineTrack, + ElementDragState, +} from "@/types/timeline"; +import type { MediaAsset } from "@/types/assets"; +import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import Image from "next/image"; +import { + ScissorIcon, + Delete02Icon, + Copy01Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, + VolumeMute02Icon, + Search01Icon, + Exchange01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { uppercase } from "@/utils/string"; +import type { ComponentProps } from "react"; + +function getDisplayShortcut(action: TAction) { + const { defaultShortcuts } = getActionDefinition(action); + if (!defaultShortcuts?.length) { + return ""; + } + + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} + +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + dragState: ElementDragState; +} export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, + element, + track, + zoomLevel, + isSelected, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + dragState, }: TimelineElementProps) { - const { mediaFiles } = useMediaStore(); - const { - dragState, - copySelected, - selectedElements, - deleteSelected, - splitSelected, - toggleSelectedHidden, - toggleSelectedMuted, - duplicateElement, - revealElementInMedia, - replaceElementWithFile, - getContextMenuState, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - - const mediaItem = - element.type === "media" - ? mediaFiles.find((file) => file.id === element.mediaId) - : null; - const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; - - const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = - useTimelineElementResize({ - element, - track, - zoomLevel, - }); - - const { - isMultipleSelected, - isCurrentElementSelected, - hasAudioElements, - canSplitSelected, - } = getContextMenuState(track.id, element.id); - - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel - ); - - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; - - const elementLeft = elementStartTime * 50 * zoomLevel; - - const handleElementSplitContext = (e: React.MouseEvent) => { - e.stopPropagation(); - splitSelected( - currentTime, - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; - - const handleElementDuplicateContext = (e: React.MouseEvent) => { - e.stopPropagation(); - duplicateElement(track.id, element.id); - }; - - const handleElementCopyContext = (e: React.MouseEvent) => { - e.stopPropagation(); - copySelected(); - }; - - const handleElementDeleteContext = (e: React.MouseEvent) => { - e.stopPropagation(); - deleteSelected( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; - - const handleToggleElementContext = (e: React.MouseEvent) => { - e.stopPropagation(); - - if (hasAudio && element.type === "media") { - toggleSelectedMuted( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - } else { - toggleSelectedHidden( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - } - }; - - const handleReplaceClip = (e: React.MouseEvent) => { - e.stopPropagation(); - - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*,audio/*,image/*"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (file) { - await replaceElementWithFile(track.id, element.id, file); - } - }; - input.click(); - }; - - const handleRevealInMedia = (e: React.MouseEvent) => { - e.stopPropagation(); - revealElementInMedia(element.id); - }; - - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-white truncate">{element.content}</span> - </div> - ); - } - - const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } - - if ( - mediaItem.type === "image" || - (mediaItem.type === "video" && mediaItem.thumbnailUrl) - ) { - const trackHeight = getTrackHeight(track.type); - const tileWidth = trackHeight * (16 / 9); - - const imageUrl = - mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; - - return ( - <div className="w-full h-full flex items-center justify-center"> - <div - className={`w-full h-full relative ${ - isSelected ? "bg-primary" : "bg-transparent" - }`} - > - <div - className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`} - style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} - /> - </div> - </div> - ); - } - - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } - - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - }; - - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; - - const isMuted = element.type === "media" && element.muted; - - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - 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 rounded-[0.5rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - } ${element.hidden ? "opacity-50" : ""}`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> - - {(hasAudio ? isMuted : element.hidden) && ( - <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> - {hasAudio ? ( - <VolumeX className="h-6 w-6 text-white" /> - ) : ( - <EyeOff className="h-6 w-6 text-white" /> - )} - </div> - )} - - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-[0.6rem] cursor-w-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - <div - className="absolute right-0 top-0 bottom-0 w-[0.6rem] cursor-e-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200"> - {(!isMultipleSelected || - (isMultipleSelected && - isCurrentElementSelected && - canSplitSelected)) && ( - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Split ${selectedElements.length} elements at playhead` - : "Split at playhead"} - </ContextMenuItem> - )} - - <ContextMenuItem onClick={handleElementCopyContext}> - <Copy className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Copy ${selectedElements.length} elements` - : "Copy element"} - </ContextMenuItem> - - <ContextMenuItem onClick={handleToggleElementContext}> - {isMultipleSelected && isCurrentElementSelected ? ( - hasAudioElements ? ( - <VolumeX className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - ) - ) : hasAudio ? ( - isMuted ? ( - <Volume2 className="h-4 w-4 mr-2" /> - ) : ( - <VolumeX className="h-4 w-4 mr-2" /> - ) - ) : element.hidden ? ( - <Eye className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - )} - <span> - {isMultipleSelected && isCurrentElementSelected - ? hasAudioElements - ? `Toggle mute ${selectedElements.length} elements` - : `Toggle visibility ${selectedElements.length} elements` - : hasAudio - ? isMuted - ? "Unmute" - : "Mute" - : element.hidden - ? "Show" - : "Hide"}{" "} - {!isMultipleSelected && (element.type === "text" ? "text" : "clip")} - </span> - </ContextMenuItem> - - {!isMultipleSelected && ( - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - )} - - {!isMultipleSelected && element.type === "media" && ( - <> - <ContextMenuItem onClick={handleRevealInMedia}> - <Search className="h-4 w-4 mr-2" /> - Reveal in media - </ContextMenuItem> - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </ContextMenuItem> - </> - )} - - <ContextMenuSeparator /> - - <ContextMenuItem - onClick={handleElementDeleteContext} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedElements.length} elements` - : `Delete ${element.type === "text" ? "text" : "clip"}`} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + const { requestRevealMedia } = useAssetsPanelStore(); + + const mediaAssets = editor.media.getAssets(); + let mediaAsset: MediaAsset | null = null; + + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } + + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); + + const { handleResizeStart, isResizing, currentStartTime, currentDuration } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }); + + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); + + const isBeingDragged = dragState.elementId === element.id; + const dragOffsetY = + isBeingDragged && dragState.isDragging + ? dragState.currentMouseY - dragState.startMouseY + : 0; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + const displayedStartTime = isResizing ? currentStartTime : elementStartTime; + const displayedDuration = isResizing ? currentDuration : element.duration; + const elementWidth = + displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const elementLeft = displayedStartTime * 50 * zoomLevel; + + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; + + const isMuted = canElementHaveAudio(element) && element.muted === true; + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none ${ + isBeingDragged ? "z-30" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + <ElementInner + element={element} + track={track} + isSelected={isSelected} + isBeingDragged={isBeingDragged} + hasAudio={hasAudio} + isMuted={isMuted} + mediaAssets={mediaAssets} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + handleResizeStart={handleResizeStart} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-64"> + <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> + Split + </ActionMenuItem> + <CopyMenuItem /> + {canElementHaveAudio(element) && hasAudio && ( + <MuteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canElementBeHidden(element) && ( + <VisibilityMenuItem + element={element} + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {selectedElements.length === 1 && ( + <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + Duplicate + </ActionMenuItem> + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + <ContextMenuItem + icon={<HugeiconsIcon icon={Search01Icon} />} + onClick={(event) => handleRevealInMedia({ event })} + > + Reveal media + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={Exchange01Icon} />} + disabled + > + Replace media + </ContextMenuItem> + </> + )} + <ContextMenuSeparator /> + <DeleteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + </ContextMenuContent> + </ContextMenu> + ); +} + +function ElementInner({ + element, + track, + isSelected, + isBeingDragged, + hasAudio, + isMuted, + mediaAssets, + onElementClick, + onElementMouseDown, + handleResizeStart, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + isBeingDragged: boolean; + hasAudio: boolean; + isMuted: boolean; + mediaAssets: MediaAsset[]; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + return ( + <div + className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( + { + type: track.type, + }, + )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + > + <button + type="button" + className="absolute inset-0 size-full cursor-pointer" + onClick={(e) => onElementClick(e, element)} + onMouseDown={(e) => onElementMouseDown(e, element)} + > + <div className="absolute inset-0 flex h-full items-center"> + <ElementContent + element={element} + track={track} + isSelected={isSelected} + mediaAssets={mediaAssets} + /> + </div> + + {(hasAudio + ? isMuted + : canElementBeHidden(element) && element.hidden) && ( + <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> + {hasAudio ? ( + <HugeiconsIcon + icon={VolumeHighIcon} + className="size-6 text-white" + /> + ) : ( + <HugeiconsIcon + icon={VolumeOffIcon} + className="size-6 text-white" + /> + )} + </div> + )} + </button> + + {isSelected && ( + <> + <ResizeHandle + side="left" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + <ResizeHandle + side="right" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + </> + )} + </div> + ); +} + +function ResizeHandle({ + side, + elementId, + handleResizeStart, +}: { + side: "left" | "right"; + elementId: string; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + const isLeft = side === "left"; + return ( + <button + type="button" + className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + aria-label={`${isLeft ? "Left" : "Right"} resize handle`} + > + <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> + </button> + ); +} + +function ElementContent({ + element, + track, + isSelected, + mediaAssets, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + mediaAssets: MediaAsset[]; +}) { + if (element.type === "text") { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); + } + + if (element.type === "sticker") { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} + alt={element.name} + className="size-5 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); + } + + if (element.type === "audio") { + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + + const audioUrl = + element.sourceType === "library" + ? element.sourceUrl + : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + + if (audioBuffer || audioUrl) { + return ( + <div className="flex size-full items-center gap-2"> + <div className="min-w-0 flex-1"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + height={24} + className="w-full" + /> + </div> + </div> + ); + } + + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + if ( + mediaAsset.type === "image" || + (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) + ) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + const imageUrl = + mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; + + return ( + <div className="flex size-full items-center justify-center"> + <div + className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} + > + <div + className="absolute right-0 left-0" + style={{ + backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + top: isSelected ? "0.25rem" : "0rem", + bottom: isSelected ? "0.25rem" : "0rem", + }} + /> + </div> + </div> + ); + } + + return ( + <span className="text-foreground/80 truncate text-xs">{element.name}</span> + ); +} + +function CopyMenuItem() { + return ( + <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + Copy + </ActionMenuItem> + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={VolumeMute02Icon} />; + } + return isMuted ? ( + <HugeiconsIcon icon={VolumeHighIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeOffIcon} /> + ); + }; + + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; + + return ( + <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + <ActionMenuItem + action="delete-selected" + variant="destructive" + icon={<HugeiconsIcon icon={Delete02Icon} />} + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + </ActionMenuItem> + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { + action: TAction; +}) { + return ( + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut(action)} + {...props} + > + {children} + </ContextMenuItem> + ); } diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx deleted file mode 100644 index 1351a457..00000000 --- a/apps/web/src/components/editor/timeline/timeline-marker.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; - -interface TimelineMarkerProps { - time: number; - zoomLevel: number; - interval: number; - isMainMarker: boolean; -} - -export function TimelineMarker({ - time, - zoomLevel, - interval, - isMainMarker, -}: TimelineMarkerProps) { - return ( - <div - className={cn( - "absolute top-0 h-4", - isMainMarker - ? "border-l border-muted-foreground/40" - : "border-l border-muted-foreground/20" - )} - style={{ - left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - > - <span - className={cn( - "absolute top-1 left-1 text-[0.6rem]", - isMainMarker - ? "text-muted-foreground font-medium" - : "text-muted-foreground/70" - )} - > - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (minutes > 0) { - return `${minutes}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (interval >= 1) { - return `${Math.floor(secs)}s`; - } - return `${secs.toFixed(1)}s`; - }; - return formatTime(time); - })()} - </span> - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index a1be5b26..58d0adb0 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,172 +1,92 @@ "use client"; -import { useRef, useState, useEffect } from "react"; -import { TimelineTrack } from "@/types/timeline"; +import { useRef } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { useEditor } from "@/hooks/use-editor"; interface TimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; - timelineRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; - isSnappingToPlayhead?: boolean; + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + timelineRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; + isSnappingToPlayhead?: boolean; } export function TimelinePlayhead({ - currentTime, - duration, - zoomLevel, - tracks, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - trackLabelsRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, }: TimelinePlayheadProps) { - const internalPlayheadRef = useRef<HTMLDivElement>(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - const [scrollLeft, setScrollLeft] = useState(0); - - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Track scroll position to lock playhead to frame - useEffect(() => { - const tracksViewport = tracksScrollRef.current; - - if (!tracksViewport) return; - - const handleScroll = () => { - setScrollLeft(tracksViewport.scrollLeft); - }; - - // Set initial scroll position - setScrollLeft(tracksViewport.scrollLeft); - - tracksViewport.addEventListener("scroll", handleScroll); - return () => tracksViewport.removeEventListener("scroll", handleScroll); - }, [tracksScrollRef]); - - // Use timeline container height minus a few pixels for breathing room - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 4; - - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - - // Calculate position locked to timeline content (accounting for scroll) - const timelinePosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft; - - // Get the timeline content width and viewport width for right boundary - const timelineContentWidth = - duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const tracksViewport = tracksScrollRef.current; - const viewportWidth = tracksViewport?.clientWidth || 1000; - - // Constrain playhead to never appear outside the timeline area - const leftBoundary = trackLabelsWidth; - const rightBoundary = Math.min( - trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content - trackLabelsWidth + viewportWidth // Don't go beyond viewport - ); - - const leftPosition = Math.max( - leftBoundary, - Math.min(rightBoundary, rawLeftPosition) - ); - - // Debug logging when playhead might go outside - if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) { - console.log( - "PLAYHEAD VISUAL DEBUG:", - JSON.stringify({ - playheadPosition, - timelinePosition, - trackLabelsWidth, - scrollLeft, - rawLeftPosition, - constrainedLeftPosition: leftPosition, - leftBoundary, - rightBoundary, - timelineContentWidth, - viewportWidth, - zoomLevel, - }) - ); - } - - return ( - <div - ref={playheadRef} - className="absolute pointer-events-auto z-40" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", // Slightly wider for better click target - }} - onMouseDown={handlePlayheadMouseDown} - > - {/* The playhead line spanning full height */} - <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`} - /> - - {/* Playhead dot indicator at the top (in ruler area) */} - <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} - /> - </div> - ); + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const timelineContainerHeight = + tracksScrollRef.current?.clientHeight ?? + timelineRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max(0, timelineContainerHeight - 4); + + const timelinePosition = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelinePosition; + + const handlePlayheadKeyDown = ( + event: React.KeyboardEvent<HTMLDivElement>, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + + event.preventDefault(); + const step = 1 / Math.max(1, editor.project.getActive().settings.fps); + const direction = event.key === "ArrowRight" ? 1 : -1; + const nextTime = Math.max( + 0, + Math.min(duration, playheadPosition + direction * step), + ); + + editor.playback.seek({ time: nextTime }); + }; + + return ( + <div + ref={playheadRef} + role="slider" + aria-label="Timeline playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={playheadPosition} + tabIndex={0} + className="pointer-events-auto absolute z-60" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + onMouseDown={handlePlayheadMouseDown} + onKeyDown={handlePlayheadKeyDown} + > + <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> + + <div + className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + /> + </div> + ); } - -// Also export a hook for getting ruler handlers -export function useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - return { handleRulerMouseDown, isDraggingRuler }; -} - -export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx new file mode 100644 index 00000000..87702a4f --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -0,0 +1,83 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { DEFAULT_FPS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; +import { TimelineTick } from "./timeline-tick"; + +interface TimelineRulerProps { + zoomLevel: number; + dynamicTimelineWidth: number; + rulerRef: React.RefObject<HTMLDivElement>; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineRuler({ + zoomLevel, + dynamicTimelineWidth, + rulerRef, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineRulerProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; + const effectiveDuration = Math.max(duration, visibleDuration); + const project = editor.project.getActive(); + const fps = project?.settings.fps ?? DEFAULT_FPS; + const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ + zoomLevel, + fps, + }); + const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + + const timelineTicks: Array<JSX.Element> = []; + for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + const time = tickIndex * tickIntervalSeconds; + if (time > effectiveDuration) break; + + const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); + timelineTicks.push( + <TimelineTick + key={tickIndex} + time={time} + zoomLevel={zoomLevel} + fps={fps} + showLabel={showLabel} + />, + ); + } + + return ( + <div + role="slider" + tabIndex={0} + aria-label="Timeline ruler" + aria-valuemin={0} + aria-valuemax={effectiveDuration} + aria-valuenow={0} + className="relative h-4 flex-1 overflow-x-visible" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={handleRulerTrackingMouseDown} + onKeyDown={() => {}} + > + <div + role="none" + ref={rulerRef} + className="relative h-4 cursor-default select-none" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {timelineTicks} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/timeline/timeline-tick.tsx new file mode 100644 index 00000000..9402d25e --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-tick.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; + +interface TimelineTickProps { + time: number; + zoomLevel: number; + fps: number; + showLabel: boolean; +} + +export function TimelineTick({ + time, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds: time, fps }); + return ( + <span + className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none" + style={{ left: `${leftPosition}px` }} + > + {label} + </span> + ); + } + + return ( + <div + className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + style={{ left: `${leftPosition}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 80c6ce08..822bb438 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,397 +1,355 @@ -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 { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; +import { SkipBack, SplitSquareHorizontal } from "lucide-react"; import { - Pause, - Play, - SkipBack, - Bookmark, - Magnet, - Link, - ZoomOut, - ZoomIn, - Copy, - Trash2, - Snowflake, - ArrowLeftToLine, - ArrowRightToLine, - SplitSquareHorizontal, - Scissors, - LayersIcon, -} from "lucide-react"; -import { - SplitButton, - SplitButtonLeft, - SplitButtonRight, - SplitButtonSeparator, + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { DEFAULT_FPS } from "@/stores/project-store"; import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { EditableTimecode } from "@/components/editable-timecode"; import { ScenesView } from "../scenes-view"; +import { type TAction, invokeAction } from "@/lib/actions"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Bookmark02Icon, + Delete02Icon, + SnowIcon, + ScissorIcon, + MagnetIcon, + Link04Icon, + SearchAddIcon, + SearchMinusIcon, + PauseIcon, + PlayIcon, + Copy01Icon, + AlignLeftIcon, + AlignRightIcon, + Layers01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; export function TimelineToolbar({ - zoomLevel, - setZoomLevel, + zoomLevel, + minZoom, + setZoomLevel, }: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; + zoomLevel: number; + minZoom: number; + setZoomLevel: ({ zoom }: { zoom: number }) => void; }) { - const { - tracks, - addTrack, - addElementToTrack, - selectedElements, - clearSelectedElements, - deleteSelected, - splitSelected, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - const { scenes, currentScene } = useSceneStore(); + const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const newZoomLevel = + direction === "in" + ? Math.min( + TIMELINE_CONSTANTS.ZOOM_MAX, + zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP, + ) + : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP); + setZoomLevel({ zoom: newZoomLevel }); + }; + + return ( + <ScrollArea className="scrollbar-hidden"> + <div className="flex h-10 items-center justify-between border-b px-2 py-1"> + <ToolbarLeftSection /> + + <SceneSelector /> + + <ToolbarRightSection + zoomLevel={zoomLevel} + minZoom={minZoom} + onZoomChange={(zoom) => setZoomLevel({ zoom })} + onZoom={handleZoom} + /> + </div> + </ScrollArea> + ); +} + +function ToolbarLeftSection() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const isPlaying = editor.playback.getIsPlaying(); + const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + + const handleAction = ({ + action, + event, + }: { + action: TAction; + event: React.MouseEvent; + }) => { + event.stopPropagation(); + invokeAction(action); + }; + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + isPlaying ? ( + <HugeiconsIcon icon={PauseIcon} /> + ) : ( + <HugeiconsIcon icon={PlayIcon} /> + ) + } + tooltip={isPlaying ? "Pause" : "Play"} + onClick={({ event }) => + handleAction({ action: "toggle-play", event }) + } + /> + + <ToolbarButton + icon={<SkipBack />} + tooltip="Go to start" + onClick={({ event }) => handleAction({ action: "goto-start", event })} + /> + + <div className="bg-border mx-1 h-6 w-px" /> + + <TimeDisplay /> - const handleSplitSelected = () => { - splitSelected(currentTime); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; + <ToolbarButton + icon={<HugeiconsIcon icon={ScissorIcon} />} + tooltip="Split element" + onClick={({ event }) => + handleAction({ action: "split", event }) + } + /> - 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(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignLeftIcon} />} + tooltip="Split left" + onClick={({ event }) => + handleAction({ action: "split-left", event }) + } + /> - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignRightIcon} />} + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> - 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); - }; + <ToolbarButton + icon={<SplitSquareHorizontal />} + tooltip="Coming soon" /* separate audio */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - 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); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Copy01Icon} />} + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={SnowIcon} />} + tooltip="Coming soon" /* freeze frame */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - const handleDeleteSelected = () => { - deleteSelected(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Delete02Icon} />} + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; + <Tooltip> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Bookmark02Icon} + className={currentBookmarked ? "fill-primary text-primary" : ""} + /> + } + tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + </Tooltip> + </TooltipProvider> + </div> + ); +} + +function TimeDisplay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( + <div className="flex flex-row items-center justify-center px-2"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + 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: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </div> + </div> + ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( + <div> + <SplitButton className="border-foreground/10 border"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight onClick={() => {}} type="button"> + <HugeiconsIcon icon={Layers01Icon} className="size-4" /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </div> + ); +} - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; +function ToolbarRightSection({ + zoomLevel, + minZoom, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + minZoom: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + const { + snappingEnabled, + rippleEditingEnabled, + toggleSnapping, + toggleRippleEditing, + } = useTimelineStore(); - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={MagnetIcon} + className={cn(snappingEnabled ? "text-primary" : "")} + /> + } + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> - const currentBookmarked = isBookmarked(currentTime); - return ( - <div className="flex items-center justify-between px-2 py-1 border-b h-10"> - <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="w-px h-6 bg-border mx-1" /> - {/* 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-xs text-muted-foreground font-mono px-2"> - / - </div> - <div className="text-xs text-muted-foreground font-mono text-center"> - {formatTimeCode(duration, "HH:MM:SS:FF")} - </div> - </div> - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <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="w-px h-6 bg-border mx-1" /> - <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" onClick={handleSeparateAudio}> - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</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={handleDeleteSelected}> - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleToggleBookmark}> - <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 border-foreground/10"> - <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="h-4 w-4 text-primary" /> - ) : ( - <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> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Link04Icon} + className={cn( + rippleEditingEnabled ? "text-primary" : "", + "scale-110", + )} + /> + } + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + </TooltipProvider> - <div className="h-6 w-px bg-border mx-1" /> - <div className="flex items-center gap-1"> - <Button variant="text" size="icon" onClick={handleZoomOut}> - <ZoomOut className="h-4 w-4" /> - </Button> - <Slider - className="w-24" - value={[zoomLevel]} - onValueChange={handleZoomSliderChange} - min={0.25} - max={4} - step={0.25} - /> - <Button variant="text" size="icon" onClick={handleZoomIn}> - <ZoomIn className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ); + <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" })} + > + <HugeiconsIcon icon={SearchAddIcon} /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={(values) => onZoomChange(values[0])} + min={minZoom} + max={TIMELINE_CONSTANTS.ZOOM_MAX} + step={TIMELINE_CONSTANTS.ZOOM_STEP} + /> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "in" })} + > + <HugeiconsIcon icon={SearchMinusIcon} /> + </Button> + </div> + </div> + ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, +}: { + icon: React.ReactNode; + tooltip: string; + onClick: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; +}) { + return ( + <Tooltip delayDuration={200}> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + type="button" + onClick={(event) => onClick({ event })} + className={disabled ? "cursor-not-allowed opacity-50" : ""} + > + {icon} + </Button> + </TooltipTrigger> + <TooltipContent>{tooltip}</TooltipContent> + </Tooltip> + ); } diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 7cfde931..29d58315 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1,1199 +1,113 @@ "use client"; -import { useRef, useState, useEffect } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { toast } from "sonner"; -import { processMediaFiles } from "@/lib/media-processing"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { TimelineElement } from "./timeline-element"; -import { - TimelineTrack, - getMainTrack, - canElementGoOnTrack, -} from "@/types/timeline"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import type { - TimelineElement as TimelineElementType, - DragData, - TrackType, -} from "@/types/timeline"; -import { - snapTimeToFrame, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; -import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; -import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll"; +import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import type { ElementDragState } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; +import { cn } from "@/utils/ui"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + lastMouseXRef: React.RefObject<number>; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onTrackMouseDown?: (event: React.MouseEvent) => void; + shouldIgnoreClick?: () => boolean; +} export function TimelineTrackContent({ - track, - zoomLevel, - onSnapPointChange, - rulerScrollRef, - tracksScrollRef, -}: { - track: TimelineTrack; - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; -}) { - const { mediaFiles } = useMediaStore(); - const { - tracks, - addTrack, - moveElementToTrack, - updateElementStartTime, - updateElementStartTimeWithRipple, - addElementToTrack, - selectedElements, - selectElement, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - clearSelectedElements, - insertTrackAt, - snappingEnabled, - rippleEditingEnabled, - } = useTimelineStore(); - - const { currentTime, duration } = usePlaybackStore(); - - // Initialize snapping hook - const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ - snapThreshold: 10, - enableElementSnapping: snappingEnabled, - enablePlayheadSnapping: snappingEnabled, - }); - - // Helper function for drop snapping that tries both edges - const getDropSnappedTime = ( - dropTime: number, - elementDuration: number, - excludeElementId?: string - ) => { - // Always apply frame snapping first - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - let finalTime = snapTimeToFrame(dropTime, projectFps); - - // Additionally apply element snapping if enabled - if (snappingEnabled) { - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - // Only use element snapping if it found a snap point, otherwise keep frame-snapped time - if (bestSnapResult.snapPoint) { - finalTime = bestSnapResult.snappedTime; - } - } - - return finalTime; - }; - - const timelineRef = useRef<HTMLDivElement>(null); - const [isDropping, setIsDropping] = useState(false); - const [dropPosition, setDropPosition] = useState<number | null>(null); - const [wouldOverlap, setWouldOverlap] = useState(false); - const dragCounterRef = useRef(0); - 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); - - // Always apply frame snapping first - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - let finalTime = snapTimeToFrame(adjustedTime, projectFps); - let snapPoint = null; - - // Additionally apply element snapping if enabled - if (snappingEnabled) { - // Find the element being dragged to get its duration - let elementDuration = 5; // fallback duration - if (dragState.elementId && dragState.trackId) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const element = sourceTrack?.elements.find( - (e) => e.id === dragState.elementId - ); - if (element) { - elementDuration = - element.duration - element.trimStart - element.trimEnd; - } - } - - // Try snapping both start and end edges - const startSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - // Only use element snapping if it found a snap point, otherwise keep frame-snapped time - if (bestSnapResult.snapPoint) { - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; - } - - // Notify parent component about snap point change - onSnapPointChange?.(snapPoint); - } else { - // Clear snap point when element snapping is disabled - onSnapPointChange?.(null); - } - - 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 { - moveElementToTrack( - dragState.trackId, - track.id, - dragState.elementId - ); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId!, - finalTime - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId!, - finalTime - ); - } - }); - } - } - } - } 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, - moveElementToTrack, - endDragAction, - selectedElements, - selectElement, - onSnapPointChange, - ]); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current, - 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) - }; - - const handleTrackDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Handle both timeline elements and media items - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - let dropTime = 0; - if (trackContainer) { - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - } - - // Check for potential overlaps and show appropriate feedback - let wouldOverlap = false; - - if (hasMediaItem) { - try { - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (mediaItemData) { - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - // Text elements have default duration of 5 seconds - const newElementDuration = 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return snappedTime < existingEnd && newElementEnd > existingStart; - }); - } else { - // Media elements - const mediaItem = mediaFiles.find( - (item) => item.id === dragData.id - ); - if (mediaItem) { - const newElementDuration = mediaItem.duration || 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && newElementEnd > existingStart - ); - }); - } - } - } - } catch (error) { - // Continue with default behavior - } - } else if (hasTimelineElement) { - try { - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (timelineElementData) { - const { elementId, trackId: fromTrackId } = - JSON.parse(timelineElementData); - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: any) => c.id === elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const snappedTime = getDropSnappedTime( - dropTime, - movingElementDuration, - elementId - ); - const movingElementEnd = snappedTime + movingElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && movingElementEnd > existingStart - ); - }); - } - } - } catch (error) { - // Continue with default behavior - } - } - - if (wouldOverlap) { - e.dataTransfer.dropEffect = "none"; - setWouldOverlap(true); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - return; - } - - e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; - setWouldOverlap(false); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - }; - - const handleTrackDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current++; - setIsDropping(true); - }; - - const handleTrackDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current--; - - if (dragCounterRef.current === 0) { - setIsDropping(false); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Debug logging - console.log( - JSON.stringify({ - message: "Drop event started in timeline track", - dataTransferTypes: Array.from(e.dataTransfer.types), - trackId: track.id, - trackType: track.type, - }) - ); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(false); - setWouldOverlap(false); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasTimelineElement && !hasMediaItem && !hasFiles) return; - - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - if (!trackContainer) return; - - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const mouseY = e.clientY - rect.top; // Get Y position relative to this track - const newStartTime = - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - const snappedTime = snapTimeToFrame(newStartTime, projectFps); - - // Calculate drop position relative to tracks - const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); - - // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) - let dropPosition: "above" | "on" | "below"; - if (mouseY < 20) { - dropPosition = "above"; - } else if (mouseY > 40) { - dropPosition = "below"; - } else { - dropPosition = "on"; - } - - try { - if (hasTimelineElement) { - // Handle timeline element movement - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (!timelineElementData) return; - - const { - elementId, - trackId: fromTrackId, - clickOffsetTime = 0, - } = JSON.parse(timelineElementData); - - // Find the element being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: TimelineElementType) => c.id === elementId - ); - - if (!movingElement) { - toast.error("Element not found"); - return; - } - - // Check for overlaps with existing elements (excluding the moving element itself) - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - - // Adjust position based on where user clicked on the element - const adjustedStartTime = newStartTime - clickOffsetTime; - const snappedStartTime = getDropSnappedTime( - adjustedStartTime, - movingElementDuration, - elementId - ); - const finalStartTime = Math.max(0, snappedStartTime); - const movingElementEnd = finalStartTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - // Skip the element being moved if it's on the same track - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - finalStartTime < existingEnd && movingElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot move element here - it would overlap with existing elements" - ); - return; - } - - if (fromTrackId === track.id) { - // Moving within same track - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - } else { - // Moving to different track - moveElementToTrack(fromTrackId, track.id, elementId); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - }); - } - } else if (hasMediaItem) { - // Handle media item drop - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (!mediaItemData) return; - - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - let targetTrackId = track.id; - let targetTrack = track; - - // Handle position-aware track creation for text - if (track.type !== "text" || dropPosition !== "on") { - // Text tracks should go above the main track - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // dropPosition === "on" but track is not text type - // Insert above main track if main track exists, otherwise at top - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } else { - insertIndex = 0; // Top of timeline - } - } - - targetTrackId = insertTrackAt("text", insertIndex); - // Get the updated tracks array after creating the new track - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - - // Check for overlaps with existing elements in target track - const newElementDuration = 5; // Default text duration - const textSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = textSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - textSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - ...DEFAULT_TEXT_ELEMENT, - name: dragData.name || DEFAULT_TEXT_ELEMENT.name, - content: dragData.content || DEFAULT_TEXT_ELEMENT.content, - startTime: textSnappedTime, - }); - } else { - // Handle media items - const mediaItem = mediaFiles.find((item) => item.id === dragData.id); - - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - let targetTrackId = track.id; - - // Check if track type is compatible - const isVideoOrImage = - dragData.type === "video" || dragData.type === "image"; - const isAudio = dragData.type === "audio"; - const isCompatible = isVideoOrImage - ? canElementGoOnTrack("media", track.type) - : isAudio - ? canElementGoOnTrack("media", track.type) - : false; - - let targetTrack = tracks.find((t) => t.id === targetTrackId); - - // Handle position-aware track creation for media elements - if (!isCompatible || dropPosition !== "on") { - if (isVideoOrImage) { - // For video/image, check if we need a main track or additional media track - const mainTrack = getMainTrack(tracks); - - if (!mainTrack) { - // No main track exists, create it - targetTrackId = addTrack("media"); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } else if ( - mainTrack.elements.length === 0 && - dropPosition === "on" - ) { - // Main track exists and is empty, use it - targetTrackId = mainTrack.id; - targetTrack = mainTrack; - } else { - // Create new media track - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert above main track - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } - - targetTrackId = insertTrackAt("media", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } else if (isAudio) { - // Audio tracks go at the bottom - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert after main track (bottom area) - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex + 1; - } else { - insertIndex = tracks.length; // Bottom of timeline - } - } - - targetTrackId = insertTrackAt("audio", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } - - if (!targetTrack) return; - - // Check for overlaps with existing elements in target track - const newElementDuration = mediaItem.duration || 5; - const mediaSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = mediaSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - mediaSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: mediaSnappedTime, - trimStart: 0, - trimEnd: 0, - }); - } - } else if (hasFiles) { - // External file drops - const { activeProject } = useProjectStore.getState(); - const { addMediaFile } = useMediaStore.getState(); - const { addElementToTrack } = useTimelineStore.getState(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - // Process and add files to new timeline tracks at playhead position - processMediaFiles(e.dataTransfer.files) - .then(async (processedItems) => { - for (const processedItem of processedItems) { - await addMediaFile(activeProject.id, processedItem); - const currentMediaFiles = mediaFiles; - const addedItem = currentMediaFiles.find( - (item) => - item.name === processedItem.name && - item.url === processedItem.url - ); - - if (addedItem) { - const trackType: TrackType = - addedItem.type === "audio" ? "audio" : "media"; - const targetTrackId = insertTrackAt(trackType, 0); - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - }) - .catch((error) => { - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - }); - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; - - return ( - <div - className="w-full h-full hover:bg-muted/20" - onClick={(e) => { - // If clicking empty area (not on an element), deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - > - <div - ref={timelineRef} - className="h-full relative track-elements-container min-w-full" - > - {track.elements.length === 0 ? ( - <div - className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ - isDropping - ? wouldOverlap - ? "border-red-500 bg-red-500/10 text-red-600" - : "border-blue-500 bg-blue-500/10 text-blue-600" - : "border-muted/30" - }`} - > - {isDropping - ? wouldOverlap - ? "Cannot drop - would overlap" - : "Drop element here" - : ""} - </div> - ) : ( - <> - {track.elements.map((element) => { - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - const handleElementSplit = () => { - const { currentTime } = usePlaybackStore(); - const { splitSelected } = useTimelineStore(); - const splitTime = currentTime; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - splitSelected(splitTime, track.id, element.id); - } else { - toast.error("Playhead must be within element to split"); - } - }; - - const handleElementDuplicate = () => { - const { addElementToTrack } = useTimelineStore.getState(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; - - const handleElementDelete = () => { - const { deleteSelected } = useTimelineStore.getState(); - deleteSelected(track.id, element.id); - }; - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - /> - ); - })} - </> - )} - </div> - </div> - ); + track, + zoomLevel, + dragState, + rulerScrollRef, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + shouldIgnoreClick, +}: TimelineTrackContentProps) { + const editor = useEditor(); + const { isElementSelected, clearElementSelection } = useElementSelection(); + + const duration = editor.timeline.getTotalDuration(); + + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current ?? 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); + + const hasSelectedElements = track.elements.some((element) => + isElementSelected({ trackId: track.id, elementId: element.id }), + ); + + return ( + <button + className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} + onClick={() => { + if (shouldIgnoreClick?.()) return; + clearElementSelection(); + }} + onMouseDown={(event) => { + onTrackMouseDown?.(event); + }} + type="button" + > + <div className="relative h-full min-w-full"> + {track.elements.length === 0 ? ( + <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 = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onSnapPointChange={onSnapPointChange} + onResizeStateChange={onResizeStateChange} + onElementMouseDown={(event, element) => + onElementMouseDown({ event, element, track }) + } + onElementClick={(event, element) => + onElementClick({ event, element, track }) + } + dragState={dragState} + /> + ); + }) + )} + </div> + </button> + ); } -- cgit v1.3.1