summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/dialogs/delete-project-dialog.tsx84
-rw-r--r--apps/web/src/components/editor/dialogs/migration-dialog.tsx44
-rw-r--r--apps/web/src/components/editor/dialogs/project-info-dialog.tsx81
-rw-r--r--apps/web/src/components/editor/dialogs/rename-project-dialog.tsx72
-rw-r--r--apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx229
-rw-r--r--apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx41
-rw-r--r--apps/web/src/components/editor/editor-header.tsx14
-rw-r--r--apps/web/src/components/editor/export-button.tsx8
-rw-r--r--apps/web/src/components/editor/onboarding.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/assets-panel-store.tsx124
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx12
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx24
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx337
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx92
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx10
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx563
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx455
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx46
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx73
-rw-r--r--apps/web/src/components/editor/panels/preview/cursors.ts21
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-overlay.tsx32
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-popover.tsx100
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx427
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx294
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx308
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx103
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx576
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx47
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx158
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx148
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx145
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts8
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts8
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts10
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx20
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx151
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx230
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx309
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx238
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx826
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx156
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx767
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx462
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx275
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx345
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx427
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx29
-rw-r--r--apps/web/src/components/editor/panels/timeline/drop-target.ts262
-rw-r--r--apps/web/src/components/editor/panels/timeline/expanded-layout.ts117
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx235
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx334
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts704
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts178
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx944
-rw-r--r--apps/web/src/components/editor/panels/timeline/interaction.ts4
-rw-r--r--apps/web/src/components/editor/panels/timeline/layers.ts6
-rw-r--r--apps/web/src/components/editor/panels/timeline/layout.ts20
-rw-r--r--apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts135
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx50
-rw-r--r--apps/web/src/components/editor/panels/timeline/theme.ts31
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx1305
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx114
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx141
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx42
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx376
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx132
-rw-r--r--apps/web/src/components/editor/panels/timeline/track-layout.ts60
-rw-r--r--apps/web/src/components/editor/scenes-view.tsx4
-rw-r--r--apps/web/src/components/footer.tsx4
-rw-r--r--apps/web/src/components/gitHub-contribute-section.tsx2
-rw-r--r--apps/web/src/components/header.tsx4
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx12
-rw-r--r--apps/web/src/components/storage-provider.tsx2
-rw-r--r--apps/web/src/components/ui/font-picker.tsx8
-rw-r--r--apps/web/src/components/ui/use-overlay-open-change.ts2
84 files changed, 214 insertions, 14150 deletions
diff --git a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx
deleted file mode 100644
index 0086384e..00000000
--- a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-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 (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- event.stopPropagation();
- }}
- >
- <DialogHeader>
- <DialogTitle>
- {singleName ? (
- <>
- {"Delete '"}
- <span className="inline-block max-w-[300px] truncate align-bottom">
- {singleName}
- </span>
- {"'?"}
- </>
- ) : (
- `Delete ${count} projects?`
- )}
- </DialogTitle>
- </DialogHeader>
- <DialogBody>
- <Alert variant="destructive">
- <AlertTitle>Warning</AlertTitle>
- <AlertDescription>
- This will permanently delete{" "}
- {singleName ? `"${singleName}"` : `${count} projects`} and all
- associated files.
- </AlertDescription>
- </Alert>
- <div className="flex flex-col gap-3">
- <Label className="text-xs font-semibold text-slate-500">
- Type "DELETE" to confirm
- </Label>
- <Input
- type="text"
- placeholder="DELETE"
- size="lg"
- variant="destructive"
- />
- </div>
- </DialogBody>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- Cancel
- </Button>
- <Button variant="destructive" onClick={onConfirm}>
- Delete project
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/editor/dialogs/migration-dialog.tsx b/apps/web/src/components/editor/dialogs/migration-dialog.tsx
deleted file mode 100644
index 4c9550c2..00000000
--- a/apps/web/src/components/editor/dialogs/migration-dialog.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"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 (
- <Dialog open={true}>
- <DialogContent
- className="sm:max-w-md"
- onPointerDownOutside={(event) => event.preventDefault()}
- onEscapeKeyDown={(event) => event.preventDefault()}
- >
- <DialogHeader>
- <DialogTitle>{title}</DialogTitle>
- <DialogDescription>{description}</DialogDescription>
- </DialogHeader>
-
- <div className="flex items-center justify-center py-4">
- <Loader2 className="text-muted-foreground size-8 animate-spin" />
- </div>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
deleted file mode 100644
index ed4eb990..00000000
--- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/lib/project/types";
-import { formatDate } from "@/utils/date";
-import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
-import { Button } from "@/components/ui/button";
-
-function InfoRow({
- label,
- value,
-}: {
- label: string;
- value: string | React.ReactNode;
-}) {
- return (
- <div className="flex justify-between items-center py-0 last:pb-0">
- <span className="text-muted-foreground text-sm">{label}</span>
- <span className="text-sm font-medium">{value}</span>
- </div>
- );
-}
-
-export function ProjectInfoDialog({
- isOpen,
- onOpenChange,
- project,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- project: TProjectMetadata;
-}) {
- const durationSeconds = mediaTimeToSeconds({ time: project.duration });
- const durationFormatted =
- project.duration > 0
- ? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
- : "0:00";
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
- <DialogHeader>
- <DialogTitle className="truncate max-w-[350px]">
- {project.name}
- </DialogTitle>
- </DialogHeader>
-
- <DialogBody className="flex flex-col">
- <InfoRow label="Duration" value={durationFormatted} />
- <InfoRow
- label="Created"
- value={formatDate({ date: project.createdAt })}
- />
- <InfoRow
- label="Modified"
- value={formatDate({ date: project.updatedAt })}
- />
- <InfoRow
- label="Project ID"
- value={
- <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
- {project.id.slice(0, 8)}
- </code>
- }
- />
- </DialogBody>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- Close
- </Button>
- <Button onClick={() => onOpenChange(false)}>Done</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx
deleted file mode 100644
index 73024d71..00000000
--- a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-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 (
- <Dialog open={isOpen} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Rename project</DialogTitle>
- </DialogHeader>
-
- <DialogBody className="gap-3">
- <Label>New name</Label>
- <Input
- value={name}
- onChange={(e) => setName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- onConfirm(name);
- }
- }}
- placeholder="Enter a new name"
- />
- </DialogBody>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onOpenChange(false);
- }}
- >
- Cancel
- </Button>
- <Button onClick={() => onConfirm(name)}>Rename</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
deleted file mode 100644
index 70802b5b..00000000
--- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-"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<KeyboardShortcut | null>(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 (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
- <DialogHeader>
- <DialogTitle>Keyboard shortcuts</DialogTitle>
- </DialogHeader>
-
- <DialogBody className="scrollbar-thin grow overflow-y-auto">
- <div className="flex flex-col gap-6">
- {categories.map((category) => (
- <div key={category} className="flex flex-col gap-1">
- <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
- {category}
- </h3>
- <div className="flex flex-col gap-1">
- {shortcuts
- .filter((shortcut) => shortcut.category === category)
- .map((shortcut) => (
- <ShortcutItem
- key={shortcut.action}
- shortcut={shortcut}
- isRecording={
- shortcut.action === recordingShortcut?.action
- }
- onStartRecording={() => handleStartRecording(shortcut)}
- />
- ))}
- </div>
- </div>
- ))}
- </div>
- </DialogBody>
- <DialogFooter>
- <Button variant="destructive" onClick={resetToDefaults}>
- Reset to default
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
-
-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 (
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-3">
- {shortcut.icon && (
- <div className="text-muted-foreground">{shortcut.icon}</div>
- )}
- <span className="text-sm">{shortcut.description}</span>
- </div>
- <div className="flex items-center gap-2">
- {displayKeys.map((key: string, index: number) => (
- <div key={key} className="flex items-center gap-2">
- <div className="flex items-center gap-1">
- {key.split("+").map((keyPart: string, partIndex: number) => {
- const keyId = `${shortcut.id}-${index}-${partIndex}`;
- return (
- <EditableShortcutKey
- key={keyId}
- isRecording={isRecording}
- onStartRecording={() => onStartRecording({ shortcut })}
- >
- {keyPart}
- </EditableShortcutKey>
- );
- })}
- </div>
- {index < displayKeys.length - 1 && (
- <span className="text-muted-foreground text-xs">or</span>
- )}
- </div>
- ))}
- </div>
- </div>
- );
-}
-
-function EditableShortcutKey({
- children,
- isRecording,
- onStartRecording,
-}: {
- children: React.ReactNode;
- isRecording: boolean;
- onStartRecording: () => void;
-}) {
- const handleClick = (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- onStartRecording();
- };
-
- return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleClick}
- title={
- isRecording ? "Press any key combination..." : "Click to edit shortcut"
- }
- >
- {children}
- </Button>
- );
-}
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
deleted file mode 100644
index 0e854a93..00000000
--- a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { useStoragePersistence } from "@/hooks/use-storage-persistence";
-
-export function StoragePersistenceDialog() {
- const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
-
- return (
- <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}>
- <DialogContent className="sm:max-w-md">
- <DialogHeader>
- <DialogTitle>Don't lose your projects</DialogTitle>
- </DialogHeader>
- <DialogBody>
- <p className="text-base text-muted-foreground">
- Your browser can automatically delete your projects when storage
- runs low.
- </p>
- <p className="text-base text-muted-foreground">
- Allow OpenCut to protect them?
- </p>
- </DialogBody>
- <DialogFooter>
- <Button variant="outline" onClick={onDismiss}>
- Not now
- </Button>
- <Button onClick={onConfirm}>Allow</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 44e8af68..6404dc01 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -10,20 +10,20 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import Link from "next/link";
-import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
-import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
+import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
+import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
-import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
+import { FeedbackPopover } from "@/feedback/components/feedback-popover";
import { ThemeToggle } from "../theme-toggle";
-import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
-import { SOCIAL_LINKS } from "@/lib/site/social";
+import { DEFAULT_LOGO_URL } from "@/site/brand";
+import { SOCIAL_LINKS } from "@/site/social";
import { toast } from "sonner";
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
+import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog";
import Image from "next/image";
import { cn } from "@/utils/ui";
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index e66067da..e738fb68 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -18,22 +18,22 @@ import {
getExportMimeType,
getExportFileExtension,
downloadBuffer,
-} from "@/lib/export";
+} from "@/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
-} from "@/lib/export";
+} from "@/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults";
+import { useEditor } from "@/editor/use-editor";
+import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
index 54196c76..bc5533ce 100644
--- a/apps/web/src/components/editor/onboarding.tsx
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -3,8 +3,8 @@
import { ArrowRightIcon } from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
-import { SOCIAL_LINKS } from "@/lib/site/social";
-import { useLocalStorage } from "@/hooks/storage/use-local-storage";
+import { SOCIAL_LINKS } from "@/site/social";
+import { useLocalStorage } from "@/services/storage/use-local-storage";
import { Button } from "../ui/button";
import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog";
diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
new file mode 100644
index 00000000..1539d305
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
@@ -0,0 +1,124 @@
+import type { ElementType } from "react";
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import {
+ ArrowRightDoubleIcon,
+ ClosedCaptionIcon,
+ Folder03Icon,
+ Happy01Icon,
+ HeadphonesIcon,
+ MagicWand05Icon,
+ TextIcon,
+ Settings01Icon,
+ SlidersHorizontalIcon,
+ ColorsIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+
+export const TAB_KEYS = [
+ "media",
+ "sounds",
+ "text",
+ "stickers",
+ "effects",
+ "transitions",
+ "captions",
+ "adjustment",
+ "settings",
+] as const;
+
+export type Tab = (typeof TAB_KEYS)[number];
+
+const createHugeiconsIcon =
+ ({ icon }: { icon: IconSvgElement }) =>
+ ({ className }: { className?: string }) => (
+ <HugeiconsIcon icon={icon} className={className} />
+ );
+
+export const tabs = {
+ media: {
+ icon: createHugeiconsIcon({ icon: Folder03Icon }),
+ label: "Media",
+ },
+ sounds: {
+ icon: createHugeiconsIcon({ icon: HeadphonesIcon }),
+ label: "Sounds",
+ },
+ text: {
+ icon: createHugeiconsIcon({ icon: TextIcon }),
+ label: "Text",
+ },
+ stickers: {
+ icon: createHugeiconsIcon({ icon: Happy01Icon }),
+ label: "Stickers",
+ },
+ effects: {
+ icon: createHugeiconsIcon({ icon: MagicWand05Icon }),
+ label: "Effects",
+ },
+ transitions: {
+ icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }),
+ label: "Transitions",
+ },
+ captions: {
+ icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }),
+ label: "Captions",
+ },
+ adjustment: {
+ icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }),
+ label: "Adjustment",
+ },
+ settings: {
+ icon: createHugeiconsIcon({ icon: Settings01Icon }),
+ label: "Settings",
+ },
+} satisfies Record<
+ Tab,
+ { icon: ElementType<{ className?: string }>; label: string }
+>;
+
+export type MediaViewMode = "grid" | "list";
+export type MediaSortKey = "name" | "type" | "duration" | "size";
+export type MediaSortOrder = "asc" | "desc";
+
+interface AssetsPanelStore {
+ activeTab: Tab;
+ setActiveTab: (tab: Tab) => void;
+ highlightMediaId: string | null;
+ requestRevealMedia: (mediaId: string) => void;
+ clearHighlight: () => void;
+
+ /* Media */
+ mediaViewMode: MediaViewMode;
+ setMediaViewMode: (mode: MediaViewMode) => void;
+ mediaSortBy: MediaSortKey;
+ mediaSortOrder: MediaSortOrder;
+ setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
+}
+
+export const useAssetsPanelStore = create<AssetsPanelStore>()(
+ persist(
+ (set) => ({
+ activeTab: "media",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ highlightMediaId: null,
+ requestRevealMedia: (mediaId) =>
+ set({ activeTab: "media", highlightMediaId: mediaId }),
+ clearHighlight: () => set({ highlightMediaId: null }),
+ mediaViewMode: "grid",
+ setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
+ mediaSortBy: "name",
+ mediaSortOrder: "asc",
+ setMediaSort: (key, order) =>
+ set({ mediaSortBy: key, mediaSortOrder: order }),
+ }),
+ {
+ name: "assets-panel",
+ partialize: (state) => ({
+ mediaViewMode: state.mediaViewMode,
+ mediaSortBy: state.mediaSortBy,
+ mediaSortOrder: state.mediaSortOrder,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index 166c81db..2f8bc64a 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -10,9 +10,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useEditor } from "@/hooks/use-editor";
-import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/lib/timeline/drag";
+import { useEditor } from "@/editor/use-editor";
+import { clearDragData, setDragData } from "@/timeline/drag-data";
+import type { TimelineDragData } from "@/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index 1cdbf295..bae73299 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -1,15 +1,15 @@
"use client";
import { Separator } from "@/components/ui/separator";
-import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
+import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
import { TabBar } from "./tabbar";
-import { Captions } from "./views/captions";
+import { Captions } from "@/subtitles/components/assets-view";
import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
-import { SoundsView } from "./views/sounds";
-import { StickersView } from "./views/stickers";
-import { TextView } from "./views/text";
-import { EffectsView } from "./views/effects";
+import { SoundsView } from "@/sounds/components/assets-view";
+import { StickersView } from "@/stickers/components/assets-view";
+import { TextView } from "@/text/components/assets-view";
+import { EffectsView } from "@/effects/components/assets-view";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index da53bed0..3b084c0d 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -12,7 +12,7 @@ import {
TAB_KEYS,
tabs,
useAssetsPanelStore,
-} from "@/stores/assets-panel-store";
+} from "@/components/editor/panels/assets/assets-panel-store";
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 33b47c67..4afd22b7 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -25,28 +25,28 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useEditor } from "@/hooks/use-editor";
-import { useFileUpload } from "@/hooks/use-file-upload";
-import { invokeAction } from "@/lib/actions";
-import { processMediaAssets } from "@/lib/media/processing";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useEditor } from "@/editor/use-editor";
+import { useFileUpload } from "@/media/use-file-upload";
+import { invokeAction } from "@/actions";
+import { processMediaAssets } from "@/media/processing";
+import { showMediaUploadToast } from "@/media/upload-toast";
import {
SelectableItem,
SelectableSurface,
useSelection,
useSelectionScope,
-} from "@/lib/selection";
-import { buildElementFromMedia } from "@/lib/timeline/element-utils";
+} from "@/selection";
+import { buildElementFromMedia } from "@/timeline/element-utils";
import {
type MediaSortKey,
type MediaSortOrder,
type MediaViewMode,
useAssetsPanelStore,
-} from "@/stores/assets-panel-store";
-import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
+} from "@/components/editor/panels/assets/assets-panel-store";
+import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
+import type { MediaAsset } from "@/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
deleted file mode 100644
index 040df749..00000000
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { useReducer, useRef, useState } from "react";
-import { extractTimelineAudio } from "@/lib/media/mediabunny";
-import { useEditor } from "@/hooks/use-editor";
-import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics";
-import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio";
-import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages";
-import type {
- CaptionChunk,
- TranscriptionLanguage,
- TranscriptionProgress,
-} from "@/lib/transcription/types";
-import { transcriptionService } from "@/services/transcription/service";
-import { decodeAudioToFloat32 } from "@/lib/media/audio";
-import { buildCaptionChunks } from "@/lib/transcription/caption";
-import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert";
-import { parseSubtitleFile } from "@/lib/subtitles/parse";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
-} from "@/components/section";
-import { AlertCircleIcon, CloudUploadIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import type { DiagnosticSeverity } from "@/lib/diagnostics/types";
-
-const DIAGNOSTIC_BUTTON_VARIANT: Record<
- DiagnosticSeverity,
- "caution" | "destructive-foreground"
-> = {
- caution: "caution",
- error: "destructive-foreground",
-};
-
-type ProcessingState =
- | { status: "idle"; error: string | null; warnings: string[] }
- | { status: "processing"; step: string };
-
-type ProcessingAction =
- | { type: "start"; step: string }
- | { type: "update_step"; step: string }
- | { type: "succeed"; warnings: string[] }
- | { type: "fail"; error: string };
-
-const IDLE_STATE: ProcessingState = {
- status: "idle",
- error: null,
- warnings: [],
-};
-
-function processingReducer(
- state: ProcessingState,
- action: ProcessingAction,
-): ProcessingState {
- switch (action.type) {
- case "start":
- return { status: "processing", step: action.step };
- case "update_step":
- if (state.status !== "processing") return state;
- return { status: "processing", step: action.step };
- case "succeed":
- return { status: "idle", error: null, warnings: action.warnings };
- case "fail":
- return { status: "idle", error: action.error, warnings: [] };
- }
-}
-
-export function Captions() {
- const [selectedLanguage, setSelectedLanguage] =
- useState<TranscriptionLanguage>("auto");
- const [processing, dispatch] = useReducer(processingReducer, IDLE_STATE);
- const containerRef = useRef<HTMLDivElement>(null);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const editor = useEditor();
-
- const isProcessing = processing.status === "processing";
-
- const activeDiagnostics = useEditor((e) =>
- e.diagnostics.getActive({ scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE }),
- );
-
- const handleProgress = (progress: TranscriptionProgress) => {
- if (progress.status === "loading-model") {
- dispatch({
- type: "update_step",
- step: `Loading model ${Math.round(progress.progress)}%`,
- });
- } else if (progress.status === "transcribing") {
- dispatch({ type: "update_step", step: "Transcribing..." });
- }
- };
-
- const insertCaptions = ({
- captions,
- }: {
- captions: CaptionChunk[];
- }): boolean => {
- const trackId = insertCaptionChunksAsTextTrack({ editor, captions });
- return trackId !== null;
- };
-
- const handleGenerateTranscript = async () => {
- dispatch({ type: "start", step: "Extracting audio..." });
- try {
- const audioBlob = await extractTimelineAudio({
- tracks: editor.scenes.getActiveScene().tracks,
- mediaAssets: editor.media.getAssets(),
- totalDuration: editor.timeline.getTotalDuration(),
- });
-
- dispatch({ type: "update_step", step: "Preparing audio..." });
- const { samples } = await decodeAudioToFloat32({
- audioBlob,
- sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
- });
-
- const result = await transcriptionService.transcribe({
- audioData: samples,
- language: selectedLanguage === "auto" ? undefined : selectedLanguage,
- onProgress: handleProgress,
- });
-
- dispatch({ type: "update_step", step: "Generating captions..." });
- const captionChunks = buildCaptionChunks({ segments: result.segments });
-
- if (!insertCaptions({ captions: captionChunks })) {
- dispatch({ type: "fail", error: "No captions were generated" });
- return;
- }
-
- dispatch({ type: "succeed", warnings: [] });
- } catch (error) {
- console.error("Transcription failed:", error);
- dispatch({
- type: "fail",
- error:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred",
- });
- }
- };
-
- const handleImportClick = () => {
- fileInputRef.current?.click();
- };
-
- const handleImportFile = async ({ file }: { file: File }) => {
- dispatch({ type: "start", step: "Reading subtitle file..." });
- try {
- const input = await file.text();
- const result = parseSubtitleFile({
- fileName: file.name,
- input,
- });
-
- if (result.captions.length === 0) {
- dispatch({
- type: "fail",
- error: "No valid subtitle cues were found in the subtitle file",
- });
- return;
- }
-
- dispatch({ type: "update_step", step: "Importing subtitles..." });
-
- if (!insertCaptions({ captions: result.captions })) {
- dispatch({ type: "fail", error: "No captions were generated" });
- return;
- }
-
- const nextWarnings = [...result.warnings];
- if (result.skippedCueCount > 0) {
- nextWarnings.unshift(
- `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`,
- );
- }
-
- dispatch({ type: "succeed", warnings: nextWarnings });
- } catch (error) {
- console.error("Subtitle import failed:", error);
- dispatch({
- type: "fail",
- error:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred",
- });
- }
- };
-
- const handleFileChange = async ({
- event,
- }: {
- event: React.ChangeEvent<HTMLInputElement>;
- }) => {
- const file = event.target.files?.[0];
- if (event.target) {
- event.target.value = "";
- }
- if (!file) return;
-
- await handleImportFile({ file });
- };
-
- 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);
- };
-
- const error = processing.status === "idle" ? processing.error : null;
- const warnings = processing.status === "idle" ? processing.warnings : [];
-
- return (
- <PanelView
- title="Captions"
- contentClassName="px-0 flex flex-col h-full"
- actions={
- <TooltipProvider>
- <div className="flex items-center gap-1.5">
- {!isProcessing &&
- activeDiagnostics.map((diagnostic) => (
- <Tooltip key={diagnostic.id}>
- <TooltipTrigger asChild>
- <Button
- variant={DIAGNOSTIC_BUTTON_VARIANT[diagnostic.severity]}
- size="icon"
- aria-label={diagnostic.message}
- >
- <HugeiconsIcon icon={AlertCircleIcon} size={16} />
- </Button>
- </TooltipTrigger>
- <TooltipContent>{diagnostic.message}</TooltipContent>
- </Tooltip>
- ))}
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={handleImportClick}
- disabled={isProcessing}
- className="items-center justify-center gap-1.5"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- </TooltipProvider>
- }
- ref={containerRef}
- >
- <input
- ref={fileInputRef}
- type="file"
- accept=".srt,.ass"
- className="hidden"
- onChange={(event) => void handleFileChange({ event })}
- />
- <Section
- showTopBorder={false}
- showBottomBorder={false}
- className="flex-1"
- >
- <SectionContent className="flex flex-col gap-4 h-full pt-1">
- <SectionFields>
- <SectionField label="Language">
- <Select
- value={selectedLanguage}
- onValueChange={(value) => handleLanguageChange({ value })}
- >
- <SelectTrigger>
- <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>
- </SectionField>
- </SectionFields>
-
- <Button
- type="button"
- className="mt-auto w-full"
- onClick={handleGenerateTranscript}
- disabled={isProcessing || activeDiagnostics.length > 0}
- >
- {isProcessing && <Spinner className="mr-1" />}
- {isProcessing ? processing.step : "Generate transcript"}
- </Button>
- {error && (
- <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
- <p className="text-destructive text-sm">{error}</p>
- </div>
- )}
- {warnings.length > 0 && (
- <div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
- <ul className="space-y-1 text-sm text-amber-700">
- {warnings.map((warning) => (
- <li key={warning}>{warning}</li>
- ))}
- </ul>
- </div>
- )}
- </SectionContent>
- </Section>
- </PanelView>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
deleted file mode 100644
index 74128df6..00000000
--- a/apps/web/src/components/editor/panels/assets/views/effects.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useCallback } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
-import { effectPreviewService } from "@/services/renderer/effect-preview";
-import { useEditor } from "@/hooks/use-editor";
-import { buildEffectElement } from "@/lib/timeline/element-utils";
-import type { EffectDefinition } from "@/lib/effects/types";
-
-export function EffectsView() {
- const effects = effectsRegistry.getAll();
-
- return (
- <PanelView title="Effects">
- <EffectsGrid effects={effects} />
- </PanelView>
- );
-}
-
-function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
- return (
- <div
- className="grid gap-2"
- style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
- >
- {effects.map((effect) => (
- <EffectItem key={effect.type} effect={effect} />
- ))}
- </div>
- );
-}
-
-function EffectPreviewCanvas({ effectType }: { effectType: string }) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
-
- useEffect(() => {
- const render = () => {
- if (canvasRef.current) {
- effectPreviewService.renderPreview({
- effectType,
- params: {},
- targetCanvas: canvasRef.current,
- });
- }
- };
-
- render();
- return effectPreviewService.onPreviewImageReady({ callback: render });
- }, [effectType]);
-
- return <canvas ref={canvasRef} className="size-full" />;
-}
-
-function EffectItem({ effect }: { effect: EffectDefinition }) {
- const editor = useEditor();
-
- const handleAddToTimeline = useCallback(() => {
- const currentTime = editor.playback.getCurrentTime();
- const element = buildEffectElement({
- effectType: effect.type,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- placement: { mode: "auto", trackType: "effect" },
- element,
- });
- }, [editor, effect.type]);
-
- const preview = <EffectPreviewCanvas effectType={effect.type} />;
-
- return (
- <DraggableItem
- name={effect.name}
- preview={preview}
- dragData={{
- id: effect.type,
- name: effect.name,
- type: "effect",
- effectType: effect.type,
- targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
- }}
- onAddToTimeline={handleAddToTimeline}
- aspectRatio={1}
- isRounded
- variant="card"
- containerClassName="w-full"
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
index 099e335a..9ce34ebb 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -12,12 +12,12 @@ import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
BACKGROUND_BLUR_INTENSITY_PRESETS,
DEFAULT_BACKGROUND_BLUR_INTENSITY,
-} from "@/lib/background/blur";
-import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color";
+} from "@/background/blur";
+import { DEFAULT_BACKGROUND_COLOR } from "@/background/color";
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 { useEditor } from "@/editor/use-editor";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { cn } from "@/utils/ui";
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index 4d87493a..bf3a1c16 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -9,9 +9,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/lib/fps/presets";
-import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
-import { useEditor } from "@/hooks/use-editor";
+import { FPS_PRESETS } from "@/fps/presets";
+import { floatToFrameRate, frameRateToFloat } from "@/fps/utils";
+import { useEditor } from "@/editor/use-editor";
import {
Section,
SectionContent,
@@ -22,7 +22,7 @@ import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
-import { useEditorStore } from "@/stores/editor-store";
+import { useEditorStore } from "@/editor/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons";
@@ -30,7 +30,7 @@ import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@/components/icons";
-import type { TCanvasSize } from "@/lib/project/types";
+import type { TCanvasSize } from "@/project/types";
type SettingsView = "project-info" | "background";
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
deleted file mode 100644
index c7a1029c..00000000
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ /dev/null
@@ -1,563 +0,0 @@
-"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 "@/lib/sounds/types";
-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="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>
- </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="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-background 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>
- );
-}
-
-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
deleted file mode 100644
index 318aaccb..00000000
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ /dev/null
@@ -1,455 +0,0 @@
-"use client";
-
-import Image from "next/image";
-import type { CSSProperties } from "react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Spinner } from "@/components/ui/spinner";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { useEditor } from "@/hooks/use-editor";
-import { resolveStickerIntrinsicSize } from "@/lib/stickers";
-import {
- buildGraphicElement,
- buildStickerElement,
-} from "@/lib/timeline/element-utils";
-import { STICKER_CATEGORIES } from "@/lib/stickers/categories";
-import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
-import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
-import type { TimelineDragData } from "@/lib/timeline/drag";
-import type {
- StickerBrowseSection,
- StickerCategory,
- StickerItem as StickerData,
-} from "@/lib/stickers";
-import { useStickersStore } from "@/stores/stickers-store";
-import { cn } from "@/utils/ui";
-import {
- HappyIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-export function StickersView() {
- const {
- browseContent,
- browseStickers,
- searchQuery,
- searchStickers,
- selectedCategory,
- setSearchQuery,
- setSelectedCategory,
- viewMode,
- } = useStickersStore();
-
- useEffect(() => {
- if (viewMode === "browse" && !browseContent) {
- void browseStickers();
- }
- }, [browseContent, browseStickers, viewMode]);
-
- return (
- <div className="flex h-full flex-col py-2">
- <div className="px-2">
- <Input
- size="sm"
- variant="default"
- placeholder="Search..."
- value={searchQuery}
- onChange={(e) => {
- setSearchQuery({ query: e.target.value });
- void searchStickers({ query: e.target.value });
- }}
- showClearIcon
- onClear={() => {
- setSearchQuery({ query: "" });
- void searchStickers({ query: "" });
- }}
- className="w-full"
- containerClassName="w-full"
- />
- </div>
-
- <Tabs
- value={selectedCategory}
- onValueChange={(value) => {
- setSelectedCategory({ category: value as StickerCategory });
- }}
- variant="underline"
- className="mt-2 flex min-h-0 flex-1 flex-col"
- >
- <TabsList aria-label="Sticker categories">
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
- <TabsTrigger key={key} value={key}>
- {label}
- </TabsTrigger>
- ))}
- </TabsList>
- <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
- <StickersContentView />
- </div>
- </Tabs>
- </div>
- );
-}
-
-function StickerGrid({
- items,
- shouldCapSize = false,
-}: {
- items: StickerData[];
- shouldCapSize?: boolean;
-}) {
- const gridStyle: CSSProperties & {
- "--sticker-min": string;
- "--sticker-max"?: string;
- } = {
- gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
- : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
- "--sticker-min": "80px",
- ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
- };
-
- return (
- <div className="grid gap-2" style={gridStyle}>
- {items.map((item) => (
- <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
- ))}
- </div>
- );
-}
-
-function StickerRow({ items }: { items: StickerData[] }) {
- return (
- <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
- {items.map((item) => (
- <div key={item.id} className="w-20 shrink-0">
- <StickerItem item={item} shouldCapSize containerClassName="w-full" />
- </div>
- ))}
- </div>
- );
-}
-
-function EmptyView({ message }: { message: string }) {
- return (
- <div className="bg-background 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 RegionBanner({ region }: { region: string }) {
- return (
- <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
- <svg
- width="12"
- height="12"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- className="shrink-0 text-sky-600"
- aria-hidden="true"
- >
- <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
- <circle cx="12" cy="10" r="3" />
- </svg>
- <span className="text-xs font-semibold text-sky-600">{region}</span>
- </div>
- );
-}
-
-function StickersContentView() {
- const {
- browseContent,
- clearRecentStickers,
- isBrowsing,
- isSearching,
- searchQuery,
- searchResults,
- selectedCategory,
- setSelectedCategory,
- viewMode,
- } = useStickersStore();
-
- if (viewMode === "search") {
- if (isSearching) {
- return (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- );
- }
-
- if (searchResults?.items.length) {
- const normalizedQuery = searchQuery.trim().toLowerCase();
- const isRegionSearch =
- selectedCategory === "flags" &&
- resolveQueryToRegions({ query: normalizedQuery }) !== null;
- const regionLabel = getRegionLabel({ query: normalizedQuery });
-
- return (
- <div className="flex flex-col gap-3 pb-4">
- {isRegionSearch && <RegionBanner region={regionLabel} />}
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid items={searchResults.items} />
- </div>
- );
- }
-
- // "all" tab search — sections are in browseContent, fall through to section rendering below
- if (selectedCategory !== "all" && searchQuery) {
- return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
- }
- }
-
- if (isBrowsing && !browseContent) {
- return (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- );
- }
-
- if (!browseContent?.sections.length) {
- const categoryLabel = STICKER_CATEGORIES[selectedCategory];
- return (
- <EmptyView
- message={
- viewMode === "search"
- ? `No stickers found for "${searchQuery}"`
- : selectedCategory === "all"
- ? "No stickers available yet."
- : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
- }
- />
- );
- }
-
- return (
- <div className="flex flex-col gap-4 pb-4">
- {browseContent.sections.map((section) => (
- <StickerSection
- key={section.id}
- section={section}
- onClearRecent={clearRecentStickers}
- onSeeAll={(category) => {
- setSelectedCategory({ category });
- }}
- />
- ))}
- </div>
- );
-}
-
-function StickerSection({
- section,
- onClearRecent,
- onSeeAll,
-}: {
- section: StickerBrowseSection;
- onClearRecent: () => void;
- onSeeAll: (category: StickerCategory) => void;
-}) {
- const hasHeader =
- Boolean(section.title) || section.id === "recent" || section.action;
-
- return (
- <div className="flex flex-col gap-2">
- {hasHeader && (
- <div className="flex items-center justify-between gap-2">
- {section.title ? (
- <p className="text-xs text-muted-foreground">{section.title}</p>
- ) : (
- <div />
- )}
-
- <div className="ml-auto flex items-center gap-2">
- {section.id === "recent" && (
- <Button
- onClick={onClearRecent}
- variant="text"
- size="sm"
- className="h-auto gap-1 p-0 text-xs text-muted-foreground"
- >
- Clear
- </Button>
- )}
-
- {section.action?.type === "see-all" && section.action.category && (
- <Button
- variant="text"
- size="sm"
- className="h-auto gap-1 p-0 text-xs text-primary"
- onClick={() => {
- onSeeAll(section.action?.category as StickerCategory);
- }}
- >
- See all
- </Button>
- )}
- </div>
- </div>
- )}
-
- {section.layout === "row" ? (
- <StickerRow items={section.items} />
- ) : (
- <StickerGrid items={section.items} />
- )}
- </div>
- );
-}
-
-interface StickerItemProps {
- item: StickerData;
- shouldCapSize?: boolean;
- containerClassName?: string;
-}
-
-function StickerItem({
- item,
- shouldCapSize = false,
- containerClassName,
-}: StickerItemProps) {
- const editor = useEditor();
- const { addToRecentStickers } = useStickersStore();
- const [isAdding, setIsAdding] = useState(false);
- const [hasImageError, setHasImageError] = useState(false);
-
- useEffect(() => {
- if (!item.id) {
- return;
- }
-
- setHasImageError(false);
- }, [item.id]);
-
- const displayName = item.name;
- const shapePreset =
- item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
-
- const handleAdd = async () => {
- setIsAdding(true);
- try {
- const currentTime = editor.playback.getCurrentTime();
-
- let element:
- | ReturnType<typeof buildGraphicElement>
- | ReturnType<typeof buildStickerElement>;
- if (shapePreset) {
- element = buildGraphicElement({
- definitionId: shapePreset.definitionId,
- name: shapePreset.name,
- startTime: currentTime,
- params: shapePreset.params,
- });
- } else {
- const { width: intrinsicWidth, height: intrinsicHeight } =
- await resolveStickerIntrinsicSize({ stickerId: item.id });
- element = buildStickerElement({
- stickerId: item.id,
- name: item.name,
- startTime: currentTime,
- intrinsicWidth,
- intrinsicHeight,
- });
- }
-
- editor.timeline.insertElement({
- placement: { mode: "auto" },
- element,
- });
-
- addToRecentStickers({ stickerId: item.id });
- } catch (error) {
- console.error("Failed to add sticker:", error);
- toast.error("Failed to add sticker to timeline");
- } finally {
- setIsAdding(false);
- }
- };
-
- const preview = (
- <div className="flex size-full items-center justify-center p-3">
- {hasImageError ? (
- <span className="text-muted-foreground text-center text-xs break-all">
- {displayName}
- </span>
- ) : (
- <Image
- src={item.previewUrl}
- alt={displayName}
- width={64}
- height={64}
- className="size-full object-contain"
- style={
- shouldCapSize
- ? {
- maxWidth: "var(--sticker-max, 160px)",
- maxHeight: "var(--sticker-max, 160px)",
- }
- : undefined
- }
- onError={() => setHasImageError(true)}
- loading="lazy"
- unoptimized
- />
- )}
- </div>
- );
-
- const dragData: TimelineDragData = shapePreset
- ? {
- id: item.id,
- type: "graphic",
- name: displayName,
- definitionId: shapePreset.definitionId,
- params: shapePreset.params ?? {},
- }
- : {
- id: item.id,
- type: "sticker",
- name: displayName,
- stickerId: item.id,
- };
-
- return (
- <div
- className={cn("relative", isAdding && "pointer-events-none opacity-50")}
- >
- <DraggableItem
- name={displayName}
- preview={preview}
- dragData={dragData}
- onAddToTimeline={handleAdd}
- aspectRatio={1}
- shouldShowLabel={false}
- isRounded
- variant="card"
- containerClassName={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>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
deleted file mode 100644
index da295da3..00000000
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-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: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-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: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
deleted file mode 100644
index 3d96f762..00000000
--- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import { useEditor } from "@/hooks/use-editor";
-import { findCurrentScene } from "@/lib/scenes";
-import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
-
-export function BookmarkNoteOverlay() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const activeProject = editor.project.getActive();
-
- if (!activeProject) {
- return null;
- }
-
- const activeScene = findCurrentScene({
- scenes: activeProject.scenes,
- currentSceneId: activeProject.currentSceneId,
- });
-
- if (!activeScene) {
- return null;
- }
-
- const bookmarks = activeScene.bookmarks;
- const activeBookmarks = getBookmarksActiveAtTime({
- bookmarks,
- time: currentTime,
- });
- const bookmarksWithNotes = activeBookmarks.filter(
- (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
- );
-
- if (bookmarksWithNotes.length === 0) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
- aria-live="polite"
- >
- {bookmarksWithNotes.map((bookmark) => (
- <div
- key={bookmark.time}
- className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
- style={{
- backgroundColor: "rgb(0 0 0 / 0.5)",
- borderLeft: bookmark.color
- ? `3px solid ${bookmark.color}`
- : "3px solid var(--primary)",
- }}
- >
- {bookmark.note}
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
deleted file mode 100644
index 677f35db..00000000
--- a/apps/web/src/components/editor/panels/preview/context-menu.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-"use client";
-
-import {
- ContextMenuCheckboxItem,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
-} from "@/components/ui/context-menu";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import { usePreviewStore } from "@/stores/preview-store";
-import { toast } from "sonner";
-
-export function PreviewContextMenu({
- onToggleFullscreen,
- containerRef,
-}: {
- onToggleFullscreen: () => void;
- containerRef: React.RefObject<HTMLElement | null>;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const { overlays, setOverlayVisibility } = usePreviewStore();
-
- const handleCopySnapshot = async () => {
- const result = await editor.renderer.copySnapshot();
-
- if (!result.success) {
- toast.error("Failed to copy snapshot", {
- description: result.error ?? "Please try again",
- });
- return;
- }
- };
-
- const handleSaveSnapshot = async () => {
- const result = await editor.renderer.saveSnapshot();
-
- if (!result.success) {
- toast.error("Failed to save snapshot", {
- description: result.error ?? "Please try again",
- });
- return;
- }
- };
-
- return (
- <ContextMenuContent className="w-56" container={containerRef.current}>
- <ContextMenuItem onClick={viewport.fitToScreen} inset>
- Fit to screen
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem onClick={onToggleFullscreen} inset>
- Full screen
- </ContextMenuItem>
- <ContextMenuItem onClick={handleSaveSnapshot} inset>
- Save snapshot
- </ContextMenuItem>
- <ContextMenuItem onClick={handleCopySnapshot} inset>
- Copy snapshot
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuCheckboxItem
- checked={overlays.bookmarks}
- onCheckedChange={(checked) =>
- setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
- }
- >
- Show bookmark notes
- </ContextMenuCheckboxItem>
- </ContextMenuContent>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts
deleted file mode 100644
index f04dbef0..00000000
--- a/apps/web/src/components/editor/panels/preview/cursors.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-function svgCursor({
- svg,
- hotspotX,
- hotspotY,
-}: {
- svg: string;
- hotspotX: number;
- hotspotY: number;
-}): string {
- return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`;
-}
-
-/** Hotspot is at the nib tip, which is where anchor points land. */
-export const PEN_CURSOR = svgCursor({
- svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
- <path d="M 1 1 L 5 2 L 13 10 L 10 13 L 2 5 Z" fill="white" stroke="#111" stroke-width="1" stroke-linejoin="round"/>
- <path d="M 1 1 L 5 2 L 2 5 Z" fill="#111"/>
-</svg>`,
- hotspotX: 1,
- hotspotY: 1,
-});
diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
deleted file mode 100644
index 4270fcb3..00000000
--- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"use client";
-
-import { getGuideById } from "@/lib/guides";
-import { usePreviewStore } from "@/stores/preview-store";
-import { usePreviewViewport } from "./preview-viewport";
-
-export function GuideOverlay() {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const viewport = usePreviewViewport();
- const guide = getGuideById(activeGuide);
-
- if (!guide) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: viewport.sceneLeft,
- top: viewport.sceneTop,
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- }}
- >
- {guide.renderOverlay({
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
deleted file mode 100644
index bcac8075..00000000
--- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-"use client";
-
-import { AnimatePresence, motion } from "motion/react";
-import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
-import { usePreviewStore } from "@/stores/preview-store";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { cn } from "@/utils/ui";
-
-export function GridPopover({ children }: { children: React.ReactNode }) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const toggleGuide = usePreviewStore((state) => state.toggleGuide);
- const activeGuideDef = getGuideById(activeGuide);
- const options = activeGuideDef?.renderOptions?.();
-
- return (
- <Popover>
- <PopoverTrigger>{children}</PopoverTrigger>
- <PopoverContent sideOffset={8} className="w-60 px-0">
- <div className="flex flex-col gap-2 px-4">
- <Label>Guides</Label>
- <div className="grid grid-cols-3 gap-1">
- {GUIDE_REGISTRY.map((guide) => (
- <GridItem
- key={guide.id}
- label={guide.label}
- preview={guide.renderPreview()}
- isSelected={activeGuide === guide.id}
- onClick={() => toggleGuide(guide.id)}
- />
- ))}
- </div>
- </div>
- <AnimatePresence>
- {options && (
- <motion.div
- initial={{ height: 0, opacity: 0 }}
- animate={{ height: "auto", opacity: 1 }}
- exit={{ height: 0, opacity: 0 }}
- transition={{
- height: { type: "spring", duration: 0.2, bounce: 0 },
- opacity: { duration: 0.2 },
- }}
- className="overflow-hidden"
- >
- <Separator className="my-3" />
- <div className="px-4">{options}</div>
- </motion.div>
- )}
- </AnimatePresence>
- </PopoverContent>
- </Popover>
- );
-}
-
-function GridItem({
- label,
- preview,
- isSelected,
- onClick,
-}: {
- label: string;
- preview: React.ReactNode;
- isSelected?: boolean;
- onClick: () => void;
-}) {
- return (
- <button
- type="button"
- onClick={onClick}
- aria-pressed={isSelected}
- className={cn(
- "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
- isSelected && "bg-primary/5! text-primary",
- )}
- >
- <div
- className={cn(
- "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
- isSelected && "bg-primary/5!",
- )}
- >
- {preview}
- </div>
- <span
- className={cn(
- "text-xs",
- isSelected ? "text-primary" : "text-muted-foreground",
- )}
- >
- {label}
- </span>
- </button>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
deleted file mode 100644
index 33baf9fc..00000000
--- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-"use client";
-
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import type { IconSvgElement } from "@hugeicons/react";
-
-export const HANDLE_SIZE = 10;
-export const HANDLE_HIT_AREA_SIZE = 18;
-export const ICON_HANDLE_RADIUS = 10;
-export const EDGE_HANDLE_THIN_SIZE = 6;
-export const EDGE_HANDLE_THICK_SIZE = 14;
-export const LINE_HIT_AREA_SIZE = 48;
-
-export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
- const normalized = ((angleDeg % 180) + 180) % 180;
- if (normalized < 22.5 || normalized >= 157.5) return "ew-resize";
- if (normalized < 67.5) return "nwse-resize";
- if (normalized < 112.5) return "ns-resize";
- return "nesw-resize";
-}
-
-export function HandleButton({
- screen,
- cursor,
- hitAreaSize,
- className,
- onPointerDown,
- onPointerMove,
- onPointerUp,
- children,
-}: {
- screen: { x: number; y: number };
- cursor?: string;
- hitAreaSize: number;
- className?: string;
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
- children: React.ReactNode;
-}) {
- return (
- <button
- type="button"
- className={cn(
- "absolute flex items-center justify-center outline-none",
- className,
- )}
- style={{
- left: screen.x - hitAreaSize / 2,
- top: screen.y - hitAreaSize / 2,
- width: hitAreaSize,
- height: hitAreaSize,
- pointerEvents: "auto",
- cursor,
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- {children}
- </button>
- );
-}
-
-export function CornerHandle({
- cursor,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- cursor?: string;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <HandleButton
- screen={screen}
- cursor={cursor}
- hitAreaSize={HANDLE_HIT_AREA_SIZE}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
- />
- </HandleButton>
- );
-}
-
-export function CircleHandle({
- cursor,
- screen,
- size = HANDLE_SIZE,
- isSelected = false,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- cursor?: string;
- screen: { x: number; y: number };
- size?: number;
- isSelected?: boolean;
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <HandleButton
- screen={screen}
- cursor={cursor}
- hitAreaSize={HANDLE_HIT_AREA_SIZE}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <div
- className={cn("rounded-full", isSelected ? "bg-primary" : "bg-white")}
- style={{ width: size, height: size }}
- />
- </HandleButton>
- );
-}
-
-export function EdgeHandle({
- edge,
- screen,
- rotation,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- edge: "right" | "left" | "bottom";
- screen: { x: number; y: number };
- rotation: number;
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isHorizontalEdge = edge === "right" || edge === "left";
- const width = isHorizontalEdge
- ? EDGE_HANDLE_THIN_SIZE
- : EDGE_HANDLE_THICK_SIZE;
- const height = isHorizontalEdge
- ? EDGE_HANDLE_THICK_SIZE
- : EDGE_HANDLE_THIN_SIZE;
- const cursor = getResizeCursor({
- angleDeg: isHorizontalEdge ? rotation : rotation + 90,
- });
-
- return (
- <HandleButton
- screen={screen}
- cursor={cursor}
- hitAreaSize={HANDLE_HIT_AREA_SIZE}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width, height, transform: `rotate(${rotation}deg)` }}
- />
- </HandleButton>
- );
-}
-
-export function IconHandle({
- icon,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- icon: IconSvgElement;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <HandleButton
- screen={screen}
- hitAreaSize={ICON_HANDLE_RADIUS * 2}
- className="rounded-full bg-white text-black shadow-sm"
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- >
- <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
- </HandleButton>
- );
-}
-
-export function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
- cursor,
- dashed = false,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
- cursor?: string;
- dashed?: boolean;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- return (
- <svg
- className="absolute overflow-visible"
- aria-hidden="true"
- focusable="false"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- pointerEvents: onPointerDown ? "auto" : "none",
- cursor,
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- >
- <rect
- x={0.5}
- y={0.5}
- width={Math.max(outlineWidth - 1, 0)}
- height={Math.max(outlineHeight - 1, 0)}
- fill="transparent"
- stroke="white"
- strokeDasharray={dashed ? "4 4" : undefined}
- strokeOpacity={0.75}
- vectorEffect="non-scaling-stroke"
- style={{ pointerEvents: onPointerDown ? "all" : "none" }}
- />
- </svg>
- );
-}
-
-export function ShapeOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
- pathData,
- cursor,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
- pathData: string;
- cursor?: string;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- return (
- <svg
- className="absolute overflow-visible"
- aria-hidden="true"
- focusable="false"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- pointerEvents: onPointerDown ? "auto" : "none",
- cursor,
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- >
- <path
- d={pathData}
- fill="transparent"
- stroke="white"
- strokeOpacity={0.75}
- vectorEffect="non-scaling-stroke"
- style={{ pointerEvents: onPointerDown ? "all" : "none" }}
- />
- </svg>
- );
-}
-
-export function CanvasPathOutline({
- pathData,
- translateX = 0,
- translateY = 0,
- scaleX = 1,
- scaleY = 1,
- cursor,
- strokeWidth = 1,
- strokeOpacity = 0.75,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- pathData: string;
- translateX?: number;
- translateY?: number;
- scaleX?: number;
- scaleY?: number;
- cursor?: string;
- strokeWidth?: number;
- strokeOpacity?: number;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- return (
- <svg
- className="absolute inset-0 overflow-visible"
- aria-hidden="true"
- focusable="false"
- style={{
- pointerEvents: onPointerDown ? "auto" : "none",
- cursor,
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- >
- <g
- transform={`translate(${translateX} ${translateY}) scale(${scaleX} ${scaleY})`}
- >
- <path
- d={pathData}
- fill="transparent"
- stroke="white"
- strokeWidth={strokeWidth}
- strokeOpacity={strokeOpacity}
- vectorEffect="non-scaling-stroke"
- style={{ pointerEvents: onPointerDown ? "stroke" : "none" }}
- />
- </g>
- </svg>
- );
-}
-
-export function LineOverlay({
- start,
- end,
- cursor,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- start: { x: number; y: number };
- end: { x: number; y: number };
- cursor?: string;
- onPointerDown?: (event: React.PointerEvent) => void;
- onPointerMove?: (event: React.PointerEvent) => void;
- onPointerUp?: (event: React.PointerEvent) => void;
-}) {
- const dx = end.x - start.x;
- const dy = end.y - start.y;
- const length = Math.sqrt(dx * dx + dy * dy);
- const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
- const cx = (start.x + end.x) / 2;
- const cy = (start.y + end.y) / 2;
-
- const sharedStyle = {
- left: cx - length / 2,
- width: length,
- transform: `rotate(${angleDeg}deg)`,
- transformOrigin: "center center",
- };
-
- return (
- <>
- {onPointerDown && (
- <div
- className="absolute"
- style={{
- ...sharedStyle,
- top: cy - LINE_HIT_AREA_SIZE / 2,
- height: LINE_HIT_AREA_SIZE,
- pointerEvents: "auto",
- cursor,
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- />
- )}
- <div
- className="pointer-events-none absolute"
- style={{
- ...sharedStyle,
- top: cy - 0.5,
- height: 1,
- backgroundColor: "white",
- opacity: 0.75,
- }}
- />
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
deleted file mode 100644
index 369d854c..00000000
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, 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 { useContainerSize } from "@/hooks/use-container-size";
-import { useFullscreen } from "@/hooks/use-fullscreen";
-import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import type { RootNode } from "@/services/renderer/nodes/root-node";
-import { buildScene } from "@/services/renderer/scene-builder";
-import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
-import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
-import { GuideOverlay } from "./guide-overlay";
-import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
-import { usePreviewStore } from "@/stores/preview-store";
-import { PreviewContextMenu } from "./context-menu";
-import { PreviewToolbar } from "./toolbar";
-import {
- PreviewViewportProvider,
- usePreviewViewportState,
-} from "./preview-viewport";
-
-function usePreviewSize() {
- const canvasSize = useEditor(
- (e) => e.project.getActive()?.settings.canvasSize,
- );
-
- return {
- width: canvasSize?.width,
- height: canvasSize?.height,
- };
-}
-
-function normalizeWheelDelta({
- delta,
- deltaMode,
- pageSize,
-}: {
- delta: number;
- deltaMode: number;
- pageSize: number;
-}): number {
- if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
- return delta * 16;
- }
-
- if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
- return delta * pageSize;
- }
-
- return delta;
-}
-
-export function PreviewPanel() {
- const containerRef = useRef<HTMLDivElement>(null);
- const { toggleFullscreen } = useFullscreen({ containerRef });
-
- return (
- <div
- ref={containerRef}
- className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
- >
- <PreviewCanvas
- containerRef={containerRef}
- onToggleFullscreen={toggleFullscreen}
- />
- <RenderTreeController />
- </div>
- );
-}
-
-function RenderTreeController() {
- const editor = useEditor();
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const activeProject = useEditor((e) => e.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,
- isPreview: true,
- });
-
- editor.renderer.setRenderTree({ renderTree });
- }, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
-
- return null;
-}
-
-function PreviewCanvas({
- containerRef,
- onToggleFullscreen,
-}: {
- containerRef: React.RefObject<HTMLElement | null>;
- onToggleFullscreen: () => void;
-}) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const viewportRef = useRef<HTMLDivElement>(null);
- const lastFrameRef = useRef(-1);
- const lastSceneRef = useRef<RootNode | null>(null);
- const renderingRef = useRef(false);
- const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
- const viewportSize = useContainerSize({ containerRef: viewportRef });
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
- const renderTree = useEditor((e) => e.renderer.getRenderTree());
- const { overlays } = usePreviewStore();
- const viewport = usePreviewViewportState({
- canvasHeight: nativeHeight,
- canvasWidth: nativeWidth,
- viewportHeight: viewportSize.height,
- viewportRef,
- viewportWidth: viewportSize.width,
- });
-
- const renderer = useMemo(() => {
- return new CanvasRenderer({
- width: nativeWidth,
- height: nativeHeight,
- fps: activeProject.settings.fps,
- });
- }, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
-
- const render = useCallback(() => {
- if (canvasRef.current && renderTree && !renderingRef.current) {
- const renderTime = Math.min(
- editor.playback.getCurrentTime(),
- editor.timeline.getLastFrameTime(),
- );
- const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
- const frame = Math.floor(renderTime / ticksPerFrame);
-
- if (
- frame !== lastFrameRef.current ||
- renderTree !== lastSceneRef.current
- ) {
- renderingRef.current = true;
- lastSceneRef.current = renderTree;
- lastFrameRef.current = frame;
- renderer
- .renderToCanvas({
- node: renderTree,
- time: renderTime,
- targetCanvas: canvasRef.current,
- })
- .then(() => {
- renderingRef.current = false;
- });
- }
- }
- }, [renderer, renderTree, editor.playback]);
-
- useRafLoop(render);
-
- useEffect(() => {
- const container = viewportRef.current;
- if (!container) return;
-
- let pendingZoomDelta = 0;
- let pendingPanDeltaX = 0;
- let pendingPanDeltaY = 0;
- let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
- let panRafId: ReturnType<typeof requestAnimationFrame> | null = null;
-
- const onWheel = (event: WheelEvent) => {
- const normalizedDeltaX = normalizeWheelDelta({
- delta: event.deltaX,
- deltaMode: event.deltaMode,
- pageSize: container.clientWidth,
- });
- const normalizedDeltaY = normalizeWheelDelta({
- delta: event.deltaY,
- deltaMode: event.deltaMode,
- pageSize: container.clientHeight,
- });
- const isZoomGesture = event.ctrlKey || event.metaKey;
- if (isZoomGesture) {
- event.preventDefault();
- pendingZoomDelta += normalizedDeltaY;
-
- if (zoomRafId === null) {
- zoomRafId = requestAnimationFrame(() => {
- const cappedDelta =
- Math.sign(pendingZoomDelta) *
- Math.min(Math.abs(pendingZoomDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
-
- viewport.scaleZoom({ factor: zoomFactor });
- pendingZoomDelta = 0;
- zoomRafId = null;
- });
- }
-
- return;
- }
-
- if (!viewport.canPan) {
- return;
- }
-
- if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
- return;
- }
-
- event.preventDefault();
- pendingPanDeltaX += normalizedDeltaX;
- pendingPanDeltaY += normalizedDeltaY;
-
- if (panRafId === null) {
- panRafId = requestAnimationFrame(() => {
- viewport.panByScreenDelta({
- deltaX: pendingPanDeltaX,
- deltaY: pendingPanDeltaY,
- });
- pendingPanDeltaX = 0;
- pendingPanDeltaY = 0;
- panRafId = null;
- });
- }
- };
-
- container.addEventListener("wheel", onWheel, {
- capture: true,
- passive: false,
- });
-
- return () => {
- container.removeEventListener("wheel", onWheel, {
- capture: true,
- });
- if (zoomRafId !== null) {
- cancelAnimationFrame(zoomRafId);
- }
- if (panRafId !== null) {
- cancelAnimationFrame(panRafId);
- }
- };
- }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
-
- return (
- <PreviewViewportProvider value={viewport}>
- <div className="flex size-full min-h-0 min-w-0 flex-col">
- <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- ref={viewportRef}
- className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
- >
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="absolute block border"
- style={{
- left: viewport.sceneLeft,
- top: viewport.sceneTop,
- width: viewport.sceneWidth,
- height: viewport.sceneHeight,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
- />
- <GuideOverlay />
- <PreviewInteractionOverlay />
- {overlays.bookmarks && <BookmarkNoteOverlay />}
- </div>
- </ContextMenuTrigger>
- <PreviewContextMenu
- onToggleFullscreen={onToggleFullscreen}
- containerRef={containerRef}
- />
- </ContextMenu>
- </div>
- <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
- </div>
- </PreviewViewportProvider>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
deleted file mode 100644
index d32b6ff9..00000000
--- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-"use client";
-
-import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useMaskHandles } from "@/hooks/use-mask-handles";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import {
- CornerHandle,
- CircleHandle,
- CanvasPathOutline,
- EdgeHandle,
- IconHandle,
- LineOverlay,
- BoundingBoxOutline,
- ShapeOutline,
-} from "./handle-primitives";
-
-const CUSTOM_MASK_ANCHOR_SIZE = 7;
-const CUSTOM_MASK_TANGENT_SIZE = 6;
-import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
-
-export function MaskHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: (lines: SnapLine[]) => void;
-}) {
- const viewport = usePreviewViewport();
- const {
- selectedWithMask,
- handlePositions,
- overlays,
- isCreatingCustomMask,
- handleCanvasPointerDown,
- handlePointerDown,
- handlePointerMove,
- handlePointerUp,
- } = useMaskHandles({ onSnapLinesChange });
-
- if (!selectedWithMask) return null;
-
- const toOverlay = ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) =>
- viewport.canvasToOverlay({
- canvasX,
- canvasY,
- });
-
- const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
- const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 });
-
- const onPointerMove = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerMove({ event })) {
- return;
- }
-
- handlePointerMove({ event });
- };
- const onPointerUp = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerUp({ event })) {
- return;
- }
-
- handlePointerUp();
- };
- const handleMaskPointerDown = ({
- event,
- handleId,
- }: {
- event: React.PointerEvent;
- handleId: string;
- }) => {
- if (viewport.handlePanPointerDown({ event })) {
- return;
- }
-
- handlePointerDown({ event, handleId });
- };
- const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerDown({ event })) {
- return;
- }
-
- handleCanvasPointerDown({ event });
- };
-
- return (
- <div
- className="pointer-events-none absolute inset-0 overflow-hidden"
- aria-hidden
- >
- {isCreatingCustomMask ? (
- <div
- className="absolute inset-0 pointer-events-auto"
- style={{ cursor: PEN_CURSOR }}
- onPointerDown={handleCanvasOverlayPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
- ) : null}
- {overlays.map((overlay) => {
- const overlayHandleId = overlay.handleId;
- const pointerHandlers = overlayHandleId
- ? {
- onPointerDown: (event: React.PointerEvent) =>
- handleMaskPointerDown({ event, handleId: overlayHandleId }),
- onPointerMove,
- onPointerUp,
- }
- : {};
-
- if (overlay.type === "line") {
- return (
- <LineOverlay
- key={overlay.id}
- cursor={overlay.cursor}
- start={toOverlay({
- canvasX: overlay.start.x,
- canvasY: overlay.start.y,
- })}
- end={toOverlay({
- canvasX: overlay.end.x,
- canvasY: overlay.end.y,
- })}
- {...pointerHandlers}
- />
- );
- }
-
- if (overlay.type === "rect") {
- return (
- <BoundingBoxOutline
- key={overlay.id}
- center={toOverlay({
- canvasX: overlay.center.x,
- canvasY: overlay.center.y,
- })}
- outlineWidth={overlay.width * scaleX}
- outlineHeight={overlay.height * scaleY}
- rotation={overlay.rotation}
- cursor={overlay.cursor}
- dashed={overlay.dashed}
- {...pointerHandlers}
- />
- );
- }
-
- if (overlay.type === "shape") {
- return (
- <ShapeOutline
- key={overlay.id}
- center={toOverlay({
- canvasX: overlay.center.x,
- canvasY: overlay.center.y,
- })}
- outlineWidth={overlay.width * scaleX}
- outlineHeight={overlay.height * scaleY}
- rotation={overlay.rotation}
- pathData={overlay.pathData}
- cursor={overlay.cursor}
- {...pointerHandlers}
- />
- );
- }
-
- if (overlay.type === "canvas-path") {
- return (
- <CanvasPathOutline
- key={overlay.id}
- pathData={overlay.pathData}
- translateX={
- overlay.coordinateSpace === "canvas" ? canvasOrigin.x : 0
- }
- translateY={
- overlay.coordinateSpace === "canvas" ? canvasOrigin.y : 0
- }
- scaleX={overlay.coordinateSpace === "canvas" ? scaleX : 1}
- scaleY={overlay.coordinateSpace === "canvas" ? scaleY : 1}
- cursor={overlay.cursor}
- strokeWidth={overlay.strokeWidth}
- strokeOpacity={overlay.strokeOpacity}
- {...pointerHandlers}
- />
- );
- }
-
- return null;
- })}
- {handlePositions.map((handle) => {
- const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
-
- if (handle.kind === "icon" && handle.icon === "rotate") {
- return (
- <IconHandle
- key={handle.id}
- icon={Rotate01Icon}
- screen={screen}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- if (handle.kind === "icon" && handle.icon === "feather") {
- return (
- <IconHandle
- key={handle.id}
- icon={FeatherIcon}
- screen={screen}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
- return (
- <EdgeHandle
- key={handle.id}
- edge="right"
- screen={screen}
- rotation={handle.rotation ?? 0}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
- return (
- <EdgeHandle
- key={handle.id}
- edge="bottom"
- screen={screen}
- rotation={handle.rotation ?? 0}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- if (handle.kind === "point" || handle.kind === "tangent") {
- return (
- <CircleHandle
- key={handle.id}
- screen={screen}
- size={
- handle.kind === "tangent"
- ? CUSTOM_MASK_TANGENT_SIZE
- : CUSTOM_MASK_ANCHOR_SIZE
- }
- isSelected={handle.isSelected}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- if (handle.kind === "corner") {
- return (
- <CornerHandle
- key={handle.id}
- screen={screen}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
-
- return (
- <CornerHandle
- key={handle.id}
- cursor={handle.cursor}
- screen={screen}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
deleted file mode 100644
index 6ee4b584..00000000
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { TransformHandles } from "./transform-handles";
-import { MaskHandles } from "./mask-handles";
-import { SnapGuides } from "./snap-guides";
-import { TextEditOverlay } from "./text-edit-overlay";
-import { usePropertiesStore } from "../properties/stores/properties-store";
-import { useEditor } from "@/hooks/use-editor";
-
-export function PreviewInteractionOverlay() {
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
-
- const selectedRef =
- selectedElements.length === 1 ? selectedElements[0] : null;
- const activeTrack = selectedRef
- ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
- : null;
- const activeElement =
- activeTrack?.elements.find(
- (element) => element.id === selectedRef?.elementId,
- ) ?? null;
- const isMaskMode = activeElement
- ? activeTabPerType[activeElement.type] === "masks"
- : false;
-
- const {
- onPointerDown,
- onPointerMove,
- onPointerUp,
- onDoubleClick,
- editingText,
- commitTextEdit,
- } = usePreviewInteraction({
- onSnapLinesChange: setSnapLines,
- isMaskMode,
- });
-
- const handlePointerDown = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerDown({ event })) {
- return;
- }
-
- onPointerDown(event);
- };
-
- const handlePointerMove = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerMove({ event })) {
- return;
- }
-
- onPointerMove(event);
- };
-
- const handlePointerUp = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerUp({ event })) {
- return;
- }
-
- onPointerUp(event);
- };
-
- return (
- <div className="absolute inset-0">
- <div
- className="absolute inset-0 pointer-events-auto"
- role="application"
- aria-label="Preview canvas"
- style={{
- cursor: viewport.isPanning
- ? "grabbing"
- : viewport.canPan
- ? "default"
- : undefined,
- }}
- onPointerDown={handlePointerDown}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- onDoubleClick={onDoubleClick}
- onDragStart={(e) => e.preventDefault()}
- />
- {editingText ? (
- <TextEditOverlay
- trackId={editingText.trackId}
- elementId={editingText.elementId}
- element={editingText.element}
- onCommit={commitTextEdit}
- />
- ) : isMaskMode ? (
- <MaskHandles onSnapLinesChange={setSnapLines} />
- ) : (
- <TransformHandles onSnapLinesChange={setSnapLines} />
- )}
- <SnapGuides lines={snapLines} />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
deleted file mode 100644
index 2ac6bd92..00000000
--- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-"use client";
-
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import {
- canvasToOverlay,
- getDisplayScale,
- positionToOverlay,
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import { PREVIEW_ZOOM } from "@/lib/preview/zoom";
-
-const MIDDLE_MOUSE_BUTTON = 1;
-const IS_AT_FIT_EPSILON = 0.001;
-const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
-
-interface PreviewViewportContextValue {
- canPan: boolean;
- isAtFit: boolean;
- isAtActualSize: boolean;
- isPanning: boolean;
- sceneHeight: number;
- sceneLeft: number;
- sceneTop: number;
- sceneWidth: number;
- zoomPercent: number;
- canvasToOverlay: ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) => { x: number; y: number };
- fitToScreen: () => void;
- getDisplayScale: () => { x: number; y: number };
- handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
- handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
- positionToOverlay: ({
- positionX,
- positionY,
- }: {
- positionX: number;
- positionY: number;
- }) => { x: number; y: number };
- panByScreenDelta: ({
- deltaX,
- deltaY,
- }: {
- deltaX: number;
- deltaY: number;
- }) => void;
- resetPan: () => void;
- scaleZoom: ({ factor }: { factor: number }) => void;
- screenPixelsToLogicalThreshold: ({
- screenPixels,
- }: {
- screenPixels: number;
- }) => { x: number; y: number };
- screenToCanvas: ({
- clientX,
- clientY,
- }: {
- clientX: number;
- clientY: number;
- }) => { x: number; y: number } | null;
- setActualSize: () => void;
- setViewportPercent: ({ percent }: { percent: number }) => void;
- zoomIn: () => void;
- zoomOut: () => void;
-}
-
-const PreviewViewportContext =
- createContext<PreviewViewportContextValue | null>(null);
-
-interface PreviewViewportProviderProps {
- children: React.ReactNode;
- value: PreviewViewportContextValue;
-}
-
-interface PreviewViewportStateOptions {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportRef: React.RefObject<HTMLDivElement | null>;
- viewportWidth: number;
-}
-
-interface PanSession {
- centerX: number;
- centerY: number;
- clientX: number;
- clientY: number;
- pointerId: number;
-}
-
-function getClampedCenterAxis({
- axisSize,
- center,
- scale,
- viewportSize,
-}: {
- axisSize: number;
- center: number;
- scale: number;
- viewportSize: number;
-}): number {
- if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
- return axisSize / 2;
- }
-
- const visibleHalfSpan = viewportSize / (2 * scale);
- if (visibleHalfSpan >= axisSize / 2) {
- return axisSize / 2;
- }
-
- return clamp({
- value: center,
- min: visibleHalfSpan,
- max: axisSize - visibleHalfSpan,
- });
-}
-
-function clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX,
- centerY,
- scale,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- centerX: number;
- centerY: number;
- scale: number;
- viewportHeight: number;
- viewportWidth: number;
-}): { x: number; y: number } {
- return {
- x: getClampedCenterAxis({
- center: centerX,
- axisSize: canvasWidth,
- scale,
- viewportSize: viewportWidth,
- }),
- y: getClampedCenterAxis({
- center: centerY,
- axisSize: canvasHeight,
- scale,
- viewportSize: viewportHeight,
- }),
- };
-}
-
-function getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
-}: {
- canvasHeight: number;
- canvasWidth: number;
- viewportHeight: number;
- viewportWidth: number;
-}): number {
- if (
- canvasHeight <= 0 ||
- canvasWidth <= 0 ||
- viewportHeight <= 0 ||
- viewportWidth <= 0
- ) {
- return 1;
- }
-
- return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
-}
-
-function getClampedZoom({ zoom }: { zoom: number }): number {
- return clamp({
- value: zoom,
- min: PREVIEW_ZOOM.min,
- max: PREVIEW_ZOOM.max,
- });
-}
-
-export function PreviewViewportProvider({
- children,
- value,
-}: PreviewViewportProviderProps) {
- return (
- <PreviewViewportContext.Provider value={value}>
- {children}
- </PreviewViewportContext.Provider>
- );
-}
-
-export function usePreviewViewportState({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportRef,
- viewportWidth,
-}: PreviewViewportStateOptions): PreviewViewportContextValue {
- const [zoom, setZoomState] = useState(1);
- const [center, setCenter] = useState(() => ({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- }));
- const [isPanning, setIsPanning] = useState(false);
- const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
-
- const fitScale = useMemo(
- () =>
- getFitScale({
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportWidth,
- }),
- [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
- );
- const viewportScale = fitScale * zoom;
- const geometry = useMemo(
- () => ({
- canvasHeight,
- canvasWidth,
- centerX: center.x,
- centerY: center.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- [
- canvasHeight,
- canvasWidth,
- center.x,
- center.y,
- viewportHeight,
- viewportScale,
- viewportWidth,
- ],
- );
-
- const scaleZoom = useCallback(({ factor }: { factor: number }) => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * factor,
- }),
- );
- }, []);
-
- const panByScreenDelta = useCallback(
- ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
- if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
- return;
- }
-
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x + deltaX / viewportScale,
- centerY: previousCenter.y + deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- },
- [
- canvasHeight,
- canvasWidth,
- viewportHeight,
- viewportScale,
- viewportWidth,
- zoom,
- ],
- );
-
- const resetPan = useCallback(() => {
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const fitToScreen = useCallback(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- }, [canvasHeight, canvasWidth]);
-
- const zoomIn = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom * PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const zoomOut = useCallback(() => {
- setZoomState((previousZoom) =>
- getClampedZoom({
- zoom: previousZoom / PREVIEW_ZOOM.step,
- }),
- );
- }, []);
-
- const setActualSize = useCallback(() => {
- const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
- setZoomState(
- getClampedZoom({
- zoom: actualSizeZoom,
- }),
- );
- }, [fitScale]);
-
- const setViewportPercent = useCallback(
- ({ percent }: { percent: number }) => {
- const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
- setZoomState(getClampedZoom({ zoom: targetZoom }));
- },
- [fitScale],
- );
-
- const getViewportRect = useCallback(
- () => viewportRef.current?.getBoundingClientRect() ?? null,
- [viewportRef],
- );
-
- const screenToCanvasWithViewport = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- const viewportRect = getViewportRect();
- if (!viewportRect) return null;
-
- return screenToCanvas({
- clientX,
- clientY,
- geometry,
- viewportRect,
- });
- },
- [getViewportRect, geometry],
- );
-
- const canvasToOverlayWithViewport = useCallback(
- ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
- canvasToOverlay({
- canvasX,
- canvasY,
- geometry,
- }),
- [geometry],
- );
-
- const positionToOverlayWithViewport = useCallback(
- ({ positionX, positionY }: { positionX: number; positionY: number }) =>
- positionToOverlay({
- geometry,
- positionX,
- positionY,
- }),
- [geometry],
- );
-
- const getDisplayScaleWithViewport = useCallback(
- () => getDisplayScale({ geometry }),
- [geometry],
- );
-
- const getLogicalThresholdForScreenPixels = useCallback(
- ({ screenPixels }: { screenPixels: number }) =>
- screenPixelsToLogicalThreshold({
- geometry,
- screenPixels,
- }),
- [geometry],
- );
-
- const handlePanPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- panSessionRef.current = {
- centerX: centerRef.current.x,
- centerY: centerRef.current.y,
- clientX: event.clientX,
- clientY: event.clientY,
- pointerId: event.pointerId,
- };
- setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
- return true;
- },
- [zoom],
- );
-
- const handlePanPointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- const deltaX = event.clientX - panSession.clientX;
- const deltaY = event.clientY - panSession.clientY;
- const nextCenter = clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: panSession.centerX - deltaX / viewportScale,
- centerY: panSession.centerY - deltaY / viewportScale,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- });
-
- setCenter(nextCenter);
- return true;
- },
- [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
- );
-
- const handlePanPointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const panSession = panSessionRef.current;
- if (!panSession) {
- return false;
- }
-
- if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
- ) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
- }
-
- panSessionRef.current = null;
- setIsPanning(false);
- return true;
- },
- [],
- );
-
- useEffect(() => {
- setZoomState(1);
- setCenter({
- x: canvasWidth / 2,
- y: canvasHeight / 2,
- });
- panSessionRef.current = null;
- setIsPanning(false);
- }, [canvasHeight, canvasWidth]);
-
- useEffect(() => {
- setCenter((previousCenter) =>
- clampViewportCenter({
- canvasHeight,
- canvasWidth,
- centerX: previousCenter.x,
- centerY: previousCenter.y,
- scale: viewportScale,
- viewportHeight,
- viewportWidth,
- }),
- );
- }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
-
- const sceneWidth = canvasWidth * viewportScale;
- const sceneHeight = canvasHeight * viewportScale;
- const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
- const sceneTop = viewportHeight / 2 - center.y * viewportScale;
- const canPan = zoom > 1;
- const zoomPercent = Math.round(viewportScale * 100);
-
- return useMemo(
- () => ({
- canPan,
- isAtActualSize: isNearlyEqual({
- leftValue: viewportScale,
- rightValue: 1,
- epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
- }),
- isAtFit: isNearlyEqual({
- leftValue: zoom,
- rightValue: 1,
- epsilon: IS_AT_FIT_EPSILON,
- }),
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlay: canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScale: getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlay: positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
- screenToCanvas: screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- }),
- [
- canPan,
- viewportScale,
- zoom,
- isPanning,
- sceneHeight,
- sceneLeft,
- sceneTop,
- sceneWidth,
- zoomPercent,
- canvasToOverlayWithViewport,
- fitToScreen,
- getDisplayScaleWithViewport,
- handlePanPointerDown,
- handlePanPointerMove,
- handlePanPointerUp,
- panByScreenDelta,
- positionToOverlayWithViewport,
- resetPan,
- scaleZoom,
- getLogicalThresholdForScreenPixels,
- screenToCanvasWithViewport,
- setActualSize,
- setViewportPercent,
- zoomIn,
- zoomOut,
- ],
- );
-}
-
-export function usePreviewViewport() {
- const context = useContext(PreviewViewportContext);
- if (!context) {
- throw new Error(
- "usePreviewViewport must be used within PreviewViewportProvider",
- );
- }
-
- return context;
-}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
deleted file mode 100644
index ced5e921..00000000
--- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-
-export function SnapGuides({ lines }: { lines: SnapLine[] }) {
- const viewport = usePreviewViewport();
-
- if (lines.length === 0) {
- return null;
- }
-
- const toOverlayX = (logicalX: number) =>
- viewport.positionToOverlay({
- positionX: logicalX,
- positionY: 0,
- }).x;
-
- const toOverlayY = (logicalY: number) =>
- viewport.positionToOverlay({
- positionX: 0,
- positionY: logicalY,
- }).y;
-
- return (
- <div className="pointer-events-none absolute inset-0" aria-hidden>
- {lines.map((line) => {
- if (line.type === "vertical") {
- return (
- <div
- key={`vertical-${line.position}`}
- className="absolute top-0 bottom-0 w-px bg-white/70"
- style={{ left: toOverlayX(line.position) }}
- />
- );
- }
- return (
- <div
- key={`horizontal-${line.position}`}
- className="absolute left-0 right-0 h-px bg-white/70"
- style={{ top: toOverlayY(line.position) }}
- />
- );
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
deleted file mode 100644
index eabbde8a..00000000
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/lib/timeline";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-import { resolveTextLayout } from "@/lib/text/primitives";
-
-export function TextEditOverlay({
- trackId,
- elementId,
- element,
- onCommit,
-}: {
- trackId: string;
- elementId: string;
- element: TextElement;
- onCommit: () => void;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const divRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- const div = divRef.current;
- if (!div) return;
- div.focus();
- const range = document.createRange();
- range.selectNodeContents(div);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }, []);
-
- const handleInput = useCallback(() => {
- const div = divRef.current;
- if (!div) return;
- const text = div.innerText;
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: { content: text } }],
- });
- }, [editor.timeline, trackId, elementId]);
-
- const handleKeyDown = useCallback(
- ({ event }: { event: React.KeyboardEvent }) => {
- const { key } = event;
- if (key === "Escape") {
- event.preventDefault();
- onCommit();
- return;
- }
- },
- [onCommit],
- );
-
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- if (!canvasSize) return null;
-
- const currentTime = editor.playback.getCurrentTime();
- const localTime = getElementLocalTime({
- timelineTime: currentTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const { x: posX, y: posY } = viewport.positionToOverlay({
- positionX: transform.position.x,
- positionY: transform.position.y,
- });
-
- const { x: displayScaleX } = viewport.getDisplayScale();
- const resolvedTextLayout = resolveTextLayout({
- text: {
- content: element.content,
- fontSize: element.fontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight,
- fontStyle: element.fontStyle,
- textAlign: element.textAlign,
- textDecoration: element.textDecoration,
- letterSpacing: element.letterSpacing,
- lineHeight: element.lineHeight,
- },
- canvasHeight: canvasSize.height,
- });
-
- const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
- const canvasLetterSpacing = element.letterSpacing ?? 0;
- const lineHeightPx = resolvedTextLayout.lineHeightPx;
-
- const bg = element.background;
- const shouldShowBackground =
- bg.enabled && bg.color && bg.color !== "transparent";
- const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
- const canvasPaddingX = shouldShowBackground
- ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
- : 0;
- const canvasPaddingY = shouldShowBackground
- ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
- : 0;
-
- return (
- <div
- className="absolute"
- style={{
- left: posX,
- top: posY,
- transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`,
- transformOrigin: "center center",
- }}
- >
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
- <div
- ref={divRef}
- contentEditable
- suppressContentEditableWarning
- tabIndex={0}
- role="textbox"
- aria-label="Edit text"
- className="cursor-text select-text outline-none whitespace-pre"
- style={{
- fontSize: resolvedTextLayout.scaledFontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
- fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
- textAlign: element.textAlign,
- letterSpacing: `${canvasLetterSpacing}px`,
- lineHeight,
- color: "transparent",
- caretColor: element.color,
- backgroundColor: shouldShowBackground ? bg.color : "transparent",
- minHeight: lineHeightPx,
- textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground
- ? `${canvasPaddingY}px ${canvasPaddingX}px`
- : 0,
- minWidth: 1,
- }}
- onInput={handleInput}
- onBlur={onCommit}
- onKeyDown={(event) => handleKeyDown({ event })}
- >
- {element.content || ""}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
deleted file mode 100644
index 9ee6c822..00000000
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { formatTimecode } from "opencut-wasm";
-import { invokeAction } from "@/lib/actions";
-import { EditableTimecode } from "@/components/editable-timecode";
-import { Button } from "@/components/ui/button";
-import {
- FullScreenIcon,
- GridTableIcon,
- PauseIcon,
- PlayIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { getGuideById } from "@/lib/guides";
-import { Separator } from "@/components/ui/separator";
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectItem,
- SelectSeparator,
-} from "@/components/ui/select";
-import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/zoom";
-import { usePreviewViewport } from "./preview-viewport";
-import { GridPopover } from "./guide-popover";
-import { usePreviewStore } from "@/stores/preview-store";
-
-export function PreviewToolbar({
- onToggleFullscreen,
-}: {
- onToggleFullscreen: () => void;
-}) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const activeGuideDefinition = getGuideById(activeGuide);
-
- return (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <TimecodeDisplay />
- <PlayPauseButton />
- <div className="justify-self-end flex items-center gap-2.5">
- <ZoomSelect />
- <Separator orientation="vertical" className="h-4" />
- {/* v0.4.0 */}
- {/* <GridPopover>
- <Button
- variant={activeGuideDefinition ? "secondary" : "text"}
- size="icon"
- >
- {activeGuideDefinition ? (
- activeGuideDefinition.renderTriggerIcon()
- ) : (
- <HugeiconsIcon icon={GridTableIcon} />
- )}
- </Button>
- </GridPopover> */}
- <Button variant="text" onClick={onToggleFullscreen}>
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function TimecodeDisplay() {
- const editor = useEditor();
- const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
- const fps = useEditor((e) => e.project.getActive().settings.fps);
- const [currentTime, setCurrentTime] = useState(() =>
- editor.playback.getCurrentTime(),
- );
-
- useEffect(() => {
- const handler = (e: Event) =>
- setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
- window.addEventListener("playback-update", handler);
- window.addEventListener("playback-seek", handler);
- return () => {
- window.removeEventListener("playback-update", handler);
- window.removeEventListener("playback-seek", handler);
- };
- }, []);
-
- return (
- <div className="flex items-center">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
- </span>
- </div>
- );
-}
-
-function ZoomSelect() {
- const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
- usePreviewViewport();
-
- const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
-
- const onValueChange = (value: string) => {
- if (value === "fit") {
- fitToScreen();
- } else {
- setViewportPercent({ percent: Number(value) });
- }
- };
-
- return (
- <Select
- value={isAtFit ? "fit" : String(zoomPercent)}
- onValueChange={onValueChange}
- >
- <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
- <SelectContent>
- <SelectItem value="fit">Fit</SelectItem>
- <SelectSeparator />
- {PREVIEW_ZOOM_PRESETS.map((preset) => (
- <SelectItem key={preset} value={String(preset)}>
- {preset}%
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
-
-function PlayPauseButton() {
- const isPlaying = useEditor((e) => e.playback.getIsPlaying());
-
- return (
- <Button
- variant="text"
- size="icon"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
deleted file mode 100644
index 2b81b557..00000000
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getCornerPosition,
- getEdgeHandlePosition,
- ROTATION_HANDLE_OFFSET,
- type Corner,
- type Edge,
-} from "@/lib/preview/element-bounds";
-import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
-import {
- BoundingBoxOutline,
- CornerHandle,
- EdgeHandle,
- IconHandle,
- getResizeCursor,
-} from "./handle-primitives";
-import { Rotate01Icon } from "@hugeicons/core-free-icons";
-
-const CORNERS: Corner[] = [
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-];
-const EDGES: Edge[] = ["right", "left", "bottom"];
-
-export function TransformHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
-}) {
- const viewport = usePreviewViewport();
- const {
- selectedWithBounds,
- hasVisualSelection,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
- } = useTransformHandles({ onSnapLinesChange });
-
- if (!hasVisualSelection || !selectedWithBounds) return null;
-
- const { bounds, element } = selectedWithBounds;
- if (!isVisualElement(element)) return null;
-
- const displayScale = viewport.getDisplayScale();
-
- const toOverlay = ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) =>
- viewport.canvasToOverlay({
- canvasX,
- canvasY,
- });
-
- const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = Math.abs(bounds.width) * displayScale.x;
- const outlineHeight = Math.abs(bounds.height) * displayScale.y;
-
- const rotationAngleRad = (bounds.rotation * Math.PI) / 180;
- const topCenterLocalY = -bounds.height / 2;
- const topCenterScreen = toOverlay({
- canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad),
- canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad),
- });
- const rotationHandleScreen = {
- x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
- y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
- };
-
- const onPointerMove = (event: React.PointerEvent) =>
- handlePointerMove({ event });
- const onPointerUp = () => handlePointerUp();
-
- return (
- <div
- className="pointer-events-none absolute inset-0 overflow-hidden"
- aria-hidden
- >
- <BoundingBoxOutline
- center={center}
- outlineWidth={outlineWidth}
- outlineHeight={outlineHeight}
- rotation={bounds.rotation}
- />
- {CORNERS.map((corner) => {
- const cornerPosition = getCornerPosition({ bounds, corner });
- const screen = toOverlay({
- canvasX: cornerPosition.x,
- canvasY: cornerPosition.y,
- });
- const angleDeg =
- Math.atan2(screen.y - center.y, screen.x - center.x) *
- (180 / Math.PI);
- return (
- <CornerHandle
- key={corner}
- cursor={getResizeCursor({ angleDeg })}
- screen={screen}
- onPointerDown={(event) =>
- handleCornerPointerDown({ event, corner })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- {EDGES.map((edge) => {
- const edgePosition = getEdgeHandlePosition({ bounds, edge });
- const screen = toOverlay({
- canvasX: edgePosition.x,
- canvasY: edgePosition.y,
- });
- return (
- <EdgeHandle
- key={edge}
- edge={edge}
- screen={screen}
- rotation={bounds.rotation}
- onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- <IconHandle
- icon={Rotate01Icon}
- screen={rotationHandleScreen}
- onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
index 9cc2b02e..c9a9c22e 100644
--- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
+import type { ParamDefinition, NumberParamDefinition } from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
index 27300784..967156b3 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -1,5 +1,5 @@
-import { useEditor } from "@/hooks/use-editor";
-import { getElementLocalTime } from "@/lib/animation";
+import { useEditor } from "@/editor/use-editor";
+import { getElementLocalTime } from "@/animation";
export function useElementPlayhead({
startTime,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index a42e422f..734b514f 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -1,11 +1,11 @@
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
-} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
export function useKeyframedColorProperty({
trackId,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index 872d55c0..9dd39768 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -1,11 +1,11 @@
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
-} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index 29b290d9..b0b6f01a 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -1,6 +1,6 @@
"use client";
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
@@ -9,12 +9,12 @@ import {
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
-} from "@/lib/animation";
+} from "@/animation";
import type {
ElementAnimations,
-} from "@/lib/animation/types";
-import type { ParamDefinition } from "@/lib/params";
-import type { TimelineElement } from "@/lib/timeline";
+} from "@/animation/types";
+import type { ParamDefinition } from "@/params";
+import type { TimelineElement } from "@/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 75319ef3..52fa42b5 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -8,8 +8,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useEditor } from "@/editor/use-editor";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { usePropertiesStore } from "./stores/properties-store";
import { getPropertiesConfig } from "./registry";
import { cn } from "@/utils/ui";
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
index df6c0e52..f9e40ab3 100644
--- a/apps/web/src/components/editor/panels/properties/registry.tsx
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -11,8 +11,8 @@ import type {
VideoElement,
AudioElement,
TimelineElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
+} from "@/timeline";
+import type { MediaAsset } from "@/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
@@ -22,14 +22,14 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
-import { TransformTab } from "./tabs/transform-tab";
-import { BlendingTab } from "./tabs/blending-tab";
-import { AudioTab } from "./tabs/audio-tab";
-import { TextTab } from "./tabs/text-tab";
-import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
-import { MasksTab } from "./tabs/masks-tab";
-import { SpeedTab } from "./tabs/speed-tab";
-import { GraphicTab } from "./tabs/graphic-tab";
+import { TransformTab } from "@/rendering/components/transform-tab";
+import { BlendingTab } from "@/rendering/components/blending-tab";
+import { AudioTab } from "@/timeline/components/audio-tab";
+import { TextTab } from "@/text/components/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
+import { MasksTab } from "@/masks/components/masks-tab";
+import { SpeedTab } from "@/speed/components/speed-tab";
+import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = {
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
deleted file mode 100644
index 5ac0fbb7..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
deleted file mode 100644
index f8e5397d..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@/components/icons";
-import { Fragment, useRef } from "react";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/lib/rendering";
-import type { ElementType } from "@/lib/timeline";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { isPropertyAtDefault } from "./transform-tab";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
- [{ value: "normal", label: "Normal" }],
- [
- { value: "darken", label: "Darken" },
- { value: "multiply", label: "Multiply" },
- { value: "color-burn", label: "Color Burn" },
- ],
- [
- { value: "lighten", label: "Lighten" },
- { value: "screen", label: "Screen" },
- { value: "plus-lighter", label: "Plus Lighter" },
- { value: "color-dodge", label: "Color Dodge" },
- ],
- [
- { value: "overlay", label: "Overlay" },
- { value: "soft-light", label: "Soft Light" },
- { value: "hard-light", label: "Hard Light" },
- ],
- [
- { value: "difference", label: "Difference" },
- { value: "exclusion", label: "Exclusion" },
- ],
- [
- { value: "hue", label: "Hue" },
- { value: "saturation", label: "Saturation" },
- { value: "color", label: "Color" },
- { value: "luminosity", label: "Luminosity" },
- ],
-];
-
-export function BlendingTab({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
- const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
- const committedBlendModeRef = useRef(blendMode);
- if (!isPreviewActive) {
- committedBlendModeRef.current = blendMode;
- }
-
- const {
- onPointerLeave,
- onOpenChange: handleBlendModeOpenChange,
- markCommitted,
- } = useMenuPreview();
-
- const previewBlendMode = ({ value }: { value: BlendMode }) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { blendMode: value } },
- ],
- });
-
- const commitBlendMode = (value: BlendMode) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { blendMode: value },
- },
- ],
- });
- }
- markCommitted();
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:blending`}>
- <SectionHeader>
- <SectionTitle>Blending</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() =>
- opacity.commitValue({ value: DEFAULTS.element.opacity })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULTS.element.opacity,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
deleted file mode 100644
index 1b94ee2d..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
+++ /dev/null
@@ -1,309 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { ParamValues } from "@/lib/params";
-import type { Effect } from "@/lib/effects/types";
-import type { EffectElement, VisualElement } from "@/lib/timeline";
-import { effectsRegistry } from "@/lib/effects";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
- SectionFields,
-} from "@/components/section";
-import { PropertyParamField } from "../components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Delete02Icon,
- ViewIcon,
- ViewOffSlashIcon,
- MagicWand05Icon,
-} from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import { Separator } from "@/components/ui/separator";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-
-export function StandaloneEffectTab({
- element,
- trackId,
-}: {
- element: EffectElement;
- trackId: string;
-}) {
- const { renderElement, previewUpdates, commit } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const effect: Effect = {
- id: element.id,
- type: element.effectType,
- params: element.params,
- enabled: true,
- };
-
- const previewParam = (key: string) => (value: number | string | boolean) => {
- previewUpdates({
- params: { ...(renderElement as EffectElement).params, [key]: value },
- });
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
- <SectionTitle>Effect</SectionTitle>
- </div>
- <EffectSection
- effect={effect}
- renderParams={(renderElement as EffectElement).params}
- previewParam={previewParam}
- onCommit={commit}
- />
- </div>
- );
-}
-
-export function ClipEffectsTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const [dragIndex, setDragIndex] = useState<number | null>(null);
- const [dropIndex, setDropIndex] = useState<number | null>(null);
- const editor = useEditor();
- const { renderElement, previewUpdates, commit } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const effects: Effect[] = element.effects ?? [];
-
- const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
- return (
- (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
- ?.params ??
- effects.find((ef) => ef.id === effectId)?.params ??
- {}
- );
- };
-
- const buildPreviewParam =
- (effectId: string) =>
- (key: string) =>
- (value: number | string | boolean) => {
- const updatedEffects = (
- (renderElement as VisualElement).effects ?? []
- ).map((existing) =>
- existing.id !== effectId
- ? existing
- : { ...existing, params: { ...existing.params, [key]: value } },
- );
- previewUpdates({ effects: updatedEffects });
- };
-
- const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
-
- const handleDragOver = ({
- event,
- index,
- }: {
- event: React.DragEvent;
- index: number;
- }) => {
- event.preventDefault();
- if (index !== dropIndex) setDropIndex(index);
- };
-
- const handleDrop = ({ toIndex }: { toIndex: number }) => {
- if (dragIndex !== null && dragIndex !== toIndex) {
- editor.timeline.reorderClipEffects({
- trackId,
- elementId: element.id,
- fromIndex: dragIndex,
- toIndex,
- });
- }
- setDragIndex(null);
- setDropIndex(null);
- };
-
- const handleDragEnd = () => {
- setDragIndex(null);
- setDropIndex(null);
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
- <SectionTitle>Effects</SectionTitle>
- </div>
- {effects.length === 0 ? (
- <EmptyView />
- ) : (
- <ul className="flex flex-col">
- {effects.map((effect, index) => {
- const resolvedDragIndex = dragIndex ?? -1;
- const isDragging = dragIndex === index;
- const isDropTarget =
- dropIndex === index && dragIndex !== null && dragIndex !== index;
- const showTopDropIndicator =
- isDropTarget && index < resolvedDragIndex;
- const showBottomDropIndicator =
- isDropTarget && index > resolvedDragIndex;
-
- return (
- <li
- key={effect.id}
- draggable
- onDragStart={() => handleDragStart({ index })}
- onDragOver={(event) => handleDragOver({ event, index })}
- onDrop={() => handleDrop({ toIndex: index })}
- onDragEnd={handleDragEnd}
- className={cn(
- "group list-none",
- isDragging && "opacity-40",
- showTopDropIndicator && "border-t-2 border-primary",
- showBottomDropIndicator && "border-b-2 border-primary",
- )}
- >
- <EffectSection
- effect={effect}
- renderParams={getRenderParams({ effectId: effect.id })}
- previewParam={buildPreviewParam(effect.id)}
- onCommit={commit}
- onToggle={() =>
- editor.timeline.toggleClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- })
- }
- onRemove={() =>
- editor.timeline.removeClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- })
- }
- />
- </li>
- );
- })}
- </ul>
- )}
- </div>
- );
-}
-
-function EmptyView() {
- const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
-
- return (
- <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-10 text-muted-foreground"
- strokeWidth={1}
- />
- <div className="flex flex-col gap-2">
- <h3 className="font-medium text-foreground">No effects</h3>
- <p className="text-muted-foreground text-sm text-balance max-w-44">
- Add effects to this layer from the Assets panel.
- </p>
- </div>
- <Button
- variant="default"
- size="sm"
- onClick={() => setActiveTab("effects")}
- >
- Open effects
- </Button>
- </div>
- );
-}
-
-function EffectSection({
- effect,
- renderParams,
- previewParam,
- onCommit,
- onToggle,
- onRemove,
-}: {
- effect: Effect;
- renderParams: ParamValues;
- previewParam: (key: string) => (value: number | string | boolean) => void;
- onCommit: () => void;
- onToggle?: () => void;
- onRemove?: () => void;
-}) {
- const definition = effectsRegistry.get(effect.type);
-
- return (
- <Section
- sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
- showTopBorder={false}
- >
- <SectionHeader
- className={cn(onToggle && "cursor-move")}
- trailing={
- onToggle && (
- <div className="flex items-center gap-1">
- <Button
- variant={effect.enabled ? "secondary" : "ghost"}
- size="icon"
- aria-label={`Toggle ${definition.name}`}
- onClick={onToggle}
- >
- <HugeiconsIcon
- icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name}`}
- onClick={onRemove}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- )
- }
- >
- <SectionTitle
- className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
- >
- {definition.name}
- </SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
- >
- <SectionFields>
- {definition.params.map((param) => (
- <div key={param.key} className="flex flex-col gap-3.5">
- <div className="px-4">
- <PropertyParamField
- param={param}
- value={renderParams[param.key] ?? param.default}
- onPreview={previewParam(param.key)}
- onCommit={onCommit}
- />
- </div>
- <Separator />
- </div>
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
deleted file mode 100644
index c61c9cd7..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "../hooks/use-keyframed-param-property";
-import { resolveGraphicParamsAtTime } from "@/lib/animation";
-import type { ParamDefinition, ParamValues } from "@/lib/params";
-import type { GraphicElement } from "@/lib/timeline";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "../components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && (
- <StrokeSection element={element} trackId={trackId} />
- )}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- });
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
deleted file mode 100644
index 090fce64..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ /dev/null
@@ -1,826 +0,0 @@
-"use client";
-
-import type { MaskableElement } from "@/lib/timeline";
-import type { Mask, MaskType, TextMask } from "@/lib/masks/types";
-import type {
- NumberParamDefinition,
- SelectParamDefinition,
-} from "@/lib/params";
-import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Delete02Icon,
- FeatherIcon,
- PlusSignIcon,
- RotateClockwiseIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { useMemo, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { FontPicker } from "@/components/ui/font-picker";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { NumberField } from "@/components/ui/number-field";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Textarea } from "@/components/ui/textarea";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- snapToStep,
-} from "@/utils/math";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import {
- OcMirrorIcon,
- OcShapesIcon,
- OcTextHeightIcon,
- OcTextWidthIcon,
-} from "@/components/icons";
-import { cn } from "@/utils/ui";
-
-type MasksTabProps = {
- element: MaskableElement;
- trackId: string;
-};
-
-type MaskItemProps = {
- trackId: string;
- elementId: string;
- mask: Mask;
- previewParam: (key: string) => (value: number | string | boolean) => void;
- onCommit: () => void;
-};
-
-type EmptyViewProps = {
- onAddMask: () => void;
-};
-
-type PreviewParamHandler = (
- key: string,
-) => (value: number | string | boolean) => void;
-
-type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
-
-function isTextMask(mask: Mask): mask is TextMask {
- return mask.type === "text";
-}
-
-export function MasksTab({ element, trackId }: MasksTabProps) {
- const editor = useEditor();
- const { renderElement, previewUpdates, commit } =
- useElementPreview<MaskableElement>({
- trackId,
- elementId: element.id,
- fallback: element,
- });
- const maskDefs = masksRegistry.getAll();
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.project.getActive().settings.canvasSize,
- );
- const masks = element.masks ?? [];
- const renderMasks = renderElement.masks ?? masks;
- const hasMask = masks.length > 0;
- const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const elementBounds = useMemo(() => {
- const clampedTime = Math.min(
- Math.max(currentTime, element.startTime),
- element.startTime + element.duration - 1,
- );
-
- return (
- getVisibleElementsWithBounds({
- tracks,
- currentTime: clampedTime,
- canvasSize,
- mediaAssets,
- }).find(
- (item) => item.trackId === trackId && item.elementId === element.id,
- )?.bounds ?? null
- );
- }, [
- canvasSize,
- currentTime,
- element.duration,
- element.id,
- element.startTime,
- mediaAssets,
- trackId,
- tracks,
- ]);
-
- const handleDropdownOpenChange = (open: boolean) => {
- if (hasMask && open) return;
- setIsDropdownOpen(open);
- onOpenChange(open);
- };
-
- const previewMask = ({ maskType }: { maskType: MaskType }) => {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- masks: [
- buildDefaultMaskInstance({
- maskType,
- elementSize: elementBounds
- ? {
- width: elementBounds.width,
- height: elementBounds.height,
- }
- : undefined,
- }),
- ],
- } as Partial<MaskableElement>,
- },
- ],
- });
- };
-
- const commitMask = ({ maskType }: { maskType: MaskType }) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- masks: [
- buildDefaultMaskInstance({
- maskType,
- elementSize: elementBounds
- ? {
- width: elementBounds.width,
- height: elementBounds.height,
- }
- : undefined,
- }),
- ],
- } as Partial<MaskableElement>,
- },
- ],
- });
- }
- markCommitted();
- setIsDropdownOpen(false);
- };
-
- const previewMaskParam =
- ({ index, key }: { index: number; key: string }) =>
- (value: number | string | boolean) => {
- if (!renderMasks[index]) {
- return;
- }
-
- const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
- maskIndex !== index
- ? existingMask
- : {
- ...existingMask,
- params: {
- ...existingMask.params,
- [key]: value,
- },
- },
- );
-
- previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
- };
-
- return (
- <div className="flex flex-col h-full">
- <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
- <SectionTitle>Masks</SectionTitle>
- <DropdownMenu
- open={hasMask ? false : isDropdownOpen}
- onOpenChange={handleDropdownOpenChange}
- >
- {hasMask ? (
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="inline-flex">
- <Button
- variant="ghost"
- size="icon"
- disabled
- aria-label="Add mask"
- >
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </span>
- </TooltipTrigger>
- <TooltipContent className="max-w-56 text-balance">
- Only one mask is supported right now. If you need more,
- duplicate the clip and apply a different mask to each copy.
- </TooltipContent>
- </Tooltip>
- ) : (
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" aria-label="Add mask">
- <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
- </Button>
- </DropdownMenuTrigger>
- )}
- <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
- {maskDefs.map((definition) => (
- <DropdownMenuItem
- key={definition.type}
- onPointerEnter={() =>
- previewMask({ maskType: definition.type })
- }
- onClick={() => commitMask({ maskType: definition.type })}
- >
- <HugeiconsIcon {...definition.icon} />
- {definition.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- {masks.length === 0 ? (
- <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
- ) : (
- masks.map((mask, index) => (
- <MaskItem
- key={mask.id}
- trackId={trackId}
- elementId={element.id}
- mask={renderMasks[index] ?? mask}
- previewParam={(paramKey) =>
- previewMaskParam({ index, key: paramKey })
- }
- onCommit={commit}
- />
- ))
- )}
- </div>
- );
-}
-
-function MaskItem({
- trackId,
- elementId,
- mask,
- previewParam,
- onCommit,
-}: MaskItemProps) {
- const editor = useEditor();
- const definition = masksRegistry.get(mask.type);
-
- return (
- <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
- <SectionHeader
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Toggle ${definition.name} mask inversion`}
- onClick={() =>
- editor.timeline.toggleMaskInverted({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <OcMirrorIcon
- className={cn(mask.params.inverted && "-scale-x-100")}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name} mask`}
- onClick={() =>
- editor.timeline.removeMask({
- trackId,
- elementId,
- maskId: mask.id,
- })
- }
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <div className="flex items-center gap-2">
- <HugeiconsIcon {...definition.icon} size={14} />
- <SectionTitle className="capitalize font-normal">
- {definition.name}
- </SectionTitle>
- </div>
- </SectionHeader>
- <SectionContent>
- <MaskParamsFields
- mask={mask}
- definition={definition}
- previewParam={previewParam}
- onCommit={onCommit}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function MaskParamsFields({
- mask,
- definition,
- previewParam,
- onCommit,
-}: {
- mask: Mask;
- definition: RegisteredMaskDefinition;
- previewParam: PreviewParamHandler;
- onCommit: () => void;
-}) {
- const featherParam = getNumberParamDefinition({
- definition,
- key: "feather",
- });
- const strokeWidthParam = getNumberParamDefinition({
- definition,
- key: "strokeWidth",
- });
- const previewNumberParam = (key: string) => (value: number) =>
- previewParam(key)(value);
- const previewStrokeColor = previewParam("strokeColor");
- const strokeAlignParam = definition.params.find(
- (param): param is SelectParamDefinition<string> =>
- param.key === "strokeAlign" && param.type === "select",
- );
-
- return (
- <SectionFields>
- {isTextMask(mask) ? (
- <TextMaskFields
- mask={mask}
- previewParam={previewParam}
- onCommit={onCommit}
- fontSizeParam={getNumberParamDefinition({
- definition,
- key: "fontSize",
- })}
- />
- ) : null}
- {definition.features.hasPosition &&
- "centerX" in mask.params &&
- "centerY" in mask.params && (
- <SectionField label="Position">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="X"
- param={getNumberParamDefinition({
- definition,
- key: "centerX",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerX",
- })}
- onPreview={previewNumberParam("centerX")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="Y"
- param={getNumberParamDefinition({
- definition,
- key: "centerY",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "centerY",
- })}
- onPreview={previewNumberParam("centerY")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-height" &&
- "width" in mask.params &&
- "height" in mask.params && (
- <SectionField label="Size">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="flex-1"
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- )}
-
- {definition.features.sizeMode === "height-only" &&
- "height" in mask.params && (
- <SectionField label="Height">
- <MaskNumberField
- icon="H"
- param={getNumberParamDefinition({
- definition,
- key: "height",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "height",
- })}
- onPreview={previewNumberParam("height")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "width-only" &&
- "width" in mask.params && (
- <SectionField label="Width">
- <MaskNumberField
- icon="W"
- param={getNumberParamDefinition({
- definition,
- key: "width",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "width",
- })}
- onPreview={previewNumberParam("width")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
- <SectionField label="Scale">
- <MaskNumberField
- icon={
- isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S"
- }
- param={getNumberParamDefinition({
- definition,
- key: "scale",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "scale",
- })}
- onPreview={previewNumberParam("scale")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- {definition.features.hasRotation && "rotation" in mask.params && (
- <SectionField label="Rotation">
- <MaskNumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- param={getNumberParamDefinition({
- definition,
- key: "rotation",
- })}
- value={getMaskNumber({
- params: mask.params,
- key: "rotation",
- })}
- onPreview={previewNumberParam("rotation")}
- onCommit={onCommit}
- />
- </SectionField>
- )}
-
- <SectionField label="Feather">
- <MaskNumberField
- icon={<HugeiconsIcon icon={FeatherIcon} />}
- param={featherParam}
- value={getMaskNumber({
- params: mask.params,
- key: "feather",
- })}
- onPreview={previewNumberParam("feather")}
- onCommit={onCommit}
- />
- </SectionField>
-
- <SectionField label="Stroke">
- <div className="flex flex-col gap-2">
- <div className="flex items-center gap-2">
- <MaskNumberField
- className="flex-1"
- icon="W"
- param={strokeWidthParam}
- value={getMaskNumber({
- params: mask.params,
- key: "strokeWidth",
- })}
- onPreview={previewNumberParam("strokeWidth")}
- onCommit={onCommit}
- />
- <ColorPicker
- className=""
- value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
- onChange={(color) => previewStrokeColor(`#${color}`)}
- onChangeEnd={(color) => {
- previewStrokeColor(`#${color}`);
- onCommit();
- }}
- />
- </div>
- {strokeAlignParam ? (
- <Select
- value={mask.params.strokeAlign}
- onValueChange={(value) => {
- previewParam("strokeAlign")(value);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {strokeAlignParam.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : null}
- </div>
- </SectionField>
- </SectionFields>
- );
-}
-
-const LETTER_SPACING_PARAM: NumberParamDefinition = {
- key: "letterSpacing",
- label: "Letter spacing",
- type: "number",
- default: 0,
- min: -100,
- max: 500,
- step: 1,
-};
-
-const LINE_HEIGHT_PARAM: NumberParamDefinition = {
- key: "lineHeight",
- label: "Line height",
- type: "number",
- default: 1.2,
- min: 0.1,
- max: 10,
- step: 0.1,
-};
-
-function TextMaskFields({
- mask,
- previewParam,
- onCommit,
- fontSizeParam,
-}: {
- mask: TextMask;
- previewParam: PreviewParamHandler;
- onCommit: () => void;
- fontSizeParam: NumberParamDefinition;
-}) {
- const content = usePropertyDraft({
- displayValue: mask.params.content,
- parse: (input) => input,
- onPreview: (value) => previewParam("content")(value),
- onCommit,
- });
-
- const previewNumberParam = (key: string) => (value: number) =>
- previewParam(key)(value);
-
- return (
- <>
- <SectionField label="Content">
- <Textarea
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionField>
- <SectionField label="Font">
- <FontPicker
- defaultValue={mask.params.fontFamily}
- onValueChange={(value) => {
- previewParam("fontFamily")(value);
- onCommit();
- }}
- />
- </SectionField>
- <SectionField label="Size">
- <MaskNumberField
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- param={fontSizeParam}
- value={mask.params.fontSize}
- onPreview={previewNumberParam("fontSize")}
- onCommit={onCommit}
- />
- </SectionField>
- <SectionField label="Spacing">
- <div className="flex items-start gap-2">
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextWidthIcon size={14} />}
- param={LETTER_SPACING_PARAM}
- value={mask.params.letterSpacing ?? 0}
- onPreview={previewNumberParam("letterSpacing")}
- onCommit={onCommit}
- />
- <MaskNumberField
- className="w-1/2"
- icon={<OcTextHeightIcon size={14} />}
- param={LINE_HEIGHT_PARAM}
- value={mask.params.lineHeight ?? 1.2}
- onPreview={previewNumberParam("lineHeight")}
- onCommit={onCommit}
- />
- </div>
- </SectionField>
- </>
- );
-}
-
-function getNumberParamDefinition({
- definition,
- key,
-}: {
- definition: RegisteredMaskDefinition;
- key: string;
-}): NumberParamDefinition {
- const param = definition.params.find((candidate) => candidate.key === key);
-
- if (!param || param.type !== "number") {
- throw new Error(`Missing number param definition for mask key "${key}"`);
- }
-
- return param;
-}
-
-function getMaskNumber<
- TParams extends Mask["params"],
- TKey extends keyof TParams & string,
->({ params, key }: { params: TParams; key: TKey }): number {
- const value = params[key];
-
- if (typeof value !== "number") {
- throw new Error(`Expected numeric mask param for "${key}"`);
- }
-
- return value;
-}
-
-function MaskNumberField({
- param,
- value,
- onPreview,
- onCommit,
- icon,
- className,
-}: {
- param: NumberParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
- icon?: React.ReactNode;
- className?: string;
-}) {
- const isPercent = param.unit === "percent";
- const percentMax = param.max ?? 100;
- const displayMultiplier = isPercent
- ? 100 / percentMax
- : (param.displayMultiplier ?? 1);
- const min = isPercent ? 0 : param.min;
- const max = isPercent ? 100 : param.max;
- const step = isPercent ? 1 : param.step;
- const displayValue = value * displayMultiplier;
- const maxFractionDigits = getFractionDigitsForStep({ step });
-
- const clampDisplay = (nextDisplayValue: number) =>
- max !== undefined
- ? clamp({ value: nextDisplayValue, min, max })
- : Math.max(min, nextDisplayValue);
-
- const previewFromDisplay = (nextDisplayValue: number) => {
- onPreview(
- clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
- displayMultiplier,
- );
- };
-
- const draft = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: displayValue,
- maxFractionDigits,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return (
- clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier
- );
- },
- onPreview,
- onCommit,
- });
-
- return (
- <NumberField
- className={className}
- icon={icon}
- value={draft.displayValue}
- dragSensitivity="slow"
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- onScrub={previewFromDisplay}
- onScrubEnd={onCommit}
- />
- );
-}
-
-function EmptyView({ onAddMask }: EmptyViewProps) {
- return (
- <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
- <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
- <div className="flex flex-col gap-2">
- <h3 className="font-medium text-foreground">No masks</h3>
- <p className="text-muted-foreground text-sm text-balance max-w-40">
- Add a mask to hide or reveal parts of this layer.
- </p>
- </div>
- <Button variant="default" size="sm" onClick={onAddMask}>
- Add mask
- </Button>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
deleted file mode 100644
index cb10aac3..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import { useRef } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
-import { buildConstantRetime } from "@/lib/retime";
-import {
- DEFAULT_RETIME_RATE,
- MIN_RETIME_RATE,
- MAX_RETIME_RATE,
- clampRetimeRate,
- canMaintainPitch,
-} from "@/lib/retime/rate";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import {
- formatNumberForDisplay,
- getFractionDigitsForStep,
- snapToStep,
-} from "@/utils/math";
-
-const SPEED_STEP = 0.01;
-const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP });
-
-function rateToDisplay({ rate }: { rate: number }): string {
- return formatNumberForDisplay({
- value: rate,
- fractionDigits: SPEED_FRACTION_DIGITS,
- });
-}
-
-function parseSpeedInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clampRetimeRate({
- rate: snapToStep({ value: parsed, step: SPEED_STEP }),
- });
-}
-
-function buildRetime({
- rate,
- maintainPitch,
-}: {
- rate: number;
- maintainPitch: boolean;
-}) {
- if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
- return buildConstantRetime({ rate, maintainPitch });
-}
-
-export function SpeedTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const rate = clampRetimeRate({
- rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
- });
- const isPitchPreserveAvailable = canMaintainPitch({ rate });
- const maintainPitch = element.retime?.maintainPitch ?? false;
- const pendingRateRef = useRef(rate);
-
- const commitRetime = ({
- rate: nextRate,
- maintainPitch: nextMaintainPitch,
- }: {
- rate: number;
- maintainPitch: boolean;
- }) => {
- editor.timeline.updateElementRetime({
- trackId,
- elementId: element.id,
- retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
- });
- };
-
- const speedDraft = usePropertyDraft({
- displayValue: rateToDisplay({ rate }),
- parse: (input) => parseSpeedInput({ input }),
- onPreview: (nextRate) => {
- pendingRateRef.current = nextRate;
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- retime: buildRetime({ rate: nextRate, maintainPitch }),
- },
- },
- ],
- });
- },
- onCommit: () => {
- commitRetime({ rate: pendingRateRef.current, maintainPitch });
- },
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:speed`}>
- <SectionHeader>
- <SectionTitle>Speed</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Speed">
- <NumberField
- icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
- value={speedDraft.displayValue}
- suffix="x"
- scrubRanges={[
- { from: 0.01, to: 1, pixelsPerUnit: 160 },
- { from: 1, to: 5, pixelsPerUnit: 48 },
- ]}
- scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
- onFocus={() => {
- pendingRateRef.current = rate;
- speedDraft.onFocus();
- }}
- onChange={speedDraft.onChange}
- onBlur={speedDraft.onBlur}
- onScrub={speedDraft.scrubTo}
- onScrubEnd={speedDraft.commitScrub}
- onReset={() =>
- commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
- }
- isDefault={rate === DEFAULT_RETIME_RATE}
- />
- </SectionField>
- <div className="flex items-center justify-between">
- <span className="text-sm">Change pitch</span>
- <Switch
- checked={!maintainPitch}
- disabled={!isPitchPreserveAvailable}
- onCheckedChange={(checked) =>
- commitRetime({ rate, maintainPitch: !checked })
- }
- />
- </div>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
deleted file mode 100644
index d37379ea..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ /dev/null
@@ -1,767 +0,0 @@
-"use client";
-
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/lib/timeline";
-import { NumberField } from "@/components/ui/number-field";
-import { useRef } from "react";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import { useEditor } from "@/hooks/use-editor";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- DEFAULT_TEXT_COLOR,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/lib/text/typography";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { isPropertyAtDefault } from "./transform-tab";
-import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- MinusSignIcon,
- PlusSignIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { cn } from "@/utils/ui";
-
-export function TextTab({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- return (
- <div className="flex flex-col">
- <ContentSection element={element} trackId={trackId} />
- <TypographySection element={element} trackId={trackId} />
- <SpacingSection element={element} trackId={trackId} />
- <BackgroundSection element={element} trackId={trackId} />
- </div>
- );
-}
-
-function ContentSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const content = usePropertyDraft({
- displayValue: element.content,
- parse: (input) => input,
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { content: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:content`}>
- <SectionHeader>
- <SectionTitle>Content</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <Textarea
- placeholder="Name"
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function TypographySection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTextColor = resolveColorAtTime({
- baseColor: element.color,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- });
-
- const textColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedTextColor,
- buildBaseUpdates: ({ value }) => ({ color: value }),
- });
-
- const fontSize = usePropertyDraft({
- displayValue: element.fontSize.toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- });
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { fontSize: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:typography`}>
- <SectionHeader>
- <SectionTitle>Typography</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- fontSize: DEFAULTS.text.element.fontSize,
- },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={textColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle text color keyframe"
- onToggle={textColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={uppercase({
- string: resolvedTextColor.replace("#", ""),
- })}
- onChange={(color) => textColor.onChange({ color: `#${color}` })}
- onChangeEnd={textColor.onChangeEnd}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function SpacingSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const letterSpacing = usePropertyDraft({
- displayValue: Math.round(
- element.letterSpacing ?? DEFAULTS.text.letterSpacing,
- ).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { letterSpacing: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const lineHeight = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: element.lineHeight ?? DEFAULTS.text.lineHeight,
- fractionDigits: 1,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed)
- ? null
- : Math.max(0.1, Math.round(parsed * 10) / 10);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { lineHeight: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:spacing`}>
- <SectionHeader>
- <SectionTitle>Spacing</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { letterSpacing: DEFAULTS.text.letterSpacing },
- },
- ],
- })
- }
- isDefault={
- (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
- DEFAULTS.text.letterSpacing
- }
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { lineHeight: DEFAULTS.text.lineHeight },
- },
- ],
- })
- }
- isDefault={
- (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
- DEFAULTS.text.lineHeight
- }
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function BackgroundSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedBgColor = resolveColorAtTime({
- baseColor: element.background.color,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- });
-
- const bgColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedBgColor,
- buildBaseUpdates: ({ value }) => ({
- background: { ...element.background, color: value },
- }),
- });
-
- const bg = element.background;
-
- const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- });
- const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- });
- const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- });
- const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- });
- const resolvedCornerRadius = resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- });
-
- const paddingX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingX: value },
- }),
- });
-
- const paddingY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingY: value },
- }),
- });
-
- const offsetX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetX: value },
- }),
- });
-
- const offsetY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetY: value },
- }),
- });
-
- const cornerRadius = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedCornerRadius).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- });
- },
- valueAtPlayhead: resolvedCornerRadius,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, cornerRadius: value },
- }),
- });
-
- const toggleBackgroundEnabled = () => {
- const enabled = !element.background.enabled;
- const color =
- enabled && element.background.color === "transparent"
- ? lastSelectedColor.current
- : element.background.color;
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- background: {
- ...element.background,
- enabled,
- color,
- },
- },
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- defaultOpen={element.background.enabled}
- sectionKey={`${element.id}:background`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleBackgroundEnabled();
- }}
- >
- <HugeiconsIcon
- icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(
- !element.background.enabled && "pointer-events-none opacity-50",
- )}
- >
- <SectionFields>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={bgColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background color keyframe"
- onToggle={bgColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={
- !element.background.enabled ||
- element.background.color === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : resolvedBgColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- bgColor.onChange({ color: hexColor });
- }}
- onChangeEnd={bgColor.onChangeEnd}
- />
- </SectionField>
- <div className="flex items-start gap-2">
- <SectionField
- label="Width"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background width keyframe"
- onToggle={paddingX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
- onFocus={paddingX.onFocus}
- onChange={paddingX.onChange}
- onBlur={paddingX.onBlur}
- onScrub={paddingX.scrubTo}
- onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- paddingX.commitValue({
- value: DEFAULTS.text.background.paddingX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- defaultValue: DEFAULTS.text.background.paddingX,
- })}
- />
- </SectionField>
- <SectionField
- label="Height"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background height keyframe"
- onToggle={paddingY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
- onFocus={paddingY.onFocus}
- onChange={paddingY.onChange}
- onBlur={paddingY.onBlur}
- onScrub={paddingY.scrubTo}
- onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- paddingY.commitValue({
- value: DEFAULTS.text.background.paddingY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- defaultValue: DEFAULTS.text.background.paddingY,
- })}
- />
- </SectionField>
- </div>
- <div className="flex items-start gap-2">
- <SectionField
- label="X-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle x-offset keyframe"
- onToggle={offsetX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={offsetX.displayValue}
- onFocus={offsetX.onFocus}
- onChange={offsetX.onChange}
- onBlur={offsetX.onBlur}
- onScrub={offsetX.scrubTo}
- onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- offsetX.commitValue({
- value: DEFAULTS.text.background.offsetX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- defaultValue: DEFAULTS.text.background.offsetX,
- })}
- />
- </SectionField>
- <SectionField
- label="Y-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle y-offset keyframe"
- onToggle={offsetY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
- onFocus={offsetY.onFocus}
- onChange={offsetY.onChange}
- onBlur={offsetY.onBlur}
- onScrub={offsetY.scrubTo}
- onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- offsetY.commitValue({
- value: DEFAULTS.text.background.offsetY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- defaultValue: DEFAULTS.text.background.offsetY,
- })}
- />
- </SectionField>
- </div>
- <SectionField
- label="Corner radius"
- beforeLabel={
- <KeyframeToggle
- isActive={cornerRadius.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle corner radius keyframe"
- onToggle={cornerRadius.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={CORNER_RADIUS_MIN}
- max={CORNER_RADIUS_MAX}
- onFocus={cornerRadius.onFocus}
- onChange={cornerRadius.onChange}
- onBlur={cornerRadius.onBlur}
- onScrub={cornerRadius.scrubTo}
- onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedCornerRadius,
- staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- defaultValue: CORNER_RADIUS_MIN,
- })}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
deleted file mode 100644
index c3cef2d6..00000000
--- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
+++ /dev/null
@@ -1,462 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { VisualElement } from "@/lib/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- resolveTransformAtTime,
-} from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { usePropertiesStore } from "../stores/properties-store";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
- const setTransformScaleLocked = usePropertiesStore(
- (s) => s.setTransformScaleLocked,
- );
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, x: value },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value },
- },
- }),
- });
-
- const parseScale = (input: string) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return parsed / 100;
- };
-
- const scaleX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleX,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleX: value,
- ...(isScaleLocked ? { scaleY: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
- : undefined,
- });
-
- const scaleY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleY,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleY: value,
- ...(isScaleLocked ? { scaleX: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
- : undefined,
- });
-
- const scaleFieldPropsX = {
- value: scaleX.displayValue,
- onFocus: scaleX.onFocus,
- onChange: scaleX.onChange,
- onBlur: scaleX.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleX.scrubTo,
- onScrubEnd: scaleX.commitScrub,
- onReset: () =>
- scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleX,
- staticValue: element.transform.scaleX,
- defaultValue: DEFAULTS.element.transform.scaleX,
- }),
- };
-
- const scaleFieldPropsY = {
- value: scaleY.displayValue,
- onFocus: scaleY.onFocus,
- onChange: scaleY.onChange,
- onBlur: scaleY.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleY.scrubTo,
- onScrubEnd: scaleY.commitScrub,
- onReset: () =>
- scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleY,
- staticValue: element.transform.scaleY,
- defaultValue: DEFAULTS.element.transform.scaleY,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasScaleKeyframe = hasGroupKeyframeAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
-
- const toggleScaleKeyframe = () => {
- if (!isPlayheadWithinElementRange) return;
- const existing = getGroupKeyframesAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
- if (existing.length > 0) {
- editor.timeline.removeKeyframes({
- keyframes: existing.map((ref) => ({
- trackId,
- elementId: element.id,
- ...ref,
- })),
- });
- return;
- }
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleX",
- time: localTime,
- value: resolvedTransform.scaleX,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleY",
- time: localTime,
- value: resolvedTransform.scaleY,
- },
- ],
- });
- };
-
- const scaleLockButton = (
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setTransformScaleLocked(!isScaleLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- );
-
- return (
- <Section collapsible sectionKey={`${element.id}:transform`}>
- <SectionHeader>
- <SectionTitle>Transform</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <div className="flex items-end gap-2">
- {isScaleLocked ? (
- <>
- <SectionField
- label="Scale"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={hasScaleKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={toggleScaleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldPropsX}
- />
- </SectionField>
- {scaleLockButton}
- </>
- ) : (
- <>
- <SectionField
- label="Width"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle width scale keyframe"
- onToggle={scaleX.toggleKeyframe}
- />
- }
- >
- <NumberField icon="W" {...scaleFieldPropsX} />
- </SectionField>
- {scaleLockButton}
- <SectionField
- label="Height"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle height scale keyframe"
- onToggle={scaleY.toggleKeyframe}
- />
- }
- >
- <NumberField icon="H" {...scaleFieldPropsY} />
- </SectionField>
- </>
- )}
- </div>
- <div className="flex items-end gap-2">
- <SectionField
- label="X"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle X position keyframe"
- onToggle={positionX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({
- value: DEFAULTS.element.transform.position.x,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULTS.element.transform.position.x,
- })}
- />
- </SectionField>
- <SectionField
- label="Y"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle Y position keyframe"
- onToggle={positionY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({
- value: DEFAULTS.element.transform.position.y,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULTS.element.transform.position.y,
- })}
- />
- </SectionField>
- </div>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({
- value: DEFAULTS.element.transform.rotate,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULTS.element.transform.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx b/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
deleted file mode 100644
index 208f3beb..00000000
--- a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-"use client";
-
-import { useCallback, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getDbFromLinePos,
- getLinePosFromDb,
-} from "@/lib/timeline/audio-display";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { hasAnimatedVolume } from "@/lib/timeline/audio-state";
-import type { AudioElement } from "@/lib/timeline/types";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import { cn } from "@/utils/ui";
-
-const HIT_AREA_HEIGHT_PX = 14;
-const TOOLTIP_OFFSET_PX = 10;
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-function clampVolume({ value }: { value: number }): number {
- return clamp({
- value: snapToStep({ value, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
-}
-
-function getVolumeFromPointer({
- clientY,
- rect,
-}: {
- clientY: number;
- rect: DOMRect;
-}): number {
- const clampedOffset = clamp({
- value: clientY - rect.top,
- min: 0,
- max: rect.height,
- });
- const progressPercent =
- rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
- return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
-}
-
-export function AudioVolumeLine({
- element,
- trackId,
-}: {
- element: AudioElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const surfaceRef = useRef<HTMLDivElement>(null);
- const activePointerIdRef = useRef<number | null>(null);
- const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
- const lastPreviewVolumeRef = useRef(
- element.volume ?? DEFAULTS.element.volume,
- );
- const hasChangedRef = useRef(false);
- const [isDragging, setIsDragging] = useState(false);
- const [tooltipClientPos, setTooltipClientPos] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- const hasAnimatedEnvelope = hasAnimatedVolume({ element });
- const currentVolume = element.volume ?? DEFAULTS.element.volume;
- const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
-
- const volumeLabel = `${formatNumberForDisplay({
- value: currentVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- })} dB`;
-
- const previewVolume = useCallback(
- (nextVolume: number) => {
- if (
- isNearlyEqual({
- leftValue: nextVolume,
- rightValue: lastPreviewVolumeRef.current,
- })
- ) {
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { volume: nextVolume },
- },
- ],
- });
- lastPreviewVolumeRef.current = nextVolume;
- hasChangedRef.current = !isNearlyEqual({
- leftValue: startVolumeRef.current,
- rightValue: nextVolume,
- });
- },
- [editor, element.id, trackId],
- );
-
- const finishDrag = useCallback(
- ({ shouldCommit }: { shouldCommit: boolean }) => {
- activePointerIdRef.current = null;
- setIsDragging(false);
-
- if (shouldCommit && hasChangedRef.current) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.discardPreview();
- }
-
- hasChangedRef.current = false;
- lastPreviewVolumeRef.current = startVolumeRef.current;
- setTooltipClientPos(null);
- },
- [editor],
- );
-
- const updateFromPointer = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- const rect = surfaceRef.current?.getBoundingClientRect();
- if (!rect) {
- return;
- }
-
- setTooltipClientPos({
- x: clientX + TOOLTIP_OFFSET_PX,
- y: clientY - TOOLTIP_OFFSET_PX,
- });
- previewVolume(getVolumeFromPointer({ clientY, rect }));
- },
- [previewVolume],
- );
-
- const handleClick = useCallback((event: React.MouseEvent) => {
- event.preventDefault();
- event.stopPropagation();
- }, []);
-
- const handleMouseDown = useCallback((event: React.MouseEvent) => {
- event.stopPropagation();
- }, []);
-
- const handlePointerDown = useCallback(
- (event: React.PointerEvent<HTMLDivElement>) => {
- if (event.button !== 0) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- editor.selection.setSelectedElements({
- elements: [{ trackId, elementId: element.id }],
- });
- activePointerIdRef.current = event.pointerId;
- startVolumeRef.current = currentVolume;
- lastPreviewVolumeRef.current = currentVolume;
- hasChangedRef.current = false;
- setIsDragging(true);
- event.currentTarget.setPointerCapture(event.pointerId);
- updateFromPointer({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- },
- [currentVolume, editor.selection, element.id, trackId, updateFromPointer],
- );
-
- const handlePointerMove = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- updateFromPointer({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- },
- [updateFromPointer],
- );
-
- const handlePointerUp = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- finishDrag({ shouldCommit: true });
- },
- [finishDrag],
- );
-
- const handlePointerCancel = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- finishDrag({ shouldCommit: false });
- },
- [finishDrag],
- );
-
- const handleLostPointerCapture = useCallback(() => {
- if (activePointerIdRef.current === null) {
- return;
- }
-
- finishDrag({ shouldCommit: hasChangedRef.current });
- }, [finishDrag]);
-
- if (hasAnimatedEnvelope) {
- return null;
- }
-
- return (
- <div className="pointer-events-none absolute inset-0">
- <div ref={surfaceRef} className="absolute inset-0">
- <div
- className={cn(
- "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
- isDragging
- ? "border-white"
- : "border-white/50 group-hover/audio:border-white/80",
- )}
- style={{ top: lineTop }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
- <div
- className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
- style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
- onClick={handleClick}
- onMouseDown={handleMouseDown}
- onPointerDown={handlePointerDown}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerCancel}
- onLostPointerCapture={handleLostPointerCapture}
- title="Drag to adjust clip volume"
- />
- {isDragging &&
- tooltipClientPos &&
- createPortal(
- <div
- className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
- style={{
- transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
- }}
- >
- {volumeLabel}
- </div>,
- document.body,
- )}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
deleted file mode 100644
index a94267dd..00000000
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
-import { useResizeObserver } from "@/hooks/use-resize-observer";
-import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
-import {
- buildWaveformSampleBuckets,
- sampleSourceWaveformSummary,
- type SourceWaveformSummary,
-} from "@/lib/media/waveform-summary";
-import type { RetimeConfig } from "@/lib/timeline";
-import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display";
-import { waveformCache } from "@/services/waveform-cache/service";
-import { findScrollParent } from "@/utils/browser";
-import { cn } from "@/utils/ui";
-
-const BAR_WIDTH = 1;
-const BAR_GAP = 1;
-const BAR_STEP = BAR_WIDTH + BAR_GAP;
-const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
-export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
-
-function sampleGainAtClipTime({
- samples,
- clipTimeSec,
- clipDurationSec,
-}: {
- samples: number[];
- clipTimeSec: number;
- clipDurationSec: number;
-}): number {
- if (samples.length === 0 || clipDurationSec <= 0) {
- return 1;
- }
-
- const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
- const rawIndex = progress * (samples.length - 1);
- const lo = Math.floor(rawIndex);
- const hi = Math.min(samples.length - 1, lo + 1);
- return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
-}
-
-interface AudioWaveformProps {
- sourceKey: string;
- sourceFile?: File;
- audioUrl?: string;
- audioBuffer?: AudioBuffer;
- gainSamples?: number[];
- pixelsPerSecond: number;
- clipDurationSec: number;
- retime?: RetimeConfig;
- sourceStartSec: number;
- color?: string;
- burnColor?: string;
- className?: string;
-}
-
-export function AudioWaveform({
- sourceKey,
- sourceFile,
- audioUrl,
- audioBuffer,
- gainSamples,
- pixelsPerSecond,
- clipDurationSec,
- retime,
- sourceStartSec,
- color = TIMELINE_AUDIO_WAVEFORM_COLOR,
- burnColor = WAVEFORM_BURN_COLOR,
- className = "",
-}: AudioWaveformProps) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const summaryRef = useRef<SourceWaveformSummary | null>(null);
- const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
- const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
- const clipDurationSecRef = useRef<number>(clipDurationSec);
- const retimeRef = useRef<RetimeConfig | undefined>(retime);
- const sourceStartSecRef = useRef<number>(sourceStartSec);
- const scrollParentRef = useRef<HTMLElement | null>(null);
- const heightRef = useRef<number>(0);
- const lastRenderSignatureRef = useRef<string | null>(null);
-
- gainSamplesRef.current = gainSamples;
- pixelsPerSecondRef.current = pixelsPerSecond;
- clipDurationSecRef.current = clipDurationSec;
- retimeRef.current = retime;
- sourceStartSecRef.current = sourceStartSec;
-
- const clearCanvas = useCallback(() => {
- const canvas = canvasRef.current;
- if (!canvas) {
- return;
- }
-
- const ctx = canvas.getContext("2d");
- if (ctx) {
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- }
- lastRenderSignatureRef.current = null;
- }, []);
-
- const drawVisible = useCallback(() => {
- const container = containerRef.current;
- const canvas = canvasRef.current;
- const summary = summaryRef.current;
- const height = heightRef.current;
-
- if (!container || !canvas || !summary || height <= 0) {
- clearCanvas();
- return;
- }
-
- const containerRect = container.getBoundingClientRect();
- const elementWidth = containerRect.width;
- if (elementWidth <= 0) {
- clearCanvas();
- return;
- }
-
- const scrollParent = scrollParentRef.current;
-
- let clipLeft: number;
- let clipRight: number;
-
- if (scrollParent) {
- const parentRect = scrollParent.getBoundingClientRect();
- clipLeft = Math.max(0, parentRect.left - containerRect.left);
- clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
- } else {
- clipLeft = Math.max(0, -containerRect.left);
- clipRight = Math.min(
- elementWidth,
- window.innerWidth - containerRect.left,
- );
- }
-
- const visibleWidth = clipRight - clipLeft;
- if (visibleWidth <= 0) {
- clearCanvas();
- return;
- }
-
- const dpr = window.devicePixelRatio || 1;
- const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
- const canvasH = Math.max(1, Math.round(height * dpr));
- const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
- const pixelsPerSecondValue = pixelsPerSecondRef.current;
- const clipDurationSecValue = clipDurationSecRef.current;
- const samples = gainSamplesRef.current;
- const renderSignature = JSON.stringify({
- elementWidth,
- clipLeft,
- clipRight,
- visibleWidth,
- canvasW,
- canvasH,
- barCount,
- dpr,
- clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
- pixelsPerSecond: pixelsPerSecondValue,
- retime: retimeRef.current ?? null,
- summarySourceKey: summary.sourceKey,
- summarySampleRate: summary.sampleRate,
- summaryTotalSamples: summary.totalSamples,
- summaryBucketSize: summary.bucketSize,
- gainSamples: samples ?? null,
- color,
- burnColor,
- });
- if (lastRenderSignatureRef.current === renderSignature) {
- return;
- }
- lastRenderSignatureRef.current = renderSignature;
-
- canvas.width = canvasW;
- canvas.height = canvasH;
- canvas.style.width = `${visibleWidth}px`;
- canvas.style.height = `${height}px`;
- canvas.style.left = `${clipLeft}px`;
-
- const backingScaleX = dpr;
- const backingScaleY = canvasH / height;
-
- const sampleBuckets = buildWaveformSampleBuckets({
- clipLeftPx: clipLeft,
- clipRightPx: clipRight,
- barCount,
- pixelsPerSecond: pixelsPerSecondValue,
- clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
- retime: retimeRef.current,
- sampleRate: summary.sampleRate,
- maxSampleExclusive: summary.totalSamples,
- barStepPx: BAR_STEP,
- });
- const amplitudes = sampleSourceWaveformSummary({
- summary,
- buckets: sampleBuckets,
- });
-
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return;
- }
-
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.clearRect(0, 0, canvasW, canvasH);
-
- const clipBottom = canvasH;
-
- for (let i = 0; i < barCount; i++) {
- const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
- const clipCenterSec = Math.max(
- 0,
- Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
- );
- const gain =
- samples != null
- ? sampleGainAtClipTime({
- samples,
- clipTimeSec: clipCenterSec,
- clipDurationSec: clipDurationSecValue,
- })
- : 1;
- const amplitude = Math.max(0, amplitudes[i] ?? 0);
- const outputAmplitude = amplitude * Math.max(0, gain);
- const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
- const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
- if (barH <= 0) {
- continue;
- }
-
- const barLeft = i * BAR_STEP;
- const barRight = barLeft + BAR_WIDTH;
- const deviceLeft = Math.round(barLeft * backingScaleX);
- const deviceRight = Math.max(
- deviceLeft + 1,
- Math.round(barRight * backingScaleX),
- );
- const deviceTop = Math.round((height - barH) * backingScaleY);
- const deviceHeight = Math.max(1, clipBottom - deviceTop);
-
- ctx.fillStyle = color;
- ctx.fillRect(
- deviceLeft,
- deviceTop,
- deviceRight - deviceLeft,
- deviceHeight,
- );
-
- if (outputAmplitude > 1) {
- const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
- ctx.fillStyle = burnColor;
- ctx.fillRect(
- deviceLeft,
- deviceTop,
- deviceRight - deviceLeft,
- burnHeight,
- );
- }
- }
- }, [burnColor, clearCanvas, color]);
-
- useEffect(() => {
- let isCancelled = false;
- summaryRef.current = null;
- clearCanvas();
-
- void waveformCache
- .getSourceSummary({
- sourceKey,
- audioBuffer,
- sourceFile,
- audioUrl,
- })
- .then((summary) => {
- if (isCancelled) {
- return;
- }
- summaryRef.current = summary;
- drawVisible();
- })
- .catch(() => {
- // Waveform loading failed (e.g. corrupt file, unsupported format).
- // Fail silently — a missing waveform is preferable to an error state.
- if (!isCancelled) {
- clearCanvas();
- }
- });
-
- return () => {
- isCancelled = true;
- };
- }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
- useLayoutEffect(() => {
- drawVisible();
- }, [
- drawVisible,
- gainSamples,
- pixelsPerSecond,
- clipDurationSec,
- retime,
- sourceStartSec,
- ]);
-
- useEffect(() => {
- const container = containerRef.current;
- if (!container) {
- return;
- }
-
- scrollParentRef.current = findScrollParent({ element: container });
- const scrollParent = scrollParentRef.current;
- if (!scrollParent) {
- return;
- }
-
- const handleScroll = () => {
- drawVisible();
- };
- scrollParent.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollParent.removeEventListener("scroll", handleScroll);
- }, [drawVisible]);
-
- const onResize = useCallback(
- (entry: ResizeObserverEntry) => {
- heightRef.current = entry.contentRect.height;
- drawVisible();
- },
- [drawVisible],
- );
-
- useResizeObserver({ ref: containerRef, onResize });
-
- return (
- <div ref={containerRef} className={cn("relative size-full", className)}>
- <canvas ref={canvasRef} className="absolute bottom-0" />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
deleted file mode 100644
index c432b7d4..00000000
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { EditorCore } from "@/core";
-import { useEditor } from "@/hooks/use-editor";
-import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
-import {
- DEFAULT_TIMELINE_BOOKMARK_COLOR,
-} from "./theme";
-import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
-import { DEFAULT_FPS } from "@/lib/fps/defaults";
-import { snappedSeekTime } from "opencut-wasm";
-import {
- ArrowTurnBackwardIcon,
- Delete02Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/lib/timeline";
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
-} from "@/components/ui/popover";
-import { Input } from "@/components/ui/input";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import {
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-
-const MIN_BOOKMARK_WIDTH_PX = 2;
-const BOOKMARK_MARKER_WIDTH_PX = 12;
-const BOOKMARK_MARKER_HEIGHT_PX = 15;
-const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
-const BOOKMARK_MARKER_CLIP_PATH =
- "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
-
-function seekToBookmarkTime({
- editor,
- time,
-}: {
- editor: EditorCore;
- time: number;
-}) {
- const activeProject = editor.project.getActive();
- const duration = editor.timeline.getTotalDuration();
- const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
- const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
- editor.playback.seek({ time: snappedTime });
-}
-
-interface TimelineBookmarksRowProps {
- zoomLevel: number;
- dynamicTimelineWidth: number;
- dragState: BookmarkDragState;
- onBookmarkMouseDown: (params: {
- event: React.MouseEvent;
- bookmark: Bookmark;
- }) => void;
- handleWheel: (event: React.WheelEvent) => void;
- handleTimelineContentClick: (event: React.MouseEvent) => void;
- handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
- handleRulerMouseDown: (event: React.MouseEvent) => void;
-}
-
-export function TimelineBookmarksRow({
- zoomLevel,
- dynamicTimelineWidth,
- dragState,
- onBookmarkMouseDown,
- handleWheel,
- handleTimelineContentClick,
- handleRulerTrackingMouseDown,
- handleRulerMouseDown,
-}: TimelineBookmarksRowProps) {
- const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
-
- return (
- <div
- className="relative flex-1 overflow-hidden"
- style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
- >
- <button
- className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
- style={{
- height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
- width: `${dynamicTimelineWidth}px`,
- }}
- aria-label="Timeline ruler"
- type="button"
- onWheel={handleWheel}
- onClick={(event) => {
- if (!event.currentTarget.contains(event.target as Node)) return;
- handleTimelineContentClick(event);
- }}
- onMouseDown={(event) => {
- if (!event.currentTarget.contains(event.target as Node)) return;
- handleRulerMouseDown(event);
- handleRulerTrackingMouseDown(event);
- }}
- >
- {bookmarks.map((bookmark) => (
- <TimelineBookmark
- key={`bookmark-${bookmark.time}`}
- bookmark={bookmark}
- zoomLevel={zoomLevel}
- dragState={dragState}
- onBookmarkMouseDown={onBookmarkMouseDown}
- />
- ))}
- </button>
- </div>
- );
-}
-
-function TimelineBookmark({
- bookmark,
- zoomLevel,
- dragState,
- onBookmarkMouseDown,
-}: {
- bookmark: Bookmark;
- zoomLevel: number;
- dragState: BookmarkDragState;
- onBookmarkMouseDown: (params: {
- event: React.MouseEvent;
- bookmark: Bookmark;
- }) => void;
-}) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
-
- const isDragging =
- dragState.isDragging &&
- dragState.bookmarkTime !== null &&
- dragState.bookmarkTime === bookmark.time;
-
- const displayTime = isDragging ? dragState.currentTime : bookmark.time;
- const time = bookmark.time;
- const bookmarkDuration = bookmark.duration ?? 0;
- const durationWidth =
- bookmarkDuration > 0
- ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
- : 0;
- const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
- const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
- const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
- const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
- const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
- const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
-
- const handleSeek = () => seekToBookmarkTime({ editor, time });
-
- const handleClick = (event: React.MouseEvent) => {
- event.preventDefault();
- event.stopPropagation();
- if (event.detail === 2) {
- setIsPopoverOpen(true);
- } else {
- handleSeek();
- }
- };
-
- const handleKeyDown = (event: React.KeyboardEvent) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- handleSeek();
- };
-
- const handleMouseDown = (event: React.MouseEvent) => {
- onBookmarkMouseDown({ event, bookmark });
- event.preventDefault();
- event.stopPropagation();
- };
-
- return (
- <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
- <PopoverAnchor asChild>
- <button
- className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
- style={{
- left: `${bookmarkLeft}px`,
- width: `${bookmarkWidth}px`,
- }}
- aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
- type="button"
- onMouseDown={handleMouseDown}
- onClick={handleClick}
- onKeyDown={handleKeyDown}
- >
- {hasDurationRange ? (
- <div
- className="absolute opacity-30"
- style={{
- top: 1.5,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
- left: BOOKMARK_HALF_WIDTH_PX,
- width: durationWidth,
- backgroundColor: iconColor,
- }}
- />
- ) : null}
- <div
- className="absolute top-0 overflow-hidden"
- style={{
- left: 0,
- width: BOOKMARK_HALF_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- }}
- >
- <div
- className="absolute inset-0"
- style={{
- width: BOOKMARK_MARKER_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: "hsl(var(--background))",
- }}
- />
- <div
- className="absolute"
- style={{
- top: 1,
- left: 1,
- width: BOOKMARK_MARKER_WIDTH_PX - 2,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: iconColor,
- }}
- />
- </div>
- <div
- className="absolute top-0 overflow-hidden"
- style={{
- left: rightHalfLeft,
- width: BOOKMARK_HALF_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- }}
- >
- <div
- className="absolute top-0"
- style={{
- left: -BOOKMARK_HALF_WIDTH_PX,
- width: BOOKMARK_MARKER_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: "hsl(var(--background))",
- }}
- />
- <div
- className="absolute"
- style={{
- top: 1,
- left: 1 - BOOKMARK_HALF_WIDTH_PX,
- width: BOOKMARK_MARKER_WIDTH_PX - 2,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: iconColor,
- }}
- />
- </div>
- </button>
- </PopoverAnchor>
- <PopoverContent
- className="w-64 flex flex-col gap-3 p-3"
- align="start"
- side="bottom"
- sideOffset={8}
- onOpenAutoFocus={(event) => event.preventDefault()}
- >
- <BookmarkPopoverContent
- bookmark={bookmark}
- time={time}
- timelineDuration={duration}
- onPopoverClose={() => setIsPopoverOpen(false)}
- />
- </PopoverContent>
- </Popover>
- );
-}
-
-function BookmarkPopoverContent({
- bookmark,
- time,
- timelineDuration,
- onPopoverClose,
-}: {
- bookmark: Bookmark;
- time: number;
- timelineDuration: number;
- onPopoverClose: () => void;
-}) {
- const editor = useEditor();
- const [draftColorHex, setDraftColorHex] = useState(
- (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
- .replace("#", "")
- .toUpperCase(),
- );
-
- useEffect(() => {
- setDraftColorHex(
- (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
- .replace("#", "")
- .toUpperCase(),
- );
- }, [bookmark.color]);
-
- const handleRemove = () => {
- editor.scenes.removeBookmark({ time });
- onPopoverClose();
- };
-
- const handleUpdate = ({
- note,
- color,
- duration,
- }: Partial<{ note: string; color: string; duration: number }>) => {
- const updates: Partial<{ note: string; color: string; duration: number }> =
- {};
- if (note !== undefined && note !== bookmark.note) updates.note = note;
- if (
- color !== undefined &&
- color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
- ) {
- updates.color = color;
- }
- if (duration !== undefined && duration !== bookmark.duration) {
- updates.duration = duration;
- }
- if (Object.keys(updates).length === 0) return;
- editor.scenes.updateBookmark({ time, updates });
- };
-
- return (
- <>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Note</Label>
- <Input
- placeholder="Add a note..."
- value={bookmark.note ?? ""}
- onChange={(event) => handleUpdate({ note: event.target.value })}
- className="h-8 text-sm"
- />
- </div>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Color</Label>
- <div className="relative">
- <ColorPicker
- value={uppercase({ string: draftColorHex })}
- onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
- onChangeEnd={(color) =>
- handleUpdate({ color: `#${uppercase({ string: color })}` })
- }
- className="bg-background border"
- />
- {bookmark.color &&
- bookmark.color.replace(/^#/, "").toUpperCase() !==
- DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
- <Button
- type="button"
- variant="text"
- size="text"
- aria-label="Reset to default color"
- className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
- onClick={() =>
- editor.scenes.updateBookmark({
- time,
- updates: { color: undefined },
- })
- }
- >
- <HugeiconsIcon
- icon={ArrowTurnBackwardIcon}
- className="!size-3.5"
- />
- </Button>
- )}
- </div>
- </div>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Duration</Label>
- <div className="flex items-center gap-1.5">
- <Input
- type="number"
- min={0}
- step={0.1}
- value={bookmark.duration ?? 0}
- onChange={(event) => {
- const parsed = parseFloat(event.target.value);
- const value = Number.isNaN(parsed)
- ? 0
- : clamp({
- value: parsed,
- min: 0,
- max: Math.max(0, timelineDuration - time),
- });
- handleUpdate({ duration: value });
- }}
- className="h-8 text-sm"
- containerClassName="w-full"
- />
- </div>
- </div>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="text-destructive hover:bg-destructive/10"
- onClick={handleRemove}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- handleRemove();
- }
- }}
- aria-label="delete bookmark"
- >
- <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
- Delete
- </Button>
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
deleted file mode 100644
index 89a4c93f..00000000
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { getDropLineY } from "./drop-target";
-import type { TimelineTrack, DropTarget } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-
-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 h-0.5"
- style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts
deleted file mode 100644
index ef6cbd90..00000000
--- a/apps/web/src/components/editor/panels/timeline/drop-target.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
-import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-import { TIMELINE_TRACK_GAP_PX } from "./layout";
-import { getTrackHeight } from "./track-layout";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-function findElementAtPosition({
- mouseX,
- tracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
-}: {
- mouseX: number;
- tracks: TimelineTrack[];
- trackIndex: number;
- targetElementTypes: string[];
- pixelsPerSecond: number;
- zoomLevel: number;
-}): { elementId: string; trackId: string } | null {
- const time = Math.round(
- (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
- const track = tracks[trackIndex];
- if (!track || !("elements" in track)) return null;
-
- const hit = track.elements.find(
- (element: TimelineElement) =>
- targetElementTypes.includes(element.type) &&
- element.startTime <= time &&
- time < element.startTime + element.duration,
- );
- if (!hit) return null;
- return { elementId: hit.id, trackId: track.id };
-}
-
-function getTrackAtY({
- mouseY,
- tracks,
- verticalDragDirection,
-}: {
- mouseY: number;
- tracks: TimelineTrack[];
- verticalDragDirection?: "up" | "down" | null;
-}): { trackIndex: number; relativeY: number } | null {
- let cumulativeHeight = 0;
-
- for (let i = 0; i < tracks.length; i++) {
- const trackHeight = getTrackHeight({ type: tracks[i].type });
- const trackTop = cumulativeHeight;
- const trackBottom = trackTop + trackHeight;
-
- if (mouseY >= trackTop && mouseY < trackBottom) {
- return {
- trackIndex: i,
- relativeY: mouseY - trackTop,
- };
- }
-
- if (i < tracks.length - 1 && verticalDragDirection) {
- const gapTop = trackBottom;
- const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
- if (mouseY >= gapTop && mouseY < gapBottom) {
- const isDraggingUp = verticalDragDirection === "up";
- return {
- trackIndex: isDraggingUp ? i : i + 1,
- relativeY: isDraggingUp ? trackHeight - 1 : 0,
- };
- }
- }
-
- cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
- }
-
- return null;
-}
-
-const EMPTY_TARGET_ELEMENT = null;
-
-function fallbackNewTrackDropTarget({
- xPosition,
-}: {
- xPosition: number;
-}): DropTarget {
- return {
- trackIndex: 0,
- isNewTrack: true,
- insertPosition: null,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime,
- isExternalDrop,
- elementDuration,
- pixelsPerSecond,
- zoomLevel,
- verticalDragDirection,
- startTimeOverride,
- excludeElementId,
- targetElementTypes,
-}: ComputeDropTargetParams): DropTarget {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const mainTrackIndex = tracks.overlay.length;
- const xPosition =
- typeof startTimeOverride === "number"
- ? startTimeOverride
- : isExternalDrop
- ? playheadTime
- : Math.round(
- Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
-
- if (orderedTracks.length === 0) {
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- createNewTrackOnly: true,
- },
- });
- const emptyTimelineResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!emptyTimelineResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: emptyTimelineResult.insertIndex,
- isNewTrack: true,
- insertPosition: emptyTimelineResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const trackAtMouse = getTrackAtY({
- mouseY,
- tracks: orderedTracks,
- verticalDragDirection,
- });
-
- if (!trackAtMouse) {
- const isAboveAllTracks = mouseY < 0;
-
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
- hoverDirection: isAboveAllTracks ? "above" : "below",
- createNewTrackOnly: true,
- },
- });
- const outOfBoundsResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!outOfBoundsResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: outOfBoundsResult.insertIndex,
- isNewTrack: true,
- insertPosition: outOfBoundsResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const { trackIndex, relativeY } = trackAtMouse;
- const track = orderedTracks[trackIndex];
-
- if (targetElementTypes && targetElementTypes.length > 0) {
- const targetElement = findElementAtPosition({
- mouseX,
- tracks: orderedTracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
- });
- if (targetElement) {
- return {
- trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition,
- targetElement,
- };
- }
- }
-
- const trackHeight = getTrackHeight({ type: track.type });
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex,
- hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
- verticalDragDirection,
- },
- });
- if (!placementResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- if (placementResult.kind === "existingTrack") {
- const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
-
- return {
- trackIndex: placementResult.trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition: adjustedXPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- return {
- trackIndex: placementResult.insertIndex,
- isNewTrack: true,
- insertPosition: placementResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function getDropLineY({
- dropTarget,
- tracks,
-}: {
- dropTarget: DropTarget;
- tracks: TimelineTrack[];
-}): number {
- const safeTrackIndex = Math.min(
- Math.max(dropTarget.trackIndex, 0),
- tracks.length,
- );
- let y = 0;
-
- for (let i = 0; i < safeTrackIndex; i++) {
- y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
- }
-
- return y;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
deleted file mode 100644
index 6b9734a7..00000000
--- a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type {
- AnimationPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { TimelineTrack } from "@/lib/timeline";
-import { getElementKeyframes } from "@/lib/animation";
-import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
-
-export interface ExpandedRow {
- propertyPath: AnimationPath;
- label: string;
-}
-
-interface PropertyGroupDefinition {
- matchesPath: (path: AnimationPath) => boolean;
-}
-
-const PROPERTY_GROUPS: PropertyGroupDefinition[] = [
- { matchesPath: (path) => path.startsWith("transform.") || path === "opacity" },
- { matchesPath: (path) => path === "volume" || path === "color" },
- { matchesPath: (path) => path.startsWith("background.") },
- { matchesPath: (path) => path.startsWith("params.") },
- { matchesPath: (path) => path.startsWith("effects.") },
-];
-
-const PROPERTY_LABELS: Partial<Record<string, string>> = {
- "transform.positionX": "Position X",
- "transform.positionY": "Position Y",
- "transform.scaleX": "Scale X",
- "transform.scaleY": "Scale Y",
- "transform.rotate": "Rotation",
- opacity: "Opacity",
- volume: "Volume",
- color: "Color",
- "background.color": "BG Color",
- "background.paddingX": "BG Pad X",
- "background.paddingY": "BG Pad Y",
- "background.offsetX": "BG Offset X",
- "background.offsetY": "BG Offset Y",
- "background.cornerRadius": "Corner Radius",
-};
-
-export function getPropertyLabel(path: AnimationPath): string {
- if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path];
- if (path.startsWith("params.")) return path.slice("params.".length);
- if (path.startsWith("effects.")) {
- const parts = path.split(".");
- return parts[parts.length - 1];
- }
- return path;
-}
-
-export function getExpandedRows({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ExpandedRow[] {
- const keyframes = getElementKeyframes({ animations });
- const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))];
- if (propertyPaths.length === 0) return [];
-
- const rows: ExpandedRow[] = [];
-
- for (const group of PROPERTY_GROUPS) {
- const groupPaths = propertyPaths.filter((path) =>
- group.matchesPath(path),
- );
- for (const path of groupPaths) {
- rows.push({ propertyPath: path, label: getPropertyLabel(path) });
- }
- }
-
- return rows;
-}
-
-export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number {
- return rows.length * KEYFRAME_LANE_HEIGHT_PX;
-}
-
-export function computeTrackExpansionHeight({
- track,
- expandedElementIds,
-}: {
- track: TimelineTrack;
- expandedElementIds: Set<string>;
-}): number {
- let maxHeight = 0;
- for (const element of track.elements) {
- if (!expandedElementIds.has(element.id)) continue;
- const rows = getExpandedRows({ animations: element.animations });
- maxHeight = Math.max(maxHeight, getExpansionHeight({ rows }));
- }
- return maxHeight;
-}
-
-export function getTrackExpandedRows({
- track,
- expandedElementIds,
-}: {
- track: TimelineTrack;
- expandedElementIds: Set<string>;
-}): ExpandedRow[] {
- let maxHeight = 0;
- let maxRows: ExpandedRow[] = [];
-
- for (const element of track.elements) {
- if (!expandedElementIds.has(element.id)) continue;
- const rows = getExpandedRows({ animations: element.animations });
- const height = getExpansionHeight({ rows });
- if (height > maxHeight) {
- maxHeight = height;
- maxRows = rows;
- }
- }
-
- return maxRows;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
deleted file mode 100644
index 3f78350b..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-"use client";
-
-import { useRef, useState, type PointerEvent } from "react";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { getBezierPoint } from "@/lib/animation/bezier";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import { cn } from "@/utils/ui";
-
-const GRAPH_WIDTH = 140;
-const GRAPH_HEIGHT = 94;
-const GRAPH_PADDING = 12;
-const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
-const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
-const HANDLE_RADIUS = 3.5;
-const ENDPOINT_RADIUS = 2;
-const SNAP_THRESHOLD = 0.06;
-const SNAP_TARGETS = [0, 1];
-const CURVE_SEGMENTS = 64;
-const Y_CLAMP_MIN = -0.5;
-const Y_CLAMP_MAX = 1.5;
-
-type BezierHandle = "c1" | "c2";
-
-export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
-
-function snap({
- value,
- targets,
- isEnabled,
-}: {
- value: number;
- targets: number[];
- isEnabled: boolean;
-}) {
- if (!isEnabled) return value;
- for (const target of targets) {
- if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
- }
- return value;
-}
-
-function toSvgX({ value }: { value: number }) {
- return GRAPH_PADDING + value * GRAPH_WIDTH;
-}
-
-function toSvgY({ value }: { value: number }) {
- return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
-}
-
-function fromSvgX({ svgX }: { svgX: number }) {
- return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
-}
-
-function fromSvgY({ svgY }: { svgY: number }) {
- return Math.max(
- Y_CLAMP_MIN,
- Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
- );
-}
-
-function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
- const points: string[] = [];
- for (let i = 0; i <= CURVE_SEGMENTS; i++) {
- const progress = i / CURVE_SEGMENTS;
- const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
- const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
- points.push(`${x},${y}`);
- }
- return `M${points.join("L")}`;
-}
-
-function clampHandleY({ svgY }: { svgY: number }) {
- return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
-}
-
-export function BezierGraph({
- value,
- onChange,
- onChangeEnd,
- onCancel,
-}: {
- value: NormalizedCubicBezier;
- onChange?: (value: NormalizedCubicBezier) => void;
- onChangeEnd?: (value: NormalizedCubicBezier) => void;
- onCancel?: () => void;
-}) {
- const svgRef = useRef<SVGSVGElement>(null);
- const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
- const isShiftPressedRef = useShiftKey();
- const latestValueRef = useRef(value);
-
- latestValueRef.current = value;
-
- function getPointerPosition({
- event,
- }: {
- event: PointerEvent;
- }): { x: number; y: number } {
- const svg = svgRef.current;
- if (!svg) return { x: 0, y: 0 };
- const rect = svg.getBoundingClientRect();
- const scale = SVG_WIDTH / rect.width;
- return {
- x: (event.clientX - rect.left) * scale,
- y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
- };
- }
-
- function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
- return (event: PointerEvent<SVGCircleElement>) => {
- event.preventDefault();
- event.stopPropagation();
- setActiveHandle(handle);
- event.currentTarget.setPointerCapture(event.pointerId);
- };
- }
-
- function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
- if (!activeHandle) return;
- const pointerPos = getPointerPosition({ event });
- const x = fromSvgX({ svgX: pointerPos.x });
- const y = snap({
- value: fromSvgY({ svgY: pointerPos.y }),
- targets: SNAP_TARGETS,
- isEnabled: !isShiftPressedRef.current,
- });
- const next: NormalizedCubicBezier = [...value];
- if (activeHandle === "c1") {
- next[0] = x;
- next[1] = y;
- } else {
- next[2] = x;
- next[3] = y;
- }
- latestValueRef.current = next;
- onChange?.(next);
- }
-
- function onPointerUp() {
- if (!activeHandle) return;
- setActiveHandle(null);
- onChangeEnd?.(latestValueRef.current);
- }
-
- function onPointerCancel() {
- if (!activeHandle) return;
- setActiveHandle(null);
- onCancel?.();
- }
-
- const path = curvePath({ curve: value });
- const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
- const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
- const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
- const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
- const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
- const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
-
- return (
- <svg
- ref={svgRef}
- viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
- className="bg-foreground/3 w-full cursor-crosshair select-none"
- onPointerMove={(event) => onPointerMove({ event })}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerCancel}
- >
- <title>Bezier curve editor</title>
- <line
- x1={p0.x}
- y1={p0.y}
- x2={p1.x}
- y2={p1.y}
- className="stroke-foreground/8"
- strokeWidth={1}
- strokeDasharray="3 3"
- />
- <line
- x1={p0.x}
- y1={p0.y}
- x2={c1Clamped.x}
- y2={c1Clamped.y}
- className="stroke-primary/30"
- strokeWidth={1}
- />
- <line
- x1={p1.x}
- y1={p1.y}
- x2={c2Clamped.x}
- y2={c2Clamped.y}
- className="stroke-primary/30"
- strokeWidth={1}
- />
- <path
- d={path}
- fill="none"
- className="stroke-primary"
- strokeWidth={2}
- strokeLinecap="round"
- />
- <circle
- cx={p0.x}
- cy={p0.y}
- r={ENDPOINT_RADIUS}
- className="fill-foreground/20"
- />
- <circle
- cx={p1.x}
- cy={p1.y}
- r={ENDPOINT_RADIUS}
- className="fill-foreground/20"
- />
- <circle
- cx={c1Clamped.x}
- cy={c1Clamped.y}
- r={HANDLE_RADIUS}
- className={cn(
- "fill-primary cursor-grab",
- activeHandle === "c1" && "cursor-grabbing",
- )}
- onPointerDown={onHandlePointerDown({ handle: "c1" })}
- />
- <circle
- cx={c2Clamped.x}
- cy={c2Clamped.y}
- r={HANDLE_RADIUS}
- className={cn(
- "fill-primary cursor-grab",
- activeHandle === "c2" && "cursor-grabbing",
- )}
- onPointerDown={onHandlePointerDown({ handle: "c2" })}
- />
- </svg>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
deleted file mode 100644
index 684ac7c9..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { useSyncExternalStore } from "react";
-import { generateUUID } from "@/utils/id";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import type { EasingPreset } from "./easing-presets";
-
-const STORAGE_KEY = "graph-editor-presets";
-
-let cachedPresets: EasingPreset[] | null = null;
-const listeners = new Set<() => void>();
-
-function isValidPresetArray(value: unknown): value is EasingPreset[] {
- return (
- Array.isArray(value) &&
- value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- typeof item.id === "string" &&
- typeof item.label === "string" &&
- Array.isArray(item.value) &&
- item.value.length === 4 &&
- item.value.every((number: unknown) => typeof number === "number"),
- )
- );
-}
-
-function readFromStorage(): EasingPreset[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return [];
- const parsed: unknown = JSON.parse(raw);
- return isValidPresetArray(parsed) ? parsed : [];
- } catch {
- // Silently recover — corrupted localStorage shouldn't crash the editor
- return [];
- }
-}
-
-function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
-}
-
-function getSnapshot(): EasingPreset[] {
- cachedPresets ??= readFromStorage();
- return cachedPresets;
-}
-
-function getServerSnapshot(): EasingPreset[] {
- return [];
-}
-
-function notify(): void {
- cachedPresets = null;
- for (const listener of listeners) {
- listener();
- }
-}
-
-function onStorageChange(event: StorageEvent): void {
- if (event.key === STORAGE_KEY) notify();
-}
-
-function subscribe(listener: () => void): () => void {
- if (listeners.size === 0 && typeof window !== "undefined") {
- window.addEventListener("storage", onStorageChange);
- }
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- if (listeners.size === 0 && typeof window !== "undefined") {
- window.removeEventListener("storage", onStorageChange);
- }
- };
-}
-
-export function useCustomPresets(): EasingPreset[] {
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-}
-
-export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
- const current = getSnapshot();
- writeToStorage({
- presets: [
- ...current,
- {
- id: generateUUID(),
- label: `Custom ${current.length + 1}`,
- value,
- isCustom: true,
- },
- ],
- });
- notify();
-}
-
-export function removePreset({ id }: { id: string }): void {
- writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
- notify();
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
deleted file mode 100644
index 6ccc9071..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-
-export const PRESET_MATCH_TOLERANCE = 0.02;
-
-export interface EasingPreset {
- id: string;
- label: string;
- value: NormalizedCubicBezier;
- isCustom?: boolean;
-}
-
-export const BUILTIN_PRESETS: EasingPreset[] = [
- { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
- { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
- { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
- { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
- { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
- { id: "linear", label: "Linear", value: [0, 0, 1, 1] },
-];
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
deleted file mode 100644
index 733195ed..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
+++ /dev/null
@@ -1,334 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Popover, PopoverContent } from "@/components/ui/popover";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowDown01Icon,
- Delete02Icon,
- PlusSignIcon,
-} from "@hugeicons/core-free-icons";
-import { getBezierPoint } from "@/lib/animation/bezier";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import type { GraphEditorComponentOption } from "./session";
-import {
- BUILTIN_PRESETS,
- PRESET_MATCH_TOLERANCE,
- type EasingPreset,
-} from "./easing-presets";
-import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
-import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
-
-const COLLAPSED_MAX = 6;
-const THUMB_SEGMENTS = 24;
-const THUMB_WIDTH = 40;
-const THUMB_HEIGHT = 22;
-const THUMB_PADDING_X = 4;
-const THUMB_PADDING_Y = 3;
-const COLLAPSED_GRID_MAX_HEIGHT = 120;
-const EXPANDED_GRID_MAX_HEIGHT = 240;
-
-export function GraphEditorPopover({
- children,
- side,
- open,
- onOpenChange,
- value,
- message,
- componentOptions,
- activeComponentKey,
- onActiveComponentKeyChange,
- onPreviewValue,
- onCommitValue,
- onCancelPreview,
-}: {
- children: React.ReactNode;
- side?: "top" | "bottom" | "left" | "right";
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- value: NormalizedCubicBezier | null;
- message: string;
- componentOptions: GraphEditorComponentOption[];
- activeComponentKey: string | null;
- onActiveComponentKeyChange?: (componentKey: string) => void;
- onPreviewValue?: (value: NormalizedCubicBezier) => void;
- onCommitValue?: (value: NormalizedCubicBezier) => void;
- onCancelPreview?: () => void;
-}) {
- const [isExpanded, setIsExpanded] = useState(false);
- const custom = useCustomPresets();
- const allPresets = [...BUILTIN_PRESETS, ...custom];
- const canEdit = value !== null;
- const activePresetId =
- value == null
- ? null
- : (allPresets.find((preset) =>
- preset.value.every(
- (presetValue, index) =>
- Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
- ),
- )?.id ?? null);
-
- return (
- <Popover
- open={open}
- onOpenChange={(nextOpen) => {
- if (!nextOpen) {
- onCancelPreview?.();
- }
- onOpenChange?.(nextOpen);
- }}
- >
- {children}
- <PopoverContent
- side={side}
- sideOffset={8}
- className="w-60 overflow-hidden px-0"
- >
- {componentOptions.length > 1 && (
- <div className="border-b px-3 py-2">
- <div className="bg-muted/40 inline-flex rounded-md p-0.5">
- {componentOptions.map((component) => (
- <button
- key={component.key}
- type="button"
- onClick={() => onActiveComponentKeyChange?.(component.key)}
- className={cn(
- "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
- activeComponentKey === component.key
- ? "bg-background text-foreground shadow-xs"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {component.label}
- </button>
- ))}
- </div>
- </div>
- )}
-
- <div className="px-3 py-3">
- {value ? (
- <BezierGraph
- value={value}
- onChange={onPreviewValue}
- onChangeEnd={onCommitValue}
- onCancel={onCancelPreview}
- />
- ) : (
- <GraphEditorEmptyState message={message} />
- )}
- </div>
-
- <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
- <TabsList className="px-3">
- <TabsTrigger value="presets" className="text-xs">
- Presets
- </TabsTrigger>
- <TabsTrigger value="saved" className="text-xs">
- Saved
- </TabsTrigger>
- </TabsList>
- <TabsContent value="presets" className="px-3 pb-0">
- <ExpandableGrid
- isExpanded={isExpanded}
- shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
- onExpand={() => setIsExpanded(true)}
- >
- {BUILTIN_PRESETS.map((preset) => (
- <PresetItem
- key={preset.id}
- preset={preset}
- isActive={activePresetId === preset.id}
- disabled={!canEdit}
- onSelect={() => onCommitValue?.(preset.value)}
- />
- ))}
- </ExpandableGrid>
- </TabsContent>
- <TabsContent value="saved" className="px-3">
- <div className="grid grid-cols-3 gap-1">
- {custom.map((preset) => (
- <PresetItem
- key={preset.id}
- preset={preset}
- isActive={activePresetId === preset.id}
- disabled={!canEdit}
- onSelect={() => onCommitValue?.(preset.value)}
- onDelete={() => removePreset({ id: preset.id })}
- />
- ))}
- <button
- type="button"
- onClick={() => value && savePreset({ value })}
- disabled={!canEdit}
- className={cn(
- "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
- canEdit
- ? "hover:bg-foreground/5 cursor-pointer"
- : "cursor-not-allowed opacity-50",
- )}
- >
- <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
- <HugeiconsIcon
- icon={PlusSignIcon}
- className="size-3.5 opacity-40"
- />
- </div>
- <span className="text-[10px] leading-tight">Save</span>
- </button>
- </div>
- </TabsContent>
- </Tabs>
- </PopoverContent>
- </Popover>
- );
-}
-
-function GraphEditorEmptyState({ message }: { message: string }) {
- return (
- <div
- style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
- className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
- >
- {message}
- </div>
- );
-}
-
-function ExpandableGrid({
- children,
- isExpanded,
- shouldExpand,
- onExpand,
-}: {
- children: React.ReactNode;
- isExpanded: boolean;
- shouldExpand: boolean;
- onExpand: () => void;
-}) {
- const gridStyle = shouldExpand
- ? isExpanded
- ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
- : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
- : undefined;
-
- return (
- <div className="relative">
- <div className="grid grid-cols-3 gap-1" style={gridStyle}>
- {children}
- </div>
- {!isExpanded && shouldExpand && (
- <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
- <Button
- variant="ghost"
- size="icon"
- className="size-5"
- onClick={onExpand}
- >
- <HugeiconsIcon
- icon={ArrowDown01Icon}
- className="text-muted-foreground size-3"
- />
- </Button>
- </div>
- )}
- </div>
- );
-}
-
-function PresetItem({
- preset,
- isActive,
- onSelect,
- onDelete,
- disabled,
-}: {
- preset: EasingPreset;
- isActive: boolean;
- onSelect: () => void;
- onDelete?: () => void;
- disabled?: boolean;
-}) {
- return (
- <button
- type="button"
- onClick={onSelect}
- disabled={disabled}
- className={cn(
- "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
- disabled
- ? "cursor-not-allowed opacity-50"
- : "hover:bg-foreground/5 cursor-pointer",
- isActive && "bg-primary/5! text-primary",
- )}
- >
- <div
- className={cn(
- "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
- isActive && "bg-primary/5!",
- )}
- >
- <CurveThumb value={preset.value} />
- </div>
- <span
- className={cn(
- "text-[10px] leading-tight",
- isActive ? "text-primary" : "text-muted-foreground",
- )}
- >
- {preset.label}
- </span>
- {onDelete && (
- <Button
- variant="destructive"
- size="icon"
- className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
- onClick={(event) => {
- event.stopPropagation();
- onDelete();
- }}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- )}
- </button>
- );
-}
-
-function toThumbX({ value }: { value: number }) {
- return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2);
-}
-
-function toThumbY({ value }: { value: number }) {
- return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2);
-}
-
-function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
- const points: string[] = [];
- for (let i = 0; i <= THUMB_SEGMENTS; i++) {
- const progress = i / THUMB_SEGMENTS;
- const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) });
- const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) });
- points.push(`${x},${y}`);
- }
- return (
- <svg
- width={THUMB_WIDTH}
- height={THUMB_HEIGHT}
- viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
- >
- <title>Curve preset preview</title>
- <path
- d={`M${points.join("L")}`}
- fill="none"
- className="stroke-current"
- strokeWidth={1.5}
- strokeLinecap="round"
- />
- </svg>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
deleted file mode 100644
index cc0fb070..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ /dev/null
@@ -1,704 +0,0 @@
-import {
- getCurveHandlesForNormalizedCubicBezier,
- getEditableScalarChannels,
- getEasingModeForKind,
- getNormalizedCubicBezierForScalarSegment,
- getScalarKeyframeContext,
- updateScalarKeyframeCurve,
-} from "@/lib/animation";
-import type {
- AnimationPath,
- ElementAnimations,
- NormalizedCubicBezier,
- ScalarCurveKeyframePatch,
- ScalarGraphKeyframeContext,
- SelectedKeyframeRef,
-} from "@/lib/animation/types";
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-
-const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
-const FLAT_VALUE_EPSILON = 1e-6;
-const LINEAR_CURVE_EPSILON = 1e-6;
-
-export type GraphEditorUnavailableReason =
- | "no-keyframe-selected"
- | "multiple-keyframes-selected"
- | "selected-keyframes-span-multiple-elements"
- | "selected-keyframes-are-not-adjacent"
- | "selected-properties-have-no-shared-component"
- | "selected-element-missing"
- | "selected-element-has-no-animations"
- | "selected-keyframe-has-no-scalar-channel"
- | "selected-keyframe-missing-on-channel"
- | "selected-keyframe-has-no-next-segment"
- | "selected-segment-is-hold"
- | "selected-segment-is-flat";
-
-export interface GraphEditorComponentOption {
- key: string;
- label: string;
-}
-
-interface GraphEditorPropertyOption {
- key: string;
- label: string;
- context: ScalarGraphKeyframeContext;
- allContexts: ScalarGraphKeyframeContext[];
-}
-
-export interface GraphEditorResolvedSegment {
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframeId: string;
- context: ScalarGraphKeyframeContext;
- allContexts: ScalarGraphKeyframeContext[];
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}
-
-interface GraphEditorBaseSelectionState {
- componentOptions: GraphEditorComponentOption[];
- activeComponentKey: string | null;
- message: string;
-}
-
-export interface GraphEditorUnavailableState
- extends GraphEditorBaseSelectionState {
- status: "unavailable";
- reason: GraphEditorUnavailableReason;
-}
-
-export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
- status: "ready";
- trackId: string;
- elementId: string;
- element: TimelineElement;
- segments: GraphEditorResolvedSegment[];
- cubicBezier: NormalizedCubicBezier;
-}
-
-export type GraphEditorSelectionState =
- | GraphEditorUnavailableState
- | GraphEditorReadyState;
-
-export interface GraphEditorCurvePatch {
- keyframeId: string;
- patch: ScalarCurveKeyframePatch;
-}
-
-function createUnavailableState({
- reason,
- message,
- componentOptions = [],
- activeComponentKey = null,
-}: {
- reason: GraphEditorUnavailableReason;
- message: string;
- componentOptions?: GraphEditorComponentOption[];
- activeComponentKey?: string | null;
-}): GraphEditorUnavailableState {
- return {
- status: "unavailable",
- reason,
- message,
- componentOptions,
- activeComponentKey,
- };
-}
-
-function findElementByKeyframe({
- tracks,
- keyframe,
-}: {
- tracks: SceneTracks;
- keyframe: SelectedKeyframeRef;
-}): { element: TimelineElement; trackId: string; elementId: string } | null {
- for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
- if (track.id !== keyframe.trackId) {
- continue;
- }
-
- const element = track.elements.find(
- (trackElement) => trackElement.id === keyframe.elementId,
- );
- if (!element) {
- return null;
- }
-
- return {
- element,
- trackId: track.id,
- elementId: element.id,
- };
- }
-
- return null;
-}
-
-function findKeyframeTime({
- animations,
- propertyPath,
- keyframeId,
-}: {
- animations: ElementAnimations;
- propertyPath: AnimationPath;
- keyframeId: string;
-}): number | null {
- const binding = animations.bindings[propertyPath];
- if (!binding) return null;
-
- for (const component of binding.components) {
- const channel = animations.channels[component.channelId];
- if (channel?.kind !== "scalar") continue;
- const key = channel.keys.find((k) => k.id === keyframeId);
- if (key !== undefined) return key.time;
- }
-
- return null;
-}
-
-function groupSelectedKeyframesByProperty({
- selectedKeyframes,
-}: {
- selectedKeyframes: SelectedKeyframeRef[];
-}) {
- const groups = new Map<
- string,
- {
- trackId: string;
- elementId: string;
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframes: SelectedKeyframeRef[];
- }
- >();
-
- for (const keyframe of selectedKeyframes) {
- const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
- const existingGroup = groups.get(groupKey);
- if (existingGroup) {
- existingGroup.keyframes.push(keyframe);
- continue;
- }
-
- groups.set(groupKey, {
- trackId: keyframe.trackId,
- elementId: keyframe.elementId,
- propertyPath: keyframe.propertyPath,
- keyframes: [keyframe],
- });
- }
-
- return [...groups.values()];
-}
-
-function getComponentLabel({ componentKey }: { componentKey: string }): string {
- switch (componentKey) {
- case "value":
- return "Value";
- default:
- return componentKey.toUpperCase();
- }
-}
-
-/**
- * Returns the absolute value span of the nearest non-flat adjacent segment,
- * used as the Y-axis scale when editing a flat segment in the graph editor.
- * Falls back to 1.0 if all surrounding segments are also flat.
- */
-function getReferenceSpanValue({
- context,
-}: {
- context: ScalarGraphKeyframeContext;
-}): number {
- const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
- const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id);
- const rightIndex = context.nextKey
- ? sorted.findIndex((k) => k.id === context.nextKey?.id)
- : -1;
-
- for (let i = leftIndex - 1; i >= 0; i--) {
- const span = Math.abs(sorted[i + 1].value - sorted[i].value);
- if (span > FLAT_VALUE_EPSILON) return span;
- }
-
- if (rightIndex !== -1) {
- for (let i = rightIndex; i < sorted.length - 1; i++) {
- const span = Math.abs(sorted[i + 1].value - sorted[i].value);
- if (span > FLAT_VALUE_EPSILON) return span;
- }
- }
-
- return 1.0;
-}
-
-interface GraphEditorPropertySelection {
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframeId: string;
- secondaryKeyframeId: string | null;
- options: GraphEditorPropertyOption[];
-}
-
-function resolvePropertySelection({
- element,
- propertyKeyframes,
-}: {
- element: TimelineElement;
- propertyKeyframes: ReturnType<
- typeof groupSelectedKeyframesByProperty
- >[number];
-}):
- | GraphEditorPropertySelection
- | {
- reason: GraphEditorUnavailableReason;
- message: string;
- } {
- if (propertyKeyframes.keyframes.length > 2) {
- return {
- reason: "multiple-keyframes-selected",
- message: "Select at most two adjacent keyframes per property.",
- };
- }
-
- if (!element.animations) {
- return {
- reason: "selected-element-has-no-animations",
- message: "The selected keyframe has no editable graph.",
- };
- }
-
- const scalarResult = getEditableScalarChannels({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- });
- if (!scalarResult || scalarResult.channels.length === 0) {
- return {
- reason: "selected-keyframe-has-no-scalar-channel",
- message: "The selected keyframe has no editable graph channel.",
- };
- }
-
- const primaryKeyframe = propertyKeyframes.keyframes[0];
- let resolvedKeyframeId = primaryKeyframe.keyframeId;
- let secondaryKeyframeId =
- propertyKeyframes.keyframes.length === 2
- ? propertyKeyframes.keyframes[1].keyframeId
- : null;
-
- if (secondaryKeyframeId !== null) {
- const time1 = findKeyframeTime({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: primaryKeyframe.keyframeId,
- });
- const time2 = findKeyframeTime({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: secondaryKeyframeId,
- });
- if (time2 !== null && (time1 === null || time2 < time1)) {
- resolvedKeyframeId = secondaryKeyframeId;
- secondaryKeyframeId = primaryKeyframe.keyframeId;
- }
- }
-
- const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
- const easingMode = getEasingModeForKind(resolvedBinding.kind);
- const contexts = scalarChannels.flatMap((channel) => {
- const context = getScalarKeyframeContext({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- componentKey: channel.componentKey,
- keyframeId: resolvedKeyframeId,
- });
- if (!context) {
- return [];
- }
-
- return [
- {
- context,
- option: {
- key: channel.componentKey,
- label: getComponentLabel({ componentKey: channel.componentKey }),
- },
- },
- ];
- });
-
- if (contexts.length === 0) {
- return {
- reason: "selected-keyframe-missing-on-channel",
- message: "The selected keyframe is not editable as a graph segment.",
- };
- }
-
- // For shared-easing bindings (e.g. color), all components always use the same
- // curve. Collapse them to a single "value" option so the key is compatible with
- // single-component scalar bindings (e.g. opacity), enabling mixed selections.
- const options =
- easingMode === "shared"
- ? [
- {
- key: "value",
- label: "Curve",
- context: contexts[0].context,
- allContexts: contexts.map(({ context }) => context),
- },
- ]
- : contexts.map(({ context, option }) => ({
- key: option.key,
- label: option.label,
- context,
- allContexts: [context],
- }));
-
- return {
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: resolvedKeyframeId,
- secondaryKeyframeId,
- options,
- };
-}
-
-function isLinearCurve({
- cubicBezier,
-}: {
- cubicBezier: NormalizedCubicBezier;
-}): boolean {
- return (
- Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
- );
-}
-
-function resolveSegmentForOption({
- propertySelection,
- componentKey,
-}: {
- propertySelection: GraphEditorPropertySelection;
- componentKey: string;
-}):
- | {
- segment: GraphEditorResolvedSegment;
- }
- | {
- reason: GraphEditorUnavailableReason;
- message: string;
- } {
- const option = propertySelection.options.find(
- (propertyOption) => propertyOption.key === componentKey,
- );
- if (!option) {
- return {
- reason: "selected-properties-have-no-shared-component",
- message: "Selected properties do not share a graph-editable channel.",
- };
- }
-
- if (!option.context.nextKey) {
- return {
- reason: "selected-keyframe-has-no-next-segment",
- message: "Select a keyframe that has an outgoing segment.",
- };
- }
-
- if (
- propertySelection.secondaryKeyframeId !== null &&
- option.context.nextKey.id !== propertySelection.secondaryKeyframeId
- ) {
- return {
- reason: "selected-keyframes-are-not-adjacent",
- message: "Selected keyframes must be adjacent on each property.",
- };
- }
-
- if (option.context.keyframe.segmentToNext === "step") {
- return {
- reason: "selected-segment-is-hold",
- message: "Hold segments have a fixed value - easing has no effect here.",
- };
- }
-
- const referenceSpanValue = getReferenceSpanValue({ context: option.context });
- const cubicBezier =
- option.context.keyframe.segmentToNext === "linear"
- ? GRAPH_LINEAR_CURVE
- : getNormalizedCubicBezierForScalarSegment({
- leftKey: option.context.keyframe,
- rightKey: option.context.nextKey,
- referenceSpanValue,
- });
- if (!cubicBezier) {
- return {
- reason: "selected-segment-is-flat",
- message:
- "Cannot edit a segment where both keyframes are at the same time.",
- };
- }
-
- return {
- segment: {
- propertyPath: propertySelection.propertyPath,
- keyframeId: propertySelection.keyframeId,
- context: option.context,
- allContexts: option.allContexts,
- cubicBezier,
- referenceSpanValue,
- },
- };
-}
-
-export function resolveGraphEditorSelectionState({
- tracks,
- selectedKeyframes,
- preferredComponentKey,
-}: {
- tracks: SceneTracks;
- selectedKeyframes: SelectedKeyframeRef[];
- preferredComponentKey?: string | null;
-}): GraphEditorSelectionState {
- if (selectedKeyframes.length === 0) {
- return createUnavailableState({
- reason: "no-keyframe-selected",
- message: "Select a keyframe to edit its curve.",
- });
- }
-
- const propertyKeyframes = groupSelectedKeyframesByProperty({
- selectedKeyframes,
- });
- const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
- if (!primaryKeyframe) {
- return createUnavailableState({
- reason: "no-keyframe-selected",
- message: "Select a keyframe to edit its curve.",
- });
- }
-
- const selectedElement = findElementByKeyframe({
- tracks,
- keyframe: primaryKeyframe,
- });
- if (!selectedElement) {
- return createUnavailableState({
- reason: "selected-element-missing",
- message: "The selected keyframe could not be resolved.",
- });
- }
-
- const spansMultipleElements = propertyKeyframes.some(
- (propertySelection) =>
- propertySelection.trackId !== selectedElement.trackId ||
- propertySelection.elementId !== selectedElement.elementId,
- );
- if (spansMultipleElements) {
- return createUnavailableState({
- reason: "selected-keyframes-span-multiple-elements",
- message: "Selected keyframes must be on the same element.",
- });
- }
-
- const propertySelections = propertyKeyframes.map((propertySelection) =>
- resolvePropertySelection({
- element: selectedElement.element,
- propertyKeyframes: propertySelection,
- }),
- );
- const unavailablePropertySelection = propertySelections.find(
- (propertySelection) => "reason" in propertySelection,
- );
- if (
- unavailablePropertySelection &&
- "reason" in unavailablePropertySelection
- ) {
- return createUnavailableState({
- reason: unavailablePropertySelection.reason,
- message: unavailablePropertySelection.message,
- });
- }
-
- const resolvedPropertySelections = propertySelections.filter(
- (propertySelection): propertySelection is GraphEditorPropertySelection =>
- !("reason" in propertySelection),
- );
- const sharedComponentOptions =
- resolvedPropertySelections[0]?.options.filter((componentOption) =>
- resolvedPropertySelections.every((propertySelection) =>
- propertySelection.options.some(
- (option) => option.key === componentOption.key,
- ),
- ),
- ) ?? [];
- const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
- key,
- label,
- }));
- if (componentOptions.length === 0) {
- return createUnavailableState({
- reason: "selected-properties-have-no-shared-component",
- message: "Selected properties do not share a graph-editable channel.",
- });
- }
-
- // Try each component option in preference order (preferred first, then the rest)
- // and stop at the first key where every property resolves to a valid segment.
- // This single pass both selects the active key and produces the segment list.
- const candidateKeys = [
- ...(preferredComponentKey &&
- componentOptions.some((option) => option.key === preferredComponentKey)
- ? [preferredComponentKey]
- : []),
- ...componentOptions
- .filter((option) => option.key !== preferredComponentKey)
- .map((option) => option.key),
- ];
-
- let activeComponentKey = componentOptions[0].key;
- let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
-
- for (const candidateKey of candidateKeys) {
- const results = resolvedPropertySelections.map((propertySelection) =>
- resolveSegmentForOption({
- propertySelection,
- componentKey: candidateKey,
- }),
- );
- activeComponentKey = candidateKey;
- segmentResults = results;
- if (results.every((result) => "segment" in result)) {
- break;
- }
- }
-
- const unavailableSegment = segmentResults.find(
- (result) => !("segment" in result),
- );
- if (unavailableSegment && !("segment" in unavailableSegment)) {
- return createUnavailableState({
- reason: unavailableSegment.reason,
- message: unavailableSegment.message,
- componentOptions,
- activeComponentKey,
- });
- }
-
- const segments = segmentResults.flatMap((result) =>
- "segment" in result ? [result.segment] : [],
- );
- const primarySegment = segments[0];
- if (!primarySegment) {
- return createUnavailableState({
- reason: "selected-keyframe-missing-on-channel",
- message: "The selected keyframe is not editable as a graph segment.",
- componentOptions,
- activeComponentKey,
- });
- }
-
- return {
- status: "ready",
- message:
- segments.length === 1
- ? "Edit graph"
- : `Edit graph for ${segments.length} properties`,
- componentOptions,
- activeComponentKey,
- trackId: selectedElement.trackId,
- elementId: selectedElement.elementId,
- element: selectedElement.element,
- segments,
- cubicBezier: primarySegment.cubicBezier,
- };
-}
-
-export function buildGraphEditorCurvePatches({
- context,
- cubicBezier,
- referenceSpanValue,
-}: {
- context: ScalarGraphKeyframeContext;
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}): GraphEditorCurvePatch[] | null {
- if (!context.nextKey) {
- return null;
- }
-
- if (isLinearCurve({ cubicBezier })) {
- return [
- {
- keyframeId: context.keyframe.id,
- patch: {
- segmentToNext: "linear",
- rightHandle: null,
- },
- },
- {
- keyframeId: context.nextKey.id,
- patch: {
- leftHandle: null,
- },
- },
- ];
- }
-
- const handles = getCurveHandlesForNormalizedCubicBezier({
- leftKey: context.keyframe,
- rightKey: context.nextKey,
- cubicBezier,
- referenceSpanValue,
- });
- if (!handles) {
- return null;
- }
-
- return [
- {
- keyframeId: context.keyframe.id,
- patch: {
- segmentToNext: "bezier",
- rightHandle: handles.rightHandle,
- },
- },
- {
- keyframeId: context.nextKey.id,
- patch: {
- leftHandle: handles.leftHandle,
- },
- },
- ];
-}
-
-export function applyGraphEditorCurvePreview({
- animations,
- context,
- cubicBezier,
- referenceSpanValue,
-}: {
- animations: ElementAnimations | undefined;
- context: ScalarGraphKeyframeContext;
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}): ElementAnimations | undefined {
- const patches = buildGraphEditorCurvePatches({
- context,
- cubicBezier,
- referenceSpanValue,
- });
- if (!patches) {
- return animations;
- }
-
- return patches.reduce<ElementAnimations | undefined>(
- (nextAnimations, { keyframeId, patch }) =>
- updateScalarKeyframeCurve({
- animations: nextAnimations,
- propertyPath: context.propertyPath,
- componentKey: context.componentKey,
- keyframeId,
- patch,
- }),
- animations,
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
deleted file mode 100644
index 51d5a0ff..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { registerCanceller } from "@/lib/cancel-interaction";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import {
- applyGraphEditorCurvePreview,
- buildGraphEditorCurvePatches,
- resolveGraphEditorSelectionState,
- type GraphEditorSelectionState,
-} from "./session";
-
-export function useGraphEditorController() {
- const editor = useEditor();
- const renderTracks = useEditor(
- (currentEditor) =>
- currentEditor.timeline.getPreviewTracks() ??
- currentEditor.scenes.getActiveScene().tracks,
- );
- const { selectedKeyframes } = useKeyframeSelection();
- const [open, setOpen] = useState(false);
- const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
- null,
- );
- const hasPreviewRef = useRef(false);
-
- const state = useMemo<GraphEditorSelectionState>(
- () =>
- resolveGraphEditorSelectionState({
- tracks: renderTracks,
- selectedKeyframes,
- preferredComponentKey: activeComponentKey,
- }),
- [activeComponentKey, renderTracks, selectedKeyframes],
- );
-
- const stateKey =
- state.status === "ready"
- ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments
- .map(
- (segment) =>
- `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`,
- )
- .join("|")}`
- : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
- const previousStateKeyRef = useRef(stateKey);
-
- const discardPreview = useCallback(() => {
- if (!hasPreviewRef.current) {
- return;
- }
-
- editor.timeline.discardPreview();
- hasPreviewRef.current = false;
- }, [editor]);
-
- useEffect(() => {
- if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
- discardPreview();
- }
-
- previousStateKeyRef.current = stateKey;
- }, [discardPreview, stateKey]);
-
- useEffect(() => {
- if (!open) {
- return;
- }
-
- return registerCanceller({
- fn: () => {
- discardPreview();
- setOpen(false);
- },
- });
- }, [discardPreview, open]);
-
- const handleOpenChange = useCallback(
- (nextOpen: boolean) => {
- if (!nextOpen) {
- discardPreview();
- }
-
- setOpen(nextOpen);
- },
- [discardPreview],
- );
-
- const handleActiveComponentKeyChange = useCallback(
- (nextComponentKey: string) => {
- discardPreview();
- setActiveComponentKey(nextComponentKey);
- },
- [discardPreview],
- );
-
- const handlePreviewValue = useCallback(
- (nextValue: NormalizedCubicBezier) => {
- if (state.status !== "ready") {
- return;
- }
-
- const nextAnimations = state.segments.reduce(
- (animations, segment) =>
- segment.allContexts.reduce(
- (nextAnimationsForSegment, context) =>
- applyGraphEditorCurvePreview({
- animations: nextAnimationsForSegment,
- context,
- cubicBezier: nextValue,
- referenceSpanValue: segment.referenceSpanValue,
- }),
- animations,
- ),
- state.element.animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId: state.trackId,
- elementId: state.elementId,
- updates: { animations: nextAnimations },
- },
- ],
- });
- hasPreviewRef.current = true;
- },
- [editor, state],
- );
-
- const handleCommitValue = useCallback(
- (nextValue: NormalizedCubicBezier) => {
- if (state.status !== "ready") {
- return;
- }
-
- editor.timeline.updateKeyframeCurves({
- keyframes: state.segments.flatMap((segment) => {
- const patches = buildGraphEditorCurvePatches({
- context: segment.context,
- cubicBezier: nextValue,
- referenceSpanValue: segment.referenceSpanValue,
- });
- if (!patches) {
- return [];
- }
-
- return segment.allContexts.flatMap((context) =>
- patches.map(({ keyframeId, patch }) => ({
- trackId: state.trackId,
- elementId: state.elementId,
- propertyPath: segment.propertyPath,
- componentKey: context.componentKey,
- keyframeId,
- patch,
- })),
- );
- }),
- });
- hasPreviewRef.current = false;
- },
- [editor, state],
- );
-
- return {
- open,
- onOpenChange: handleOpenChange,
- canOpen: state.status === "ready",
- tooltip: state.status === "ready" ? "Open graph editor" : state.message,
- state,
- onActiveComponentKeyChange: handleActiveComponentKeyChange,
- onPreviewValue: handlePreviewValue,
- onCommitValue: handleCommitValue,
- onCancelPreview: discardPreview,
- };
-}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
deleted file mode 100644
index 20e9faf1..00000000
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ /dev/null
@@ -1,944 +0,0 @@
-"use client";
-
-import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- Delete02Icon,
- MagicWand05Icon,
- MusicNote03Icon,
- TaskAdd02Icon,
- TextIcon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
-import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
-import type { ElementDragState, DropTarget } from "@/lib/timeline";
-import { TimelineTrackContent } from "./timeline-track";
-import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "@/lib/selection/selection-box";
-import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
-import { SnapIndicator } from "./snap-indicator";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import type { TimelineTrack } from "@/lib/timeline";
-import {
- TIMELINE_SCROLLBAR_SIZE_PX,
- TIMELINE_CONTENT_TOP_PADDING_PX,
- TIMELINE_TRACK_GAP_PX,
- TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
- KEYFRAME_LANE_HEIGHT_PX,
-} from "./layout";
-import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
-import {
- canTrackHaveAudio,
- canTrackBeHidden,
- getTimelineZoomMin,
- getTimelinePaddingPx,
-} from "@/lib/timeline";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
-} from "./track-layout";
-import { SELECTED_TRACK_ROW_CLASS } from "./theme";
-import {
- computeTrackExpansionHeight,
- getTrackExpandedRows,
- getPropertyLabel,
- type ExpandedRow,
-} from "./expanded-layout";
-import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
-import { TimelineToolbar } from "./timeline-toolbar";
-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 { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
-import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
-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";
-import { resolveTimelineElementIntersections } from "./selection-hit-testing";
-import { cn } from "@/utils/ui";
-
-const TRACKS_CONTAINER_MAX_HEIGHT = 800;
-const FALLBACK_CONTAINER_WIDTH = 1000;
-const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
-const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = {
- video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
- text: (
- <HugeiconsIcon
- icon={TextIcon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- audio: (
- <HugeiconsIcon
- icon={MusicNote03Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
- effect: (
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
-};
-
-export function Timeline() {
- const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const {
- selectedElements,
- clearElementSelection,
- setElementSelection,
- mergeElementsIntoSelection,
- } = useElementSelection();
- const editor = useEditor();
- const timeline = editor.timeline;
- const scene = useEditor((currentEditor) =>
- currentEditor.scenes.getActiveSceneOrNull(),
- );
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const mainTrackId = scene?.tracks.main.id ?? null;
- const seek = (time: number) => editor.playback.seek({ time });
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const timelineHeaderRef = useRef<HTMLDivElement>(null);
- const rulerRef = useRef<HTMLDivElement>(null);
- const rulerScrollRef = 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);
-
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null,
- );
-
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
-
- 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,
- });
- const { isResizing, handleResizeStart } = useTimelineResize({
- zoomLevel,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
-
- const getTrackExpansionHeight = useCallback(
- (trackIndex: number) => {
- const track = tracks[trackIndex];
- if (!track) return 0;
- return computeTrackExpansionHeight({ track, expandedElementIds });
- },
- [tracks, expandedElementIds],
- );
-
- // Stable refs so the wheel listener never goes stale
- const setZoomLevelRef = useRef(setZoomLevel);
- useEffect(() => {
- setZoomLevelRef.current = setZoomLevel;
- }, [setZoomLevel]);
-
- const saveScrollPositionRef = useRef(saveScrollPosition);
- useEffect(() => {
- saveScrollPositionRef.current = saveScrollPosition;
- }, [saveScrollPosition]);
-
- const minZoomLevelRef = useRef(minZoomLevel);
- useEffect(() => {
- minZoomLevelRef.current = minZoomLevel;
- }, [minZoomLevel]);
-
- // Pushes tracks scroll position to the two overflow:hidden followers
- // (ruler and track labels). Called from the wheel handler (before paint,
- // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
- const syncFollowers = useCallback(() => {
- const tracks = tracksScrollRef.current;
- if (!tracks) return;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
- }
- if (trackLabelsScrollRef.current) {
- trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
- }
- }, []);
-
- // Single non-passive capture listener owns all wheel input. Prevents any
- // native scroll or browser zoom from firing inside the timeline.
- useEffect(() => {
- const container = timelineRef.current;
- if (!container) return;
-
- let pendingZoomDelta = 0;
- let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
-
- const onWheel = (e: WheelEvent) => {
- const isZoom = e.ctrlKey || e.metaKey;
-
- if (isZoom) {
- e.preventDefault();
- const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
- pendingZoomDelta += normalizedDelta;
-
- if (zoomRafId === null) {
- zoomRafId = requestAnimationFrame(() => {
- const frameRawDelta = pendingZoomDelta;
- const cappedDelta =
- Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
- setZoomLevelRef.current((prev) => prev * zoomFactor);
- pendingZoomDelta = 0;
- zoomRafId = null;
- });
- }
- return;
- }
-
- const tracks = tracksScrollRef.current;
- if (!tracks) return;
-
- const isHorizontal =
- e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
-
- e.preventDefault();
-
- if (isHorizontal) {
- const raw =
- Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
- const clamped =
- Math.sign(raw) *
- Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
- tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
- } else {
- tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
- }
-
- syncFollowers();
- saveScrollPositionRef.current();
- };
-
- container.addEventListener("wheel", onWheel, {
- passive: false,
- capture: true,
- });
- return () => {
- container.removeEventListener("wheel", onWheel, { capture: true });
- if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
- };
- }, [syncFollowers]);
-
- useInitialScrollBottom({
- tracksScrollRef,
- trackLabelsScrollRef,
- onAfterScroll: () => saveScrollPositionRef.current(),
- isReady: tracks.length > 0,
- });
-
- const {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- } = useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const {
- dragState: bookmarkDragState,
- handleBookmarkMouseDown,
- lastMouseXRef: bookmarkLastMouseXRef,
- } = useBookmarkDrag({
- zoomLevel,
- scrollRef: tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
- useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
- containerRef: tracksContainerRef,
- tracksScrollRef,
- zoomLevel,
- });
-
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- shouldIgnoreClick,
- } = useBoxSelect({
- containerRef: tracksContainerRef,
- selectedIds: selectedElements,
- anchorId: null,
- getIsAdditiveSelection: (event) =>
- event.shiftKey || event.ctrlKey || event.metaKey,
- resolveIntersections: ({ startPos, currentPos }) => {
- if (!tracksContainerRef.current) {
- return [];
- }
-
- return resolveTimelineElementIntersections({
- container: tracksContainerRef.current,
- scrollContainer: tracksScrollRef.current,
- tracks,
- zoomLevel,
- startPos,
- currentPos,
- });
- },
- onSelectionChange: ({ intersectedIds, isAdditive }) => {
- if (isAdditive) {
- mergeElementsIntoSelection({ elements: intersectedIds });
- } else {
- setElementSelection({ elements: intersectedIds });
- }
- },
- });
-
- const containerWidth =
- tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
- const contentWidth = timelineTimeToPixels({
- time: timelineDuration,
- zoomLevel,
- });
- const paddingPx = getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom: minZoomLevel,
- });
- const dynamicTimelineWidth = Math.max(
- contentWidth + paddingPx,
- containerWidth,
- );
- const tracksViewportWidth =
- tracksScrollRef.current?.clientWidth ??
- tracksContainerRef.current?.clientWidth ??
- containerWidth;
- const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
-
- useEdgeAutoScroll({
- isActive: bookmarkDragState.isDragging,
- getMouseClientX: () => bookmarkLastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: dynamicTimelineWidth,
- });
-
- const showSnapIndicator =
- snappingEnabled &&
- currentSnapPoint !== null &&
- (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
-
- const {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
- } = useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration: timeline.getTotalDuration(),
- isSelecting,
- clearSelectedElements: clearElementSelection,
- seek,
- });
-
- const timelineHeaderHeight =
- (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
- TIMELINE_CONTENT_TOP_PADDING_PX || 0;
-
- return (
- <section
- className={
- "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
- }
- {...dragProps}
- aria-label="Timeline"
- >
- <TimelineToolbar
- zoomLevel={zoomLevel}
- minZoom={minZoomLevel}
- setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
- />
-
- <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
- <TrackLabelsPanel
- trackLabelsRef={trackLabelsRef}
- trackLabelsScrollRef={trackLabelsScrollRef}
- timelineHeaderHeight={timelineHeaderHeight}
- hasHorizontalScrollbar={hasHorizontalScrollbar}
- getTrackExpansionHeight={getTrackExpansionHeight}
- />
-
- <div
- className="relative isolate 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={tracks}
- isVisible={isDragOver && !dropTarget?.targetElement}
- headerHeight={timelineHeaderHeight}
- />
- <DragLine
- dropTarget={dragDropTarget}
- tracks={tracks}
- isVisible={dragState.isDragging}
- headerHeight={timelineHeaderHeight}
- />
-
- <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
- <div
- ref={timelineHeaderRef}
- className="flex flex-col"
- style={{ width: `${dynamicTimelineWidth}px` }}
- >
- <TimelineRuler
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- rulerRef={rulerRef}
- tracksScrollRef={rulerScrollRef}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- <TimelineBookmarksRow
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- dragState={bookmarkDragState}
- onBookmarkMouseDown={handleBookmarkMouseDown}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- </div>
- </div>
-
- <ScrollArea
- className="flex-1"
- ref={tracksScrollRef}
- onScroll={() => {
- syncFollowers();
- saveScrollPosition();
- }}
- >
- <div
- className="flex min-h-full flex-col"
- style={{ width: `${dynamicTimelineWidth}px` }}
- >
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
- <div
- className="relative shrink-0"
- style={{
- height: `${
- Math.max(
- TRACKS_CONTAINER_HEIGHT.min,
- Math.min(
- TRACKS_CONTAINER_HEIGHT.max,
- getTotalTracksHeight({
- tracks,
- getExtraHeight: getTrackExpansionHeight,
- }),
- ),
- ) + TIMELINE_CONTENT_TOP_PADDING_PX
- }px`,
- }}
- 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);
- }}
- >
- {tracks.length > 0 && (
- <TimelineTrackRows
- mainTrackId={mainTrackId}
- zoomLevel={zoomLevel}
- dragState={dragState}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onResizeStart={handleResizeStart}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- onTrackMouseDown={(event) => {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackMouseUp={handleTracksClick}
- shouldIgnoreClick={shouldIgnoreClick}
- isDragOver={isDragOver}
- dropTarget={dropTarget}
- />
- )}
- </div>
- <TimelineGutter
- onMouseDown={(event) => {
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={handleTracksClick}
- />
- </div>
- </ScrollArea>
-
- <TimelinePlayhead
- zoomLevel={zoomLevel}
- hasHorizontalScrollbar={hasHorizontalScrollbar}
- rulerRef={rulerRef}
- rulerScrollRef={rulerScrollRef}
- tracksScrollRef={tracksScrollRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
- </div>
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- timelineRef={timelineRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
- />
- </div>
- </section>
- );
-}
-
-function TrackLabelsPanel({
- trackLabelsRef,
- trackLabelsScrollRef,
- timelineHeaderHeight,
- hasHorizontalScrollbar,
- getTrackExpansionHeight,
-}: {
- trackLabelsRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
- timelineHeaderHeight: number;
- hasHorizontalScrollbar: boolean;
- getTrackExpansionHeight: (trackIndex: number) => number;
-}) {
- const editor = useEditor();
- const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const { selectedElements } = useElementSelection();
- const tracksWithSelection = useMemo(
- () => new Set(selectedElements.map((el) => el.trackId)),
- [selectedElements],
- );
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
- const trackExpandedRowsMap = useMemo(
- () =>
- tracks.map((track) =>
- getTrackExpandedRows({ track, expandedElementIds }),
- ),
- [tracks, expandedElementIds],
- );
-
- return (
- <div
- className="flex shrink-0 flex-col border-r"
- style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
- >
- <div
- className="shrink-0"
- style={{ height: timelineHeaderHeight || 48 }}
- />
- <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
- <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
- {tracks.length > 0 && (
- <div
- className="flex flex-col"
- style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
- >
- {tracks.map((track, index) => {
- const expandedRows = trackExpandedRowsMap[index];
- const baseHeight = getTrackHeight({ type: track.type });
-
- return (
- <div
- key={track.id}
- className={cn(
- "group flex flex-col",
- tracksWithSelection.has(track.id) &&
- SELECTED_TRACK_ROW_CLASS,
- )}
- style={{
- height: `${baseHeight + getTrackExpansionHeight(index)}px`,
- }}
- >
- <div
- className="flex shrink-0 items-center justify-end gap-2 px-3"
- style={{ height: `${baseHeight}px` }}
- >
- {canTrackHaveAudio(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>
- {expandedRows.length > 0 && (
- <PropertyTree rows={expandedRows} />
- )}
- </div>
- );
- })}
- </div>
- )}
- </div>
- </div>
- <div
- className="bg-background shrink-0"
- style={{
- height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
- }}
- />
- </div>
- );
-}
-
-function TimelineTrackRows({
- mainTrackId,
- zoomLevel,
- dragState,
- tracksScrollRef,
- lastMouseXRef,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- isDragOver,
- dropTarget,
-}: {
- mainTrackId: string | null;
- zoomLevel: number;
- dragState: ElementDragState;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onResizeStart: React.ComponentProps<
- typeof TimelineTrackContent
- >["onResizeStart"];
- onElementMouseDown: React.ComponentProps<
- typeof TimelineTrackContent
- >["onElementMouseDown"];
- onElementClick: React.ComponentProps<
- typeof TimelineTrackContent
- >["onElementClick"];
- onTrackMouseDown: (event: React.MouseEvent) => void;
- onTrackMouseUp: (event: React.MouseEvent) => void;
- shouldIgnoreClick: () => boolean;
- isDragOver: boolean;
- dropTarget: DropTarget | null;
-}) {
- const timeline = useEditor((e) => e.timeline);
- const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const { selectedElements } = useElementSelection();
- const tracksWithSelection = useMemo(
- () => new Set(selectedElements.map((el) => el.trackId)),
- [selectedElements],
- );
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
-
- const getTrackExpansionHeight = useCallback(
- (trackIndex: number) => {
- const track = tracks[trackIndex];
- if (!track) return 0;
- return computeTrackExpansionHeight({ track, expandedElementIds });
- },
- [tracks, expandedElementIds],
- );
-
- const sortedTracks = useMemo(() => {
- const draggingElementIds = new Set(dragState.dragElementIds);
- return [...tracks]
- .map((track, index) => ({ track, index }))
- .sort((a, b) => {
- const aHasDragged = a.track.elements.some((element) =>
- draggingElementIds.has(element.id),
- );
- const bHasDragged = b.track.elements.some((element) =>
- draggingElementIds.has(element.id),
- );
- if (aHasDragged) return 1;
- if (bHasDragged) return -1;
- return 0;
- });
- }, [tracks, dragState.dragElementIds]);
-
- return (
- <>
- {sortedTracks.map(({ track, index }) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className={cn(
- "absolute right-0 left-0 transition-colors",
- tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
- )}
- style={{
- top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
- height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- dragState={dragState}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onResizeStart={onResizeStart}
- onElementMouseDown={onElementMouseDown}
- onElementClick={onElementClick}
- onTrackMouseDown={onTrackMouseDown}
- onTrackMouseUp={onTrackMouseUp}
- shouldIgnoreClick={shouldIgnoreClick}
- targetElementId={
- isDragOver
- ? (dropTarget?.targetElement?.elementId ?? null)
- : null
- }
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-40">
- <ContextMenuItem
- icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.toggleTrackMute({ trackId: track.id });
- }}
- >
- {canTrackHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={ViewIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.toggleTrackVisibility({ trackId: track.id });
- }}
- >
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </ContextMenuItem>
- {track.id !== mainTrackId && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.removeTrack({ trackId: track.id });
- }}
- variant="destructive"
- >
- Delete track
- </ContextMenuItem>
- )}
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </>
- );
-}
-
-function TimelineGutter({
- onMouseDown,
- onClick,
-}: {
- onMouseDown: (event: React.MouseEvent) => void;
- onClick: (event: React.MouseEvent) => void;
-}) {
- // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
- // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
- return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
-}
-
-function TrackIcon({ track }: { track: TimelineTrack }) {
- 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}
- />
- )}
- </>
- );
-}
-
-function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
- return (
- <div className="flex flex-col overflow-hidden">
- {rows.map((row) => (
- <div
- key={row.propertyPath}
- className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
- style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
- >
- <span className="text-muted-foreground truncate text-xs leading-none">
- {getPropertyLabel(row.propertyPath)}
- </span>
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/interaction.ts b/apps/web/src/components/editor/panels/timeline/interaction.ts
deleted file mode 100644
index fe0210a4..00000000
--- a/apps/web/src/components/editor/panels/timeline/interaction.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const TIMELINE_DRAG_THRESHOLD_PX = 5;
-export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
-export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
-export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
diff --git a/apps/web/src/components/editor/panels/timeline/layers.ts b/apps/web/src/components/editor/panels/timeline/layers.ts
deleted file mode 100644
index d59db412..00000000
--- a/apps/web/src/components/editor/panels/timeline/layers.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const TIMELINE_LAYERS = {
- trackContent: 10,
- dragLine: 20,
- playhead: 30,
- snapIndicator: 40,
-} as const;
diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts
deleted file mode 100644
index c30b7877..00000000
--- a/apps/web/src/components/editor/panels/timeline/layout.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
- video: 65,
- text: 25,
- audio: 50,
- graphic: 25,
- effect: 25,
-} as const;
-
-export const KEYFRAME_LANE_HEIGHT_PX = 20;
-export const KEYFRAME_DIAMOND_SIZE_PX = 14;
-export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18;
-
-export const TIMELINE_TRACK_GAP_PX = 6;
-export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
-export const TIMELINE_RULER_HEIGHT_PX = 22;
-export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
-export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
-export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
deleted file mode 100644
index 551393e9..00000000
--- a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type { TimelineTrack } from "@/lib/timeline";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-import {
- TIMELINE_CONTENT_TOP_PADDING_PX,
-} from "./layout";
-import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
-
-type TimelineElementRef = { trackId: string; elementId: string };
-
-interface SelectionRectangle {
- left: number;
- top: number;
- right: number;
- bottom: number;
-}
-
-function getNormalizedRectangle({
- startPos,
- endPos,
-}: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- return {
- left: Math.min(startPos.x, endPos.x),
- top: Math.min(startPos.y, endPos.y),
- right: Math.max(startPos.x, endPos.x),
- bottom: Math.max(startPos.y, endPos.y),
- };
-}
-
-function getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- const containerRect = container.getBoundingClientRect();
- const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
-
- const adjustedStart = {
- x: startPos.x - containerRect.left + scrollLeft,
- y: startPos.y - scrollRect.top + scrollTop,
- };
- const adjustedEnd = {
- x: endPos.x - containerRect.left + scrollLeft,
- y: endPos.y - scrollRect.top + scrollTop,
- };
-
- return getNormalizedRectangle({
- startPos: adjustedStart,
- endPos: adjustedEnd,
- });
-}
-
-function isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
-}: {
- elementRectangle: SelectionRectangle;
- selectionRectangle: SelectionRectangle;
-}): boolean {
- return !(
- elementRectangle.right < selectionRectangle.left ||
- elementRectangle.left > selectionRectangle.right ||
- elementRectangle.bottom < selectionRectangle.top ||
- elementRectangle.top > selectionRectangle.bottom
- );
-}
-
-export function resolveTimelineElementIntersections({
- container,
- scrollContainer,
- tracks,
- zoomLevel,
- startPos,
- currentPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- tracks: TimelineTrack[];
- zoomLevel: number;
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
-}): TimelineElementRef[] {
- const selectionRectangle = getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos: currentPos,
- });
- const selectedElements: TimelineElementRef[] = [];
-
- for (const [trackIndex, track] of tracks.entries()) {
- const trackTop = getCumulativeHeightBefore({
- tracks,
- trackIndex,
- });
- const trackHeight = getTrackHeight({ type: track.type });
- const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
- const elementBottom = elementTop + trackHeight;
-
- for (const element of track.elements) {
- const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
- const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
- const elementRectangle = {
- left: elementLeft,
- top: elementTop,
- right: elementRight,
- bottom: elementBottom,
- };
-
- if (
- isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
- })
- ) {
- selectedElements.push({
- trackId: track.id,
- elementId: element.id,
- });
- }
- }
- }
-
- return selectedElements;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
deleted file mode 100644
index a86e2a5e..00000000
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import {
- getCenteredLineLeft,
- TIMELINE_INDICATOR_LINE_WIDTH_PX,
-} from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-interface SnapIndicatorProps {
- snapPoint: SnapPoint | null;
- zoomLevel: number;
- isVisible: boolean;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-export function SnapIndicator({
- snapPoint,
- zoomLevel,
- isVisible,
- timelineRef,
- tracksScrollRef,
-}: SnapIndicatorProps) {
- const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- tracksScrollRef,
- });
-
- if (!isVisible || !snapPoint) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
- top: topPosition,
- height: `${height}px`,
- width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
- zIndex: TIMELINE_LAYERS.snapIndicator,
- }}
- >
- <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts
deleted file mode 100644
index 0afe3eb4..00000000
--- a/apps/web/src/components/editor/panels/timeline/theme.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)";
-
-export const TIMELINE_TRACK_THEME: Record<
- TrackType,
- {
- elementClassName: string;
- waveformColor?: string;
- }
-> = {
- video: { elementClassName: "transparent" },
- text: { elementClassName: "bg-[#5DBAA0]" },
- audio: {
- elementClassName: "bg-[#8F5DBA]",
- waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR,
- },
- graphic: { elementClassName: "bg-[#BA5D7A]" },
- effect: { elementClassName: "bg-[#5d93ba]" },
-} as const;
-
-export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
-export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
-
-export function getTimelineElementClassName({
- type,
-}: {
- type: TrackType;
-}): string {
- return TIMELINE_TRACK_THEME[type].elementClassName.trim();
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
deleted file mode 100644
index 75a2dfa8..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ /dev/null
@@ -1,1305 +0,0 @@
-"use client";
-
-import { createContext, useContext } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
-import { AudioVolumeLine } from "./audio-volume-line";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import {
- useKeyframeDrag,
- type KeyframeDragState,
-} from "@/hooks/timeline/element/use-keyframe-drag";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
-import { SelectionBox } from "@/lib/selection/selection-box";
-import { getElementKeyframes } from "@/lib/animation";
-import {
- canElementHaveAudio,
- canElementBeHidden,
- hasElementEffects,
- hasMediaId,
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { getTrackHeight } from "./track-layout";
-import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import type {
- TimelineElement as TimelineElementType,
- TimelineTrack,
- ElementDragState,
- VideoElement,
- ImageElement,
- AudioElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import {
- canToggleSourceAudio,
- getSourceAudioActionLabel,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
-import { getTimelinePixelsPerSecond } from "@/lib/timeline";
-import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
-import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
-import {
- getActionDefinition,
- type TAction,
- type TActionWithOptionalArgs,
- invokeAction,
-} from "@/lib/actions";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { resolveStickerId } from "@/lib/stickers";
-import { buildGraphicPreviewUrl } from "@/lib/graphics";
-import Image from "next/image";
-import {
- ScissorIcon,
- Delete02Icon,
- Copy01Icon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
- VolumeMute02Icon,
- Search01Icon,
- Exchange01Icon,
- KeyframeIcon,
- MagicWand05Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { uppercase } from "@/utils/string";
-import { useMemo, type ComponentProps, type ReactNode } from "react";
-import type {
- SelectedKeyframeRef,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { cn } from "@/utils/ui";
-import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
-import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
-import {
- getExpandedRows,
- getExpansionHeight,
- type ExpandedRow,
-} from "./expanded-layout";
-
-const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
-const ELEMENT_RING_WIDTH_PX = 1.5;
-
-const PixelsPerSecondContext = createContext<number | null>(null);
-const THUMBNAIL_ASPECT_RATIO = 16 / 9;
-
-interface KeyframeIndicator {
- time: number;
- offsetPx: number;
- keyframes: SelectedKeyframeRef[];
-}
-
-export function buildKeyframeIndicator({
- keyframe,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
-}: {
- keyframe: ElementKeyframe;
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
-}): {
- time: number;
- offsetPx: number;
- keyframeRef: SelectedKeyframeRef;
-} {
- const keyframeRef = {
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- keyframeId: keyframe.id,
- };
- const keyframeLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + keyframe.time,
- zoomLevel,
- });
- return {
- time: keyframe.time,
- offsetPx: keyframeLeft - elementLeft,
- keyframeRef,
- };
-}
-
-export function getKeyframeIndicators({
- keyframes,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
-}: {
- keyframes: ElementKeyframe[];
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
- elementWidth: number;
-}): KeyframeIndicator[] {
- if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
- return [];
- }
-
- const keyframesByTime = new Map<number, KeyframeIndicator>();
- for (const keyframe of keyframes) {
- const indicator = buildKeyframeIndicator({
- keyframe,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- });
- const existingIndicator = keyframesByTime.get(indicator.time);
- if (!existingIndicator) {
- keyframesByTime.set(indicator.time, {
- time: indicator.time,
- offsetPx: indicator.offsetPx,
- keyframes: [indicator.keyframeRef],
- });
- continue;
- }
-
- existingIndicator.keyframes.push(indicator.keyframeRef);
- }
-
- return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
-}
-
-export function getDisplayShortcut({ action }: { 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;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- dragState: ElementDragState;
- isDropTarget?: boolean;
-}
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- dragState,
- isDropTarget = false,
-}: TimelineElementProps) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const { selectedElements } = useElementSelection();
- const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
- const { renderElement } = useElementPreview({
- trackId: track.id,
- elementId: element.id,
- fallback: element,
- });
-
- let mediaAsset: MediaAsset | null = null;
-
- if (hasMediaId(element)) {
- mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- }
-
- const hasAudio = mediaSupportsAudio({ media: mediaAsset });
-
- const isCurrentElementSelected = selectedElements.some(
- (selected) =>
- selected.elementId === element.id && selected.trackId === track.id,
- );
-
- const isBeingDragged = dragState.dragElementIds.includes(element.id);
- const dragOffsetY =
- isBeingDragged && dragState.isDragging
- ? dragState.currentMouseY - dragState.startMouseY
- : 0;
- const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime + dragTimeOffset
- : renderElement.startTime;
- const displayedStartTime = elementStartTime;
- const displayedDuration = renderElement.duration;
- const elementWidth = timelineTimeToPixels({
- time: displayedDuration,
- zoomLevel,
- });
- const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
- const elementLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime,
- zoomLevel,
- });
- const keyframeIndicators = isSelected
- ? getKeyframeIndicators({
- keyframes: getElementKeyframes({ animations: element.animations }),
- trackId: track.id,
- elementId: element.id,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
- })
- : [];
-
- const {
- keyframeDragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
- } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
-
- const elementKeyframes = getElementKeyframes({
- animations: element.animations,
- });
-
- const isExpanded = useTimelineStore((s) =>
- s.expandedElementIds.has(element.id),
- );
- const toggleElementExpanded = useTimelineStore(
- (s) => s.toggleElementExpanded,
- );
- const expandedRows = useMemo(
- () =>
- isExpanded ? getExpandedRows({ animations: element.animations }) : [],
- [isExpanded, element.animations],
- );
-
- const {
- containerRef: expandedLanesRef,
- selectionBox: keyframeSelectionBox,
- isBoxSelecting: isKeyframeBoxSelecting,
- handleExpandedAreaMouseDown,
- handleExpandedAreaClick,
- } = useKeyframeBoxSelect({
- trackId: track.id,
- elementId: element.id,
- rows: expandedRows,
- keyframes: elementKeyframes,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- });
-
- const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
- event.stopPropagation();
- if (hasMediaId(element)) {
- requestRevealMedia(element.mediaId);
- }
- };
-
- const isMuted = canElementHaveAudio(element) && element.muted === true;
- const canToggleCurrentSourceAudio =
- selectedElements.length === 1 &&
- isCurrentElementSelected &&
- canToggleSourceAudio(element, mediaAsset);
- const sourceAudioLabel =
- element.type === "video"
- ? getSourceAudioActionLabel({ element })
- : "Extract audio";
- const isElementSourceAudioSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
- const hasKeyframes = elementKeyframes.length > 0;
- const expansionHeight = getExpansionHeight({ rows: expandedRows });
- const baseTrackHeight = getTrackHeight({ type: track.type });
-
- const expandedContent =
- isExpanded && expandedRows.length > 0 ? (
- <ExpandedKeyframeLanes
- rows={expandedRows}
- keyframes={elementKeyframes}
- trackId={track.id}
- elementId={element.id}
- displayedStartTime={displayedStartTime}
- zoomLevel={zoomLevel}
- elementLeft={elementLeft}
- keyframeDragState={keyframeDragState}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- containerRef={expandedLanesRef}
- onLaneMouseDown={handleExpandedAreaMouseDown}
- onLaneClick={handleExpandedAreaClick}
- selectionBox={keyframeSelectionBox}
- isBoxSelecting={isKeyframeBoxSelecting}
- />
- ) : null;
-
- return (
- <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}>
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className="absolute top-0 select-none"
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- height:
- expandedRows.length > 0
- ? `${baseTrackHeight + expansionHeight}px`
- : "100%",
- transform:
- isBeingDragged && dragState.isDragging
- ? `translate3d(0, ${dragOffsetY}px, 0)`
- : undefined,
- }}
- >
- <ElementInner
- element={element}
- displayElement={renderElement}
- track={track}
- isSelected={isSelected}
- isExpanded={expandedRows.length > 0}
- baseTrackHeight={baseTrackHeight}
- expandedContent={expandedContent}
- onElementClick={onElementClick}
- onElementMouseDown={onElementMouseDown}
- onResizeStart={onResizeStart}
- isDropTarget={isDropTarget}
- />
- {isSelected && (
- <div
- className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
- style={{ height: `${baseTrackHeight}px` }}
- >
- <KeyframeIndicators
- indicators={keyframeIndicators}
- dragState={keyframeDragState}
- displayedStartTime={displayedStartTime}
- elementLeft={elementLeft}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- />
- </div>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-64">
- <ActionMenuItem
- action="split"
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- >
- Split
- </ActionMenuItem>
- <CopyMenuItem />
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Duplicate
- </ActionMenuItem>
- )}
- {canElementHaveAudio(element) && hasAudio && (
- <MuteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- isMuted={isMuted}
- />
- )}
- {canToggleCurrentSourceAudio && (
- <ContextMenuItem
- icon={
- <HugeiconsIcon
- icon={
- isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
- }
- />
- }
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("toggle-source-audio");
- }}
- >
- {sourceAudioLabel}
- </ContextMenuItem>
- )}
- {canElementBeHidden(element) && (
- <VisibilityMenuItem
- element={element}
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- />
- )}
- {hasKeyframes && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={KeyframeIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- toggleElementExpanded(element.id);
- }}
- >
- {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
- </ContextMenuItem>
- )}
- {selectedElements.length === 1 && hasMediaId(element) && (
- <>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event: React.MouseEvent) =>
- 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>
- </PixelsPerSecondContext.Provider>
- );
-}
-
-function ElementInner({
- element,
- displayElement,
- track,
- isSelected,
- isExpanded,
- baseTrackHeight,
- expandedContent,
- onElementClick,
- onElementMouseDown,
- onResizeStart,
- isDropTarget = false,
-}: {
- element: TimelineElementType;
- displayElement?: TimelineElementType;
- track: TimelineTrack;
- isSelected: boolean;
- isExpanded: boolean;
- baseTrackHeight: number;
- expandedContent: React.ReactNode;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- isDropTarget?: boolean;
-}) {
- const visibleElement = displayElement ?? element;
- const isReducedOpacity =
- (canElementBeHidden(visibleElement) && visibleElement.hidden) ||
- isDropTarget;
- return (
- <div
- className="absolute top-0 bottom-0"
- style={{
- left: `${ELEMENT_RING_WIDTH_PX}px`,
- right: `${ELEMENT_RING_WIDTH_PX}px`,
- }}
- >
- <div
- className="absolute inset-0 rounded-sm"
- style={
- isSelected
- ? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
- }
- : undefined
- }
- >
- <div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- isExpanded && "bg-background",
- )}
- >
- <button
- type="button"
- tabIndex={-1}
- className="absolute inset-0 size-full flex flex-col"
- onClick={(event) => onElementClick(event, element)}
- onMouseDown={(event) => onElementMouseDown(event, element)}
- >
- <div
- className={cn(
- "flex shrink-0 items-center overflow-hidden",
- getTimelineElementClassName({
- type: getTrackTypeForElementType({
- elementType: element.type,
- }),
- }),
- isReducedOpacity && "opacity-50",
- )}
- style={{ height: `${baseTrackHeight}px` }}
- >
- <div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
- <ElementContent element={visibleElement} track={track} />
- </div>
- </div>
- {expandedContent}
- </button>
- </div>
- </div>
-
- {isSelected && (
- <>
- <ResizeHandle
- side="left"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- <ResizeHandle
- side="right"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- </>
- )}
- </div>
- );
-}
-
-function ResizeHandle({
- side,
- element,
- track,
- onResizeStart,
-}: {
- side: "left" | "right";
- element: TimelineElementType;
- track: TimelineTrack;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
-}) {
- const isLeft = side === "left";
- return (
- <button
- type="button"
- className={cn(
- "absolute top-0 bottom-0 w-2",
- isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
- )}
- onMouseDown={(event) => onResizeStart({ event, element, track, side })}
- onClick={(event) => event.stopPropagation()}
- aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
- ></button>
- );
-}
-
-function KeyframeIndicators({
- indicators,
- dragState,
- displayedStartTime,
- elementLeft,
- onKeyframeMouseDown,
- onKeyframeClick,
- getVisualOffsetPx,
-}: {
- indicators: KeyframeIndicator[];
- dragState: KeyframeDragState;
- displayedStartTime: number;
- elementLeft: number;
- onKeyframeMouseDown: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => void;
- onKeyframeClick: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => void;
- getVisualOffsetPx: (params: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }) => number;
-}) {
- const { isKeyframeSelected } = useKeyframeSelection();
- const orderedKeyframes = indicators.flatMap(
- (indicator) => indicator.keyframes,
- );
-
- return indicators.map((indicator) => {
- const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
- const isBeingDragged = indicator.keyframes.some((keyframe) =>
- dragState.draggingKeyframeIds.has(keyframe.keyframeId),
- );
- const visualOffsetPx = getVisualOffsetPx({
- indicatorTime: indicator.time,
- indicatorOffsetPx: indicator.offsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- });
-
- return (
- <button
- key={indicator.time}
- type="button"
- className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
- style={{ left: visualOffsetPx }}
- onMouseDown={(event) =>
- onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
- }
- onClick={(event) =>
- onKeyframeClick({
- event,
- keyframes: indicator.keyframes,
- orderedKeyframes,
- indicatorTime: indicator.time,
- })
- }
- aria-label="Select keyframe"
- >
- <HugeiconsIcon
- icon={KeyframeIcon}
- className={cn(
- "size-3.5 text-black",
- isIndicatorSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- });
-}
-
-function ExpandedKeyframeLanes({
- rows,
- keyframes,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- keyframeDragState,
- onKeyframeMouseDown,
- onKeyframeClick,
- getVisualOffsetPx,
- containerRef,
- onLaneMouseDown,
- onLaneClick,
- selectionBox,
- isBoxSelecting,
-}: {
- rows: ExpandedRow[];
- keyframes: ElementKeyframe[];
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
- keyframeDragState: KeyframeDragState;
- onKeyframeMouseDown: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => void;
- containerRef: React.RefObject<HTMLDivElement | null>;
- onLaneMouseDown: (event: React.MouseEvent) => void;
- onLaneClick: (event: React.MouseEvent) => void;
- selectionBox: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
- } | null;
- isBoxSelecting: boolean;
- onKeyframeClick: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => void;
- getVisualOffsetPx: (params: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }) => number;
-}) {
- const { isKeyframeSelected } = useKeyframeSelection();
-
- const orderedKeyframes = useMemo(
- () =>
- [...keyframes]
- .sort(
- (a, b) =>
- a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
- )
- .map((kf) => ({
- trackId,
- elementId,
- propertyPath: kf.propertyPath,
- keyframeId: kf.id,
- })),
- [keyframes, trackId, elementId],
- );
-
- return (
- // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
- // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
- <div
- ref={containerRef}
- className="relative flex flex-col"
- onMouseDown={onLaneMouseDown}
- onClick={onLaneClick}
- >
- {rows.map((row) => {
- const laneKeyframes = keyframes.filter(
- (kf) => kf.propertyPath === row.propertyPath,
- );
- return (
- <div
- key={row.propertyPath}
- className={cn("relative flex items-center bg-muted/50")}
- style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
- >
- {laneKeyframes.map((kf) => {
- const keyframeRef: SelectedKeyframeRef = {
- trackId,
- elementId,
- propertyPath: row.propertyPath,
- keyframeId: kf.id,
- };
- const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
- kf.id,
- );
- const kfLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + kf.time,
- zoomLevel,
- });
- const offsetPx = kfLeft - elementLeft;
- const visualOffset = getVisualOffsetPx({
- indicatorTime: kf.time,
- indicatorOffsetPx: offsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- });
- const isSelected = isKeyframeSelected({
- keyframe: keyframeRef,
- });
-
- return (
- <button
- key={kf.id}
- type="button"
- className={cn(
- "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
- isBoxSelecting && "pointer-events-none",
- )}
- style={{ left: visualOffset }}
- onMouseDown={(event) => {
- event.stopPropagation();
- onKeyframeMouseDown({
- event,
- keyframes: [keyframeRef],
- });
- }}
- onClick={(event) => {
- event.stopPropagation();
- onKeyframeClick({
- event,
- keyframes: [keyframeRef],
- orderedKeyframes,
- indicatorTime: kf.time,
- });
- }}
- aria-label="Select keyframe"
- >
- <HugeiconsIcon
- icon={KeyframeIcon}
- className={cn(
- "size-3.5 text-black mr-1",
- isSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- })}
- </div>
- );
- })}
- {selectionBox && (
- <SelectionBox
- startPos={selectionBox.startPos}
- currentPos={selectionBox.currentPos}
- containerRef={containerRef}
- isActive={selectionBox.isActive}
- />
- )}
- </div>
- );
-}
-
-interface ElementContentProps {
- element: TimelineElementType;
- track: TimelineTrack;
-}
-
-function TextElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { 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>
- );
-}
-
-function EffectElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "effect" }>;
-}) {
- return (
- <div className="flex size-full items-center justify-start gap-1 pl-2">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-4 shrink-0 text-white"
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function StickerElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "sticker" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: element.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function GraphicElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "graphic" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={buildGraphicPreviewUrl({
- definitionId: element.definitionId,
- params: element.params,
- size: 20,
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function AudioElementContent({
- element,
- trackId,
-}: {
- element: AudioElement;
- trackId: string;
-}) {
- const pixelsPerSecond = useContext(PixelsPerSecondContext);
- if (pixelsPerSecond === null) {
- throw new Error(
- "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider",
- );
- }
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const mediaAsset =
- element.sourceType === "upload"
- ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
- : null;
-
- const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
- const audioUrl =
- element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
- const sourceFile =
- element.sourceType === "upload" ? mediaAsset?.file : undefined;
- const sourceKey =
- element.sourceType === "upload"
- ? buildWaveformSourceKey({ kind: "media", id: element.mediaId })
- : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl });
- const mediaLabel = mediaAsset?.name ?? element.name;
- const gainSamples = useMemo(
- () =>
- buildWaveformGainSamples({
- element,
- count: WAVEFORM_GAIN_SAMPLE_COUNT,
- }),
- [element],
- );
- if (audioBuffer || audioUrl || sourceFile) {
- return (
- <div className="group/audio relative size-full">
- <MediaElementHeader name={mediaLabel} hasFade={false} />
- <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden">
- <AudioWaveform
- sourceKey={sourceKey}
- sourceFile={sourceFile}
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- gainSamples={gainSamples}
- pixelsPerSecond={pixelsPerSecond}
- clipDurationSec={element.duration / TICKS_PER_SECOND}
- retime={element.retime}
- sourceStartSec={element.trimStart / TICKS_PER_SECOND}
- color={TIMELINE_TRACK_THEME.audio.waveformColor}
- />
- <AudioVolumeLine element={element} trackId={trackId} />
- </div>
- </div>
- );
- }
-
- return (
- <div className="group/audio relative size-full">
- <div className="flex size-full items-center pl-2">
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
- </div>
- <AudioVolumeLine element={element} trackId={trackId} />
- </div>
- );
-}
-
-function EffectsButton({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const editor = useEditor();
- const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
-
- const handleClick = (event: React.MouseEvent) => {
- event.stopPropagation();
- editor.selection.setSelectedElements({
- elements: [{ trackId: track.id, elementId: element.id }],
- });
- setActiveTab(element.type, "effects");
- };
-
- return (
- <button
- type="button"
- className="flex shrink-0 justify-center text-white cursor-pointer"
- onMouseDown={(event) => event.stopPropagation()}
- onClick={handleClick}
- >
- <HugeiconsIcon icon={MagicWand05Icon} size={12} />
- </button>
- );
-}
-
-function TiledMediaContent({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
-
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- const imageUrl =
- element.type === "video"
- ? mediaAsset?.thumbnailUrl
- : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
-
- if (!imageUrl) {
- return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
- );
- }
-
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-
- return (
- <>
- <div
- className="absolute inset-0"
- style={{
- backgroundColor: "var(--muted)",
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
- <MediaElementHeader
- name={mediaAsset?.name}
- leading={
- hasElementEffects({ element }) ? (
- <EffectsButton element={element} track={track} />
- ) : null
- }
- hasFade={true}
- />
- </>
- );
-}
-
-function MediaElementHeader({
- name,
- leading,
- hasFade,
-}: {
- name?: string | null;
- leading?: ReactNode;
- hasFade?: boolean;
-}) {
- if (!name && !leading) {
- return null;
- }
-
- return (
- <div
- className={cn(
- "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1",
- hasFade && "from-black/30 to-transparent",
- )}
- >
- {leading && <div className="pl-1">{leading}</div>}
- {name && (
- <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
- {name}
- </span>
- )}
- </div>
- );
-}
-
-function ElementContent({ element, track }: ElementContentProps) {
- switch (element.type) {
- case "text":
- return <TextElementContent element={element} />;
- case "effect":
- return <EffectElementContent element={element} />;
- case "sticker":
- return <StickerElementContent element={element} />;
- case "graphic":
- return <GraphicElementContent element={element} />;
- case "audio":
- return <AudioElementContent element={element} trackId={track.id} />;
- case "video":
- case "image":
- return <TiledMediaContent element={element} track={track} />;
- }
-}
-
-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={VolumeOffIcon} />
- ) : (
- <HugeiconsIcon icon={VolumeHighIcon} />
- );
- };
-
- 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: TActionWithOptionalArgs;
- children: ReactNode;
-}) {
- return (
- <ContextMenuItem
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction(action);
- }}
- textRight={getDisplayShortcut({ action })}
- {...props}
- >
- {children}
- </ContextMenuItem>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
deleted file mode 100644
index d9218371..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import {
- getCenteredLineLeft,
- TIMELINE_INDICATOR_LINE_WIDTH_PX,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useEditor } from "@/hooks/use-editor";
-import {
- TIMELINE_SCROLLBAR_SIZE_PX,
-} from "./layout";
-import { TIMELINE_LAYERS } from "./layers";
-
-interface TimelinePlayheadProps {
- zoomLevel: number;
- hasHorizontalScrollbar: boolean;
- rulerRef: React.RefObject<HTMLDivElement | null>;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- playheadRef?: React.RefObject<HTMLDivElement | null>;
- isSnappingToPlayhead?: boolean;
-}
-
-export function TimelinePlayhead({
- zoomLevel,
- hasHorizontalScrollbar,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
-}: TimelinePlayheadProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
-
- const { handlePlayheadMouseDown } = useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const timelineContainerHeight =
- timelineRef.current?.clientHeight ??
- tracksScrollRef.current?.clientHeight ??
- 400;
- const totalHeight = Math.max(
- 0,
- timelineContainerHeight -
- (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
- );
-
- const currentTime = editor.playback.getCurrentTime();
- const centerPosition = timelineTimeToSnappedPixels({
- time: currentTime,
- zoomLevel,
- });
- const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
- const leftPosition =
- getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
-
- const handlePlayheadKeyDown = (
- event: React.KeyboardEvent<HTMLDivElement>,
- ) => {
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
-
- event.preventDefault();
- const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
- const direction = event.key === "ArrowRight" ? 1 : -1;
- const now = editor.playback.getCurrentTime();
- const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame));
-
- editor.playback.seek({ time: nextTime });
- };
-
- return (
- <div
- ref={playheadRef}
- role="slider"
- aria-label="Timeline playhead"
- aria-valuemin={0}
- aria-valuemax={duration}
- aria-valuenow={currentTime}
- tabIndex={0}
- className="pointer-events-none absolute"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
- zIndex: TIMELINE_LAYERS.playhead,
- }}
- onKeyDown={handlePlayheadKeyDown}
- >
- <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
-
- <button
- type="button"
- aria-label="Drag playhead"
- className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
- onMouseDown={handlePlayheadMouseDown}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
deleted file mode 100644
index 7b69e820..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { type JSX, useLayoutEffect, useRef } from "react";
-import {
- BASE_TIMELINE_PIXELS_PER_SECOND,
-} from "@/lib/timeline/scale";
-import { mediaTimeToSeconds } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
-import { DEFAULT_FPS } from "@/lib/fps/defaults";
-import { useEditor } from "@/hooks/use-editor";
-import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
-import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
-import { TimelineTick } from "./timeline-tick";
-
-interface TimelineRulerProps {
- zoomLevel: number;
- dynamicTimelineWidth: number;
- rulerRef: React.Ref<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLElement | null>;
- 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,
- tracksScrollRef,
- handleWheel,
- handleTimelineContentClick,
- handleRulerTrackingMouseDown,
- handleRulerMouseDown,
-}: TimelineRulerProps) {
- const durationTicks = useEditor((e) => e.timeline.getTotalDuration());
- const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
- const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
- const fps =
- useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
- const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
- zoomLevel,
- fps,
- });
- const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
-
- const { scrollLeft, viewportWidth } = useScrollPosition({
- scrollRef: tracksScrollRef,
- });
-
- /**
- * widens the virtualization buffer during zoom transitions.
- * useScrollPosition lags one frame behind the scroll adjustment
- * that useLayoutEffect applies after a zoom change.
- */
- const prevZoomRef = useRef(zoomLevel);
- const isZoomTransition = zoomLevel !== prevZoomRef.current;
- const bufferPx = isZoomTransition
- ? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
- : 200;
-
- useLayoutEffect(() => {
- prevZoomRef.current = zoomLevel;
- }, [zoomLevel]);
-
- const visibleStartTimeSeconds = Math.max(
- 0,
- (scrollLeft - bufferPx) / pixelsPerSecond,
- );
- const visibleEndTimeSeconds =
- (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
-
- const startTickIndex = Math.max(
- 0,
- Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
- );
- const endTickIndex = Math.min(
- tickCount - 1,
- Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
- );
-
- const timelineTicks: Array<JSX.Element> = [];
- for (
- let tickIndex = startTickIndex;
- tickIndex <= endTickIndex;
- tickIndex += 1
- ) {
- const timeSeconds = tickIndex * tickIntervalSeconds;
- if (timeSeconds > effectiveDurationSeconds) break;
-
- const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
- const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds });
- timelineTicks.push(
- <TimelineTick
- key={tickIndex}
- time={timeTicks}
- timeInSeconds={timeSeconds}
- zoomLevel={zoomLevel}
- fps={fps}
- showLabel={showLabel}
- />,
- );
- }
-
- return (
- <div
- role="slider"
- tabIndex={0}
- aria-label="Timeline ruler"
- aria-valuemin={0}
- aria-valuemax={effectiveDurationSeconds}
- aria-valuenow={0}
- className="relative flex-1 overflow-x-visible"
- style={{ height: TIMELINE_RULER_HEIGHT_PX }}
- onWheel={handleWheel}
- onClick={(event) => {
- // Ruler seek already happens on mousedown via playhead scrubbing.
- // Forwarding the follow-up click re-enters the selection-clearing path.
- if (event.target === event.currentTarget) {
- handleTimelineContentClick(event);
- }
- }}
- onMouseDown={handleRulerTrackingMouseDown}
- onKeyDown={() => {}}
- >
- <div
- role="none"
- ref={rulerRef}
- className="relative cursor-default select-none"
- style={{
- height: TIMELINE_RULER_HEIGHT_PX,
- width: `${dynamicTimelineWidth}px`,
- }}
- onMouseDown={handleRulerMouseDown}
- >
- {timelineTicks}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
deleted file mode 100644
index fca55752..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import type { FrameRate } from "opencut-wasm";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
-
-interface TimelineTickProps {
- time: number;
- timeInSeconds: number;
- zoomLevel: number;
- fps: FrameRate;
- showLabel: boolean;
-}
-
-export function TimelineTick({
- time,
- timeInSeconds,
- zoomLevel,
- fps,
- showLabel,
-}: TimelineTickProps) {
- const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
-
- if (showLabel) {
- const label = formatRulerLabel({ timeInSeconds, fps });
- return (
- <span
- className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
- style={{ left: `${leftPosition}px` }}
- >
- {label}
- </span>
- );
- }
-
- return (
- <div
- className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
- style={{ left: `${leftPosition}px` }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
deleted file mode 100644
index 80ea10d0..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip";
-import { Button } from "@/components/ui/button";
-import {
- SplitButton,
- SplitButtonLeft,
- SplitButtonRight,
- SplitButtonSeparator,
-} from "@/components/ui/split-button";
-import { Slider } from "@/components/ui/slider";
-import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
-import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
-import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
-import { ScenesView } from "@/components/editor/scenes-view";
-import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
-import {
- canToggleSourceAudio,
- getSourceAudioActionLabel,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import { hasMediaId } from "@/lib/timeline";
-import { cn } from "@/utils/ui";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- Bookmark02Icon,
- Delete02Icon,
- SnowIcon,
- ScissorIcon,
- MagnetIcon,
- SearchAddIcon,
- SearchMinusIcon,
- Copy01Icon,
- AlignLeftIcon,
- AlignRightIcon,
- Link02Icon,
- Layers01Icon,
- Chart03Icon,
- Unlink02Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { OcRippleIcon } from "@/components/icons";
-import { GraphEditorPopover } from "./graph-editor/popover";
-import { PopoverTrigger } from "@/components/ui/popover";
-import { useGraphEditorController } from "./graph-editor/use-controller";
-
-export function TimelineToolbar({
- zoomLevel,
- minZoom,
- setZoomLevel,
-}: {
- zoomLevel: number;
- minZoom: number;
- setZoomLevel: ({ zoom }: { zoom: number }) => void;
-}) {
- const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
- const newZoomLevel =
- direction === "in"
- ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
- : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
- 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 mediaAssets = useEditor((currentEditor) =>
- currentEditor.media.getAssets(),
- );
- const { selectedElements } = useElementSelection();
- const graphEditor = useGraphEditorController();
- const isCurrentlyBookmarked = useEditor((e) =>
- e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
- );
- const selectedElement =
- selectedElements.length === 1
- ? (editor.timeline.getElementsWithTracks({
- elements: selectedElements,
- })[0] ?? null)
- : null;
- const selectedMediaAsset = (() => {
- if (!selectedElement) {
- return null;
- }
-
- const { element } = selectedElement;
- if (!hasMediaId(element)) {
- return null;
- }
-
- return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- })();
- const canToggleSelectedSourceAudio =
- !!selectedElement &&
- canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
- const sourceAudioLabel =
- selectedElement?.element.type === "video"
- ? getSourceAudioActionLabel({
- element: selectedElement.element,
- })
- : "Extract audio";
- const isSelectedSourceAudioSeparated =
- selectedElement?.element.type === "video" &&
- isSourceAudioSeparated({
- element: selectedElement.element,
- });
-
- const handleAction = ({
- action,
- event,
- }: {
- action: TActionWithOptionalArgs;
- event: React.MouseEvent;
- }) => {
- event.stopPropagation();
- invokeAction(action);
- };
-
- return (
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <ToolbarButton
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- tooltip="Split element"
- onClick={({ event }) => handleAction({ action: "split", event })}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={AlignLeftIcon} />}
- tooltip="Split left"
- onClick={({ event }) => handleAction({ action: "split-left", event })}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={AlignRightIcon} />}
- tooltip="Split right"
- onClick={({ event }) =>
- handleAction({ action: "split-right", event })
- }
- />
-
- <ToolbarButton
- icon={
- <HugeiconsIcon
- icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
- />
- }
- tooltip={sourceAudioLabel}
- disabled={!canToggleSelectedSourceAudio}
- onClick={({ event }) =>
- handleAction({ action: "toggle-source-audio", event })
- }
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- tooltip="Duplicate element"
- onClick={({ event }) =>
- handleAction({ action: "duplicate-selected", event })
- }
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Freeze frame (coming soon)"
- disabled={true}
- onClick={({ event: _event }) => {}}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- tooltip="Delete element"
- onClick={({ event }) =>
- handleAction({ action: "delete-selected", event })
- }
- />
-
- <div className="bg-border mx-1 h-6 w-px" />
-
- <Tooltip>
- <ToolbarButton
- icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
- onClick={({ event }) =>
- handleAction({ action: "toggle-bookmark", event })
- }
- />
- </Tooltip>
-
- <GraphEditorPopover
- open={graphEditor.open}
- onOpenChange={graphEditor.onOpenChange}
- value={
- graphEditor.state.status === "ready"
- ? graphEditor.state.cubicBezier
- : null
- }
- message={graphEditor.state.message}
- componentOptions={graphEditor.state.componentOptions}
- activeComponentKey={graphEditor.state.activeComponentKey}
- onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
- onPreviewValue={graphEditor.onPreviewValue}
- onCommitValue={graphEditor.onCommitValue}
- onCancelPreview={graphEditor.onCancelPreview}
- >
- <ToolbarButton
- icon={<HugeiconsIcon icon={Chart03Icon} />}
- tooltip={graphEditor.tooltip}
- disabled={!graphEditor.canOpen}
- buttonWrapper={(button) =>
- graphEditor.canOpen ? (
- <PopoverTrigger asChild>{button}</PopoverTrigger>
- ) : (
- button
- )
- }
- />
- </GraphEditorPopover>
- </TooltipProvider>
- </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={() => {}}>
- <HugeiconsIcon icon={Layers01Icon} className="size-4" />
- </SplitButtonRight>
- </ScenesView>
- </SplitButton>
- </div>
- );
-}
-
-function ToolbarRightSection({
- zoomLevel,
- minZoom,
- onZoomChange,
- onZoom,
-}: {
- zoomLevel: number;
- minZoom: number;
- onZoomChange: (zoom: number) => void;
- onZoom: (options: { direction: "in" | "out" }) => void;
-}) {
- const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
- const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
- const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
-
- return (
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <ToolbarButton
- icon={<HugeiconsIcon icon={MagnetIcon} />}
- isActive={snappingEnabled}
- tooltip="Auto snapping"
- onClick={() => toggleSnapping()}
- />
-
- <ToolbarButton
- icon={<OcRippleIcon size={24} className="scale-110" />}
- isActive={rippleEditingEnabled}
- tooltip="Ripple editing"
- onClick={() => toggleRippleEditing()}
- />
- </TooltipProvider>
-
- <div className="bg-border mx-1 h-6 w-px" />
-
- <div className="flex items-center gap-1">
- <Button
- variant="text"
- size="icon"
- onClick={() => onZoom({ direction: "out" })}
- >
- <HugeiconsIcon icon={SearchMinusIcon} />
- </Button>
- <Slider
- className="w-28"
- value={[zoomToSlider({ zoomLevel, minZoom })]}
- onValueChange={(values) =>
- onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
- }
- min={0}
- max={1}
- step={0.005}
- />
- <Button
- variant="text"
- size="icon"
- onClick={() => onZoom({ direction: "in" })}
- >
- <HugeiconsIcon icon={SearchAddIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function ToolbarButton({
- icon,
- tooltip,
- onClick,
- disabled,
- isActive,
- buttonWrapper,
-}: {
- icon: React.ReactNode;
- tooltip: string;
- onClick?: ({ event }: { event: React.MouseEvent }) => void;
- disabled?: boolean;
- isActive?: boolean;
- buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
-}) {
- const button = (
- <Button
- variant={isActive ? "secondary" : "text"}
- size="icon"
- disabled={disabled}
- onClick={onClick ? (event) => onClick({ event }) : undefined}
- className={cn(
- "rounded-sm",
- disabled ? "cursor-not-allowed opacity-50" : "",
- )}
- >
- {icon}
- </Button>
- );
- const trigger = disabled ? (
- <span className="inline-flex">{button}</span>
- ) : buttonWrapper ? (
- buttonWrapper(button)
- ) : (
- button
- );
-
- return (
- <Tooltip delayDuration={200}>
- <TooltipTrigger asChild>{trigger}</TooltipTrigger>
- <TooltipContent>{tooltip}</TooltipContent>
- </Tooltip>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
deleted file mode 100644
index 32b1b93d..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client";
-
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/lib/timeline";
-import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/lib/timeline";
-import { useEditor } from "@/hooks/use-editor";
-
-interface TimelineTrackContentProps {
- track: TimelineTrack;
- zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => 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;
- onTrackMouseUp?: (event: React.MouseEvent) => void;
- shouldIgnoreClick?: () => boolean;
- targetElementId?: string | null;
-}
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- targetElementId = null,
-}: TimelineTrackContentProps) {
- const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
-
- return (
- <div className="relative size-full">
- <button
- type="button"
- className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
- aria-label={`Select ${track.name} track`}
- onMouseUp={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
- <div
- className="relative h-full min-w-full"
- style={{ zIndex: TIMELINE_LAYERS.trackContent }}
- onMouseUp={(event) => {
- if (event.target !== event.currentTarget) return;
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- >
- {track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 pointer-events-none 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}
- onResizeStart={({ event, element, side }) =>
- onResizeStart({ event, element, track, side })
- }
- onElementMouseDown={(event, element) =>
- onElementMouseDown({ event, element, track })
- }
- onElementClick={(event, element) =>
- onElementClick({ event, element, track })
- }
- dragState={dragState}
- isDropTarget={element.id === targetElementId}
- />
- );
- })
- )}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts
deleted file mode 100644
index 8f833a57..00000000
--- a/apps/web/src/components/editor/panels/timeline/track-layout.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-import {
- KEYFRAME_LANE_HEIGHT_PX,
- TIMELINE_TRACK_GAP_PX,
- TIMELINE_TRACK_HEIGHTS_PX,
-} from "./layout";
-
-export function getTrackHeight({ type }: { type: TrackType }): number {
- return TIMELINE_TRACK_HEIGHTS_PX[type];
-}
-
-export function getExpandedTrackHeight({
- type,
- expandedLaneCount,
-}: {
- type: TrackType;
- expandedLaneCount: number;
-}): number {
- return (
- TIMELINE_TRACK_HEIGHTS_PX[type] +
- expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX
- );
-}
-
-export function getCumulativeHeightBefore({
- tracks,
- trackIndex,
- getExtraHeight,
-}: {
- tracks: Array<{ type: TrackType }>;
- trackIndex: number;
- getExtraHeight?: (trackIndex: number) => number;
-}): number {
- return tracks
- .slice(0, trackIndex)
- .reduce(
- (sum, track, i) =>
- sum +
- getTrackHeight({ type: track.type }) +
- (getExtraHeight?.(i) ?? 0) +
- TIMELINE_TRACK_GAP_PX,
- 0,
- );
-}
-
-export function getTotalTracksHeight({
- tracks,
- getExtraHeight,
-}: {
- tracks: Array<{ type: TrackType }>;
- getExtraHeight?: (trackIndex: number) => number;
-}): number {
- const tracksHeight = tracks.reduce(
- (sum, track, i) =>
- sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
- 0,
- );
- const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
- return tracksHeight + gapsHeight;
-}
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
index 34efeaff..b0f487a4 100644
--- a/apps/web/src/components/editor/scenes-view.tsx
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -21,9 +21,9 @@ import {
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
-import { canDeleteScene, getMainScene } from "@/lib/scenes";
+import { canDeleteScene, getMainScene } from "@/timeline/scenes";
import { toast } from "sonner";
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
export function ScenesView({ children }: { children: React.ReactNode }) {
const editor = useEditor();
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 21334267..b56f8c6c 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -2,8 +2,8 @@ import Link from "next/link";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
import Image from "next/image";
-import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
-import { SOCIAL_LINKS } from "@/lib/site/social";
+import { DEFAULT_LOGO_URL } from "@/site/brand";
+import { SOCIAL_LINKS } from "@/site/social";
import { capitalizeFirstLetter } from "@/utils/string";
type Category = "resources" | "company";
diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx
index 0239e366..6978b81a 100644
--- a/apps/web/src/components/gitHub-contribute-section.tsx
+++ b/apps/web/src/components/gitHub-contribute-section.tsx
@@ -1,6 +1,6 @@
import { Button } from "./ui/button";
import Link from "next/link";
-import { SOCIAL_LINKS } from "@/lib/site/social";
+import { SOCIAL_LINKS } from "@/site/social";
import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 66fec0ea..99ba1730 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -16,8 +16,8 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/utils/ui";
-import { DEFAULT_LOGO_URL, SITE_URL } from "@/lib/site/brand";
-import { SOCIAL_LINKS } from "@/lib/site/social";
+import { DEFAULT_LOGO_URL, SITE_URL } from "@/site/brand";
+import { SOCIAL_LINKS } from "@/site/social";
import {
ContextMenu,
ContextMenuContent,
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 98e6d4c6..fdc84af0 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -4,12 +4,12 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { EditorCore } from "@/core";
-import { useEditor } from "@/hooks/use-editor";
-import { useKeybindingsListener } from "@/hooks/use-keybindings";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useEditorActions } from "@/hooks/actions/use-editor-actions";
-import { loadFontAtlas } from "@/lib/fonts/google-fonts";
+import { useEditor } from "@/editor/use-editor";
+import { useKeybindingsListener } from "@/actions/use-keybindings";
+import { useKeybindingsStore } from "@/actions/keybindings-store";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { useEditorActions } from "@/actions/use-editor-actions";
+import { loadFontAtlas } from "@/fonts/google-fonts";
import {
initializeGpuRenderer,
isGpuAvailable,
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx
index fc7ae121..185a44ef 100644
--- a/apps/web/src/components/storage-provider.tsx
+++ b/apps/web/src/components/storage-provider.tsx
@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
-import { useEditor } from "@/hooks/use-editor";
+import { useEditor } from "@/editor/use-editor";
import { storageService } from "@/services/storage/service";
interface StorageContextType {
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 297d58a7..27ea4bdb 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -9,10 +9,10 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { loadFullFont } from "@/lib/fonts/google-fonts";
-import { SYSTEM_FONTS } from "@/lib/fonts/system-fonts";
-import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
-import { useFontAtlas } from "@/hooks/use-font-atlas";
+import { loadFullFont } from "@/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/fonts/system-fonts";
+import type { FontAtlas, FontAtlasEntry } from "@/fonts/types";
+import { useFontAtlas } from "@/fonts/use-font-atlas";
import { cn } from "@/utils/ui";
import { ChevronDown, Search } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts
index dbc46cfc..b9cb87d8 100644
--- a/apps/web/src/components/ui/use-overlay-open-change.ts
+++ b/apps/web/src/components/ui/use-overlay-open-change.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useId, useRef } from "react";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useKeybindingsStore } from "@/actions/keybindings-store";
export function useOverlayOpenChange({
open,