This commit is contained in:
Maze Winther
2026-02-10 16:28:21 +01:00
parent 33a8705f0d
commit fca99d6126
51 changed files with 1358 additions and 1048 deletions
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets"; import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties"; import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline"; import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview"; import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header"; import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider"; import { EditorProvider } from "@/components/providers/editor-provider";
@@ -65,7 +65,7 @@ function EditorLayout() {
defaultSize={panels.tools} defaultSize={panels.tools}
minSize={15} minSize={15}
maxSize={40} maxSize={40}
className="min-w-0 rounded-sm" className="min-w-0"
> >
<AssetsPanel /> <AssetsPanel />
</ResizablePanel> </ResizablePanel>
@@ -86,7 +86,7 @@ function EditorLayout() {
defaultSize={panels.properties} defaultSize={panels.properties}
minSize={15} minSize={15}
maxSize={40} maxSize={40}
className="min-w-0 rounded-sm" className="min-w-0"
> >
<PropertiesPanel /> <PropertiesPanel />
</ResizablePanel> </ResizablePanel>
+230 -202
View File
@@ -8,88 +8,115 @@
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
:root { :root {
--background: hsl(0, 0%, 100%); --background: hsl(0, 0%, 100%);
--foreground: hsl(0 0% 11%); --foreground: hsl(0 0% 11%);
--card: hsl(0, 0%, 100%); --card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%); --card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%); --popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%); --popover-hover: hsl(0, 0%, 96%);
--primary: hsl(203, 100%, 50%); --popover-foreground: hsl(0 0% 2%);
--primary-hover: hsl(203, 100%, 45%); --primary: #009dff;
--primary-foreground: hsl(0, 0%, 100%); --primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216 13% 94%); --secondary: hsl(204, 100%, 97%);
--secondary-foreground: hsl(0 0% 2%); --secondary-border: hsl(204, 100%, 94%);
--muted: hsl(0 0% 85.1%); --secondary-foreground: hsl(200, 98%, 39%);
--muted-foreground: hsl(0 0% 50%); --muted: hsl(0 0% 85.1%);
--accent: hsl(216, 13%, 88%); --muted-foreground: hsl(0 0% 50%);
--accent-foreground: hsl(0 0% 2%); --accent: hsl(0, 0%, 96%);
--destructive: hsl(0, 83%, 50%); --accent-foreground: hsl(0 0% 2%);
--destructive-foreground: hsl(0, 0%, 100%); --destructive: hsl(0, 83%, 50%);
--constructive: hsl(141, 71%, 48%); --destructive-foreground: hsl(0, 0%, 100%);
--constructive-foreground: hsl(0, 0%, 100%); --constructive: hsl(141, 71%, 48%);
--border: hsl(0 0% 88%); --constructive-foreground: hsl(0, 0%, 100%);
--input: hsl(0 0% 85.1%); --border: hsl(0 0% 91%);
--ring: hsl(0, 0%, 55%); --input: hsl(0 0% 85.1%);
--chart-1: hsl(220 70% 50%); --ring: hsl(0, 0%, 55%);
--chart-2: hsl(160 60% 45%); --chart-1: hsl(220 70% 50%);
--chart-3: hsl(30 80% 55%); --chart-2: hsl(160 60% 45%);
--chart-4: hsl(280 65% 60%); --chart-3: hsl(30 80% 55%);
--chart-5: hsl(340 75% 55%); --chart-4: hsl(280 65% 60%);
--sidebar-background: hsl(0 0% 96.1%); --chart-5: hsl(340 75% 55%);
--sidebar-foreground: hsl(0 0% 2%); --sidebar-background: hsl(0 0% 96.1%);
--sidebar-primary: hsl(0 0% 2%); --sidebar-foreground: hsl(0 0% 2%);
--sidebar-primary-foreground: hsl(0 0% 91%); --sidebar-primary: hsl(0 0% 2%);
--sidebar-accent: hsl(0 0% 85.1%); --sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-accent-foreground: hsl(0 0% 2%); --sidebar-accent: hsl(0 0% 85.1%);
--sidebar-border: hsl(0 0% 85.1%); --sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-ring: hsl(0 0% 16.9%); --sidebar-border: hsl(0 0% 85.1%);
--panel-background: hsl(216 13% 94%); --sidebar-ring: hsl(0 0% 16.9%);
--panel-accent: hsl(216, 8%, 88%); --sidebar: hsl(0 0% 98%);
--sidebar: hsl(0 0% 98%);
} }
.panel {
--background: hsl(216 13% 98%);
--foreground: hsl(0 0% 13%);
--card: hsl(0, 0%, 98%);
--card-foreground: hsl(0 0% 13%);
--primary-foreground: hsl(0, 0%, 98%);
--secondary: hsl(204, 100%, 95%);
--secondary-border: hsl(204, 100%, 92%);
--secondary-foreground: hsl(200, 98%, 37%);
--muted: hsl(0 0% 83.1%);
--muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 98%);
--border: hsl(0 0% 89%);
--input: hsl(0 0% 83.1%);
--ring: hsl(0, 0%, 53%);
}
.dark { .dark {
--background: hsl(0 0% 4%); --background: hsl(0, 0%, 7%);
--foreground: hsl(0 0% 89%); --foreground: hsl(0 0% 87%);
--card: hsl(0 0% 4%); --card: hsl(0, 0%, 7%);
--card-foreground: hsl(0 0% 89%); --card-foreground: hsl(0 0% 87%);
--popover: hsl(0 0% 14.9%); --popover: hsl(0, 0%, 16%);
--popover-foreground: hsl(0 0% 98%); --popover-hover: hsl(0, 0%, 22%);
--primary: hsl(203, 100%, 50%); --popover-foreground: hsl(0 0% 95%);
--primary-hover: hsl(203, 100%, 45%); --secondary: hsl(204, 100%, 12%);
--primary-foreground: hsl(0 0% 9%); --secondary-border: hsl(204, 100%, 15%);
--secondary: hsl(0 0% 14.9%); --secondary-foreground: hsl(200, 98%, 61%);
--secondary-foreground: hsl(0 0% 98%); --muted: hsl(0 0% 20%);
--muted: hsl(0 0% 14.9%); --accent: hsl(0, 0%, 14%);
--muted-foreground: hsl(0 0% 63.9%); --accent-foreground: hsl(0 0% 95%);
--accent: hsl(0, 0%, 28%); --constructive: hsl(141, 71%, 52%);
--accent-foreground: hsl(0 0% 98%); --border: hsl(0 0% 16%);
--destructive: hsl(0 83%, 55%); --input: hsl(0 0% 20%);
--destructive-foreground: hsl(0 0% 98%); --ring: hsl(0, 0%, 50%);
--constructive: hsl(141, 71%, 48%); --sidebar-background: hsl(0 0% 8%);
--constructive-foreground: hsl(0 0% 100%); --sidebar-foreground: hsl(0 0% 95%);
--border: hsl(0 0% 17%); --sidebar-primary: hsl(0 0% 95%);
--input: hsl(0 0% 14.9%); --sidebar-primary-foreground: hsl(0 0% 15%);
--ring: hsl(0 0% 83.1%); --sidebar-accent: hsl(0 0% 20%);
--chart-1: hsl(220 70% 50%); --sidebar-accent-foreground: hsl(0 0% 95%);
--chart-2: hsl(160 60% 45%); --sidebar-border: hsl(0 0% 20%);
--chart-3: hsl(30 80% 55%); --sidebar-ring: hsl(0 0% 83.1%);
--chart-4: hsl(280 65% 60%); --sidebar: hsl(0 0% 6%);
--chart-5: hsl(340 75% 55%); }
--sidebar-background: hsl(0 0% 3.9%);
--sidebar-foreground: hsl(0 0% 98%); .dark .panel {
--sidebar-primary: hsl(0 0% 98%); --background: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 9%); --foreground: hsl(0 0% 85%);
--sidebar-accent: hsl(0 0% 14.9%); --card: hsl(0, 0%, 10%);
--sidebar-accent-foreground: hsl(0 0% 98%); --card-foreground: hsl(0 0% 85%);
--sidebar-border: hsl(0 0% 14.9%); --secondary: hsl(204, 100%, 12%);
--sidebar-ring: hsl(0 0% 83.1%); --secondary-border: hsl(204, 100%, 17%);
--panel-background: hsl(0 0% 11%); --secondary-foreground: hsl(200, 98%, 63%);
--panel-accent: hsl(0 0% 15%); --muted: hsl(0 0% 22%);
--sidebar: hsl(240 5.9% 10%); --accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%);
--constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 18%);
--input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%);
} }
@layer base { @layer base {
/* /*
The default border color has changed to `currentcolor` in Tailwind CSS v4, The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3. looks the same as it did with Tailwind CSS v3.
@@ -97,165 +124,166 @@
If we ever want to remove these styles, we need to add an explicit border If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults. color utility to any element that depends on these defaults.
*/ */
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentcolor); border-color: var(--color-gray-200, currentcolor);
} }
/* Other default base styles */ /* Other default base styles */
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Prevent back/forward swipe */ /* Prevent back/forward swipe */
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
} }
::selection {
@apply bg-primary/35 selection:text-primary-foreground;
}
} }
@theme inline { @theme inline {
/* Responsive breakpoints */ /* Responsive breakpoints */
--breakpoint-xs: 30rem; --breakpoint-xs: 30rem;
/* Typography */ /* Typography */
--font-sans: var(--font-inter), sans-serif; --font-sans: var(--font-inter), sans-serif;
/* Font sizes */ /* Font sizes */
--text-xl: 1.20rem; --text-xl: 1.2rem;
--text-base: 0.92rem; --text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95); --text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8); --text-sm: 0.85rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */ /* Border radius */
--radius-lg: 0.82rem; --radius-lg: 0.82rem;
--radius-md: 0.65rem; --radius-md: 0.65rem;
--radius-sm: 0.35rem; --radius-sm: 0.35rem;
/* Palette mapped to root design tokens */ /* Palette mapped to root design tokens */
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-hover: var(--popover-hover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover); --color-secondary: var(--secondary);
--color-secondary: var(--secondary); --color-secondary-border: var(--secondary-border);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive); --color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground); --color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
/* Chart colors */ /* Chart colors */
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
/* Sidebar */ /* Sidebar */
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Panel */ /* Animations */
--color-panel: var(--panel-background); --animate-accordion-down: accordion-down 0.2s ease-out;
--color-panel-accent: var(--panel-accent); --animate-accordion-up: accordion-up 0.2s ease-out;
/* Animations */ @keyframes accordion-down {
--animate-accordion-down: accordion-down 0.2s ease-out; from {
--animate-accordion-up: accordion-up 0.2s ease-out; height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-down { @keyframes accordion-up {
from { from {
height: 0; height: var(--radix-accordion-content-height);
} }
to { to {
height: var(--radix-accordion-content-height); height: 0;
} }
} }
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
} }
@utility scrollbar-hidden { @utility scrollbar-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
} }
@utility scrollbar-x-hidden { @utility scrollbar-x-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:horizontal { &::-webkit-scrollbar:horizontal {
display: none; display: none;
} }
} }
@utility scrollbar-y-hidden { @utility scrollbar-y-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:vertical { &::-webkit-scrollbar:vertical {
display: none; display: none;
} }
} }
@utility scrollbar-thin { @utility scrollbar-thin {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
height: 8px; height: 8px;
} }
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 4px; border-radius: 4px;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground); background: var(--muted-foreground);
} }
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
+37 -50
View File
@@ -71,6 +71,11 @@ const formatProjectDuration = ({
return formatTimeCode({ timeInSeconds: duration, format }); return formatTimeCode({ timeInSeconds: duration, format });
}; };
const VIEW_MODE_OPTIONS = [
{ mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
{ mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
];
export default function ProjectsPage() { export default function ProjectsPage() {
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
const editor = useEditor(); const editor = useEditor();
@@ -147,34 +152,23 @@ function ProjectsHeader() {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="hidden md:flex rounded-md border p-1 h-10"> <div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
<button {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
type="button" <Button
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`} key={mode}
onClick={() => setViewMode({ viewMode: "grid" })} variant="ghost"
onKeyDown={(event) => size="icon"
event.key === "Enter" && setViewMode({ viewMode: "grid" }) className={cn(
} "rounded-sm hover:bg-background",
aria-label="Grid view" isHydrated && viewMode === mode && "!bg-accent",
aria-pressed={isHydrated && viewMode === "grid"} )}
> onClick={() => setViewMode({ viewMode: mode })}
<HugeiconsIcon icon={GridViewIcon} className="size-4" /> aria-label={label}
</button> aria-pressed={isHydrated && viewMode === mode}
<button >
type="button" <HugeiconsIcon icon={icon} className="size-4" />
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`} </Button>
onClick={() => setViewMode({ viewMode: "list" })} ))}
onKeyDown={(event) =>
event.key === "Enter" && setViewMode({ viewMode: "list" })
}
aria-label="List view"
aria-pressed={isHydrated && viewMode === "list"}
>
<HugeiconsIcon
icon={LeftToRightListDashIcon}
className="size-4"
/>
</button>
</div> </div>
</div> </div>
@@ -277,28 +271,21 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
<div className="h-4 w-px bg-border/50 block md:hidden" /> <div className="h-4 w-px bg-border/50 block md:hidden" />
<div className="flex md:hidden items-center gap-4"> <div className="flex md:hidden items-center gap-4">
<Button {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
variant="text" <Button
onClick={() => setViewMode({ viewMode: "grid" })} key={mode}
> variant="text"
<HugeiconsIcon onClick={() => setViewMode({ viewMode: mode })}
icon={GridViewIcon} aria-label={label}
className={cn( >
viewMode === "grid" ? "text-primary" : "text-muted-foreground", <HugeiconsIcon
)} icon={icon}
/> className={cn(
</Button> viewMode === mode ? "text-primary" : "text-muted-foreground",
<Button )}
variant="text" />
onClick={() => setViewMode({ viewMode: "list" })} </Button>
> ))}
<HugeiconsIcon
icon={LeftToRightListDashIcon}
className={cn(
viewMode === "list" ? "text-primary" : "text-muted-foreground",
)}
/>
</Button>
</div> </div>
</div> </div>
{selectedProjectCount > 0 ? <ProjectActions /> : null} {selectedProjectCount > 0 ? <ProjectActions /> : null}
@@ -122,8 +122,8 @@ export function EditableTimecode({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
className={cn( className={cn(
"border-none bg-transparent font-mono text-xs outline-none", "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1", "focus:bg-background focus:border-primary focus:rounded",
"text-primary tabular-nums", "text-primary tabular-nums",
hasError && "text-destructive focus:border-destructive", hasError && "text-destructive focus:border-destructive",
className, className,
@@ -1,8 +1,7 @@
"use client"; "use client";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { ChevronDown } from "lucide-react"; import { useRef, useState } from "react";
import { useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -17,23 +16,21 @@ import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6"; import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button"; import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle"; import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
CommandIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
import { cn } from "@/utils/ui";
export function EditorHeader() { export function EditorHeader() {
return ( return (
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5"> <header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<ProjectDropdown /> <ProjectDropdown />
<EditableProjectName />
</div> </div>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
<ExportButton /> <ExportButton />
@@ -111,14 +108,14 @@ function ProjectDropdown() {
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" size="icon" className="p-1 rounded-sm size-8">
variant="secondary" <Image
className="flex h-auto items-center justify-center px-2.5 py-1.5" src={DEFAULT_LOGO_URL}
> alt="Project thumbnail"
<ChevronDown className="text-muted-foreground" /> width={32}
<span className="mr-2 text-[0.85rem]"> height={32}
{activeProject?.metadata.name} className="invert dark:invert-0 size-5"
</span> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-52"> <DropdownMenuContent align="start" className="z-100 w-52">
@@ -130,21 +127,7 @@ function ProjectDropdown() {
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" /> <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project Exit project
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("rename")}
>
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("delete")}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
Delete project
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
@@ -185,3 +168,79 @@ function ProjectDropdown() {
</> </>
); );
} }
function EditableProjectName() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const originalNameRef = useRef("");
const projectName = activeProject?.metadata.name || "";
const startEditing = () => {
if (isEditing) return;
originalNameRef.current = projectName;
setIsEditing(true);
requestAnimationFrame(() => {
inputRef.current?.select();
});
};
const saveEdit = async () => {
if (!inputRef.current || !activeProject) return;
const newName = inputRef.current.value.trim();
setIsEditing(false);
if (!newName) {
inputRef.current.value = originalNameRef.current;
return;
}
if (newName !== originalNameRef.current) {
try {
await editor.project.renameProject({
id: activeProject.metadata.id,
name: newName,
});
} catch (error) {
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
inputRef.current?.blur();
} else if (event.key === "Escape") {
event.preventDefault();
if (inputRef.current) {
inputRef.current.value = originalNameRef.current;
}
setIsEditing(false);
inputRef.current?.blur();
}
};
return (
<input
ref={inputRef}
type="text"
defaultValue={projectName}
readOnly={!isEditing}
onClick={startEditing}
onBlur={saveEdit}
onKeyDown={handleKeyDown}
style={{ fieldSizing: "content" }}
className={cn(
"text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
)}
/>
);
}
@@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { getExportMimeType, getExportFileExtension } from "@/lib/export"; import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { Check, Copy, Download, RotateCcw } from "lucide-react";
import { import {
EXPORT_FORMAT_VALUES, EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES, EXPORT_QUALITY_VALUES,
@@ -44,9 +44,7 @@ export function ExportButton() {
type="button" type="button"
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
? "cursor-pointer"
: "cursor-not-allowed opacity-50",
)} )}
onClick={hasProject ? handleExport : undefined} onClick={hasProject ? handleExport : undefined}
disabled={!hasProject} disabled={!hasProject}
@@ -141,20 +139,12 @@ function ExportPopover({
} }
}; };
const handleClose = () => {
if (!isExporting) {
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
const handleCancel = () => { const handleCancel = () => {
cancelRequestedRef.current = true; cancelRequestedRef.current = true;
}; };
return ( return (
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3"> <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
{exportResult && !exportResult.success ? ( {exportResult && !exportResult.success ? (
<ExportError <ExportError
error={exportResult.error || "Unknown error occurred"} error={exportResult.error || "Unknown error occurred"}
@@ -162,23 +152,20 @@ function ExportPopover({
/> />
) : ( ) : (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between p-3 border-b">
<h3 className="font-medium"> <h3 className="font-medium text-sm">
{isExporting ? "Exporting project" : "Export project"} {isExporting ? "Exporting project" : "Export project"}
</h3> </h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="text-foreground/85 !size-5" />
</Button>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!isExporting && ( {!isExporting && (
<> <>
<div className="flex flex-col gap-3"> <div className="flex flex-col">
<PropertyGroup <PropertyGroup
title="Format" title="Format"
titleClassName="text-sm"
defaultExpanded={false} defaultExpanded={false}
hasBorderTop={false}
> >
<RadioGroup <RadioGroup
value={format} value={format}
@@ -203,11 +190,7 @@ function ExportPopover({
</RadioGroup> </RadioGroup>
</PropertyGroup> </PropertyGroup>
<PropertyGroup <PropertyGroup title="Quality" defaultExpanded={false}>
title="Quality"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup <RadioGroup
value={quality} value={quality}
onValueChange={(value) => { onValueChange={(value) => {
@@ -237,11 +220,7 @@ function ExportPopover({
</RadioGroup> </RadioGroup>
</PropertyGroup> </PropertyGroup>
<PropertyGroup <PropertyGroup title="Audio" defaultExpanded={false}>
title="Audio"
titleClassName="text-sm"
defaultExpanded={false}
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="include-audio" id="include-audio"
@@ -257,15 +236,17 @@ function ExportPopover({
</PropertyGroup> </PropertyGroup>
</div> </div>
<Button onClick={handleExport} className="w-full gap-2"> <div className="p-3 pt-0">
<Download className="size-4" /> <Button onClick={handleExport} className="w-full gap-2">
Export <Download className="size-4" />
</Button> Export
</Button>
</div>
</> </>
)} )}
{isExporting && ( {isExporting && (
<div className="space-y-4"> <div className="space-y-4 p-3">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center justify-between text-center"> <div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm"> <p className="text-muted-foreground mb-2 text-sm">
@@ -110,7 +110,7 @@ export function DraggableItem({
<AspectRatio <AspectRatio
ratio={aspectRatio} ratio={aspectRatio}
className={cn( className={cn(
"bg-panel-accent relative overflow-hidden", "bg-accent relative overflow-hidden",
isRounded && "rounded-sm", isRounded && "rounded-sm",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
)} )}
@@ -215,7 +215,7 @@ function PlusButton({
<Button <Button
size="icon" size="icon"
className={cn( className={cn(
"bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5", "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
className, className,
)} )}
onClick={(e) => { onClick={(e) => {
@@ -43,7 +43,7 @@ export function AssetsPanel() {
}; };
return ( return (
<div className="bg-panel flex h-full"> <div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
<TabBar /> <TabBar />
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
@@ -6,6 +6,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { import {
TAB_KEYS, TAB_KEYS,
@@ -15,9 +16,9 @@ import {
export function TabBar() { export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore(); const { activeTab, setActiveTab } = useAssetsPanelStore();
const scrollRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false); const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false); const [showBottomFade, setShowBottomFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const checkScrollPosition = useCallback(() => { const checkScrollPosition = useCallback(() => {
const element = scrollRef.current; const element = scrollRef.current;
@@ -48,32 +49,24 @@ export function TabBar() {
<div className="relative flex"> <div className="relative flex">
<div <div
ref={scrollRef} ref={scrollRef}
className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
> >
{TAB_KEYS.map((tabKey) => { {TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey]; const tab = tabs[tabKey];
return ( return (
<Tooltip key={tabKey} delayDuration={10}> <Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" variant={activeTab === tabKey ? "secondary" : "text"}
aria-label={tab.label} aria-label={tab.label}
className={cn( className={cn(
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75", "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
activeTab === tabKey activeTab !== tabKey && "border border-transparent text-muted-foreground",
? "text-primary !opacity-100"
: "text-muted-foreground",
)} )}
onClick={() => setActiveTab(tabKey)} onClick={() => setActiveTab(tabKey)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveTab(tabKey);
}
}}
> >
<tab.icon className=" " /> <tab.icon />
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="right" side="right"
@@ -108,8 +101,8 @@ function FadeOverlay({
className={cn( className={cn(
"pointer-events-none absolute right-0 left-0 h-6", "pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show direction === "top" && show
? "from-panel top-0 bg-gradient-to-b to-transparent" ? "from-background top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent", : "from-background bottom-0 bg-gradient-to-t to-transparent",
)} )}
/> />
); );
@@ -1,5 +1,4 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { import {
Select, Select,
@@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption"; import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Label } from "@/components/ui/label";
export function Captions() { export function Captions() {
const [selectedLanguage, setSelectedLanguage] = const [selectedLanguage, setSelectedLanguage] =
@@ -112,12 +112,13 @@ export function Captions() {
ref={containerRef} ref={containerRef}
className="flex h-full flex-col justify-between" className="flex h-full flex-col justify-between"
> >
<PropertyGroup title="Language"> <div className="flex flex-col gap-3">
<Label>Language</Label>
<Select <Select
value={selectedLanguage} value={selectedLanguage}
onValueChange={(value) => handleLanguageChange({ value })} onValueChange={(value) => handleLanguageChange({ value })}
> >
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs"> <SelectTrigger>
<SelectValue placeholder="Select a language" /> <SelectValue placeholder="Select a language" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -129,7 +130,7 @@ export function Captions() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</PropertyGroup> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{error && ( {error && (
@@ -202,7 +202,7 @@ export function MediaView() {
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps} {...dragProps}
> >
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b"> <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
<span className="text-muted-foreground text-sm">Assets</span> <span className="text-muted-foreground text-sm">Assets</span>
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<TooltipProvider> <TooltipProvider>
@@ -320,10 +320,10 @@ export function MediaView() {
onClick={openFilePicker} onClick={openFilePicker}
disabled={isProcessing} disabled={isProcessing}
size="sm" size="sm"
className="items-center justify-center gap-1.5 ml-1.5" className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
> >
<HugeiconsIcon icon={CloudUploadIcon} /> <HugeiconsIcon icon={CloudUploadIcon} />
Upload Import
</Button> </Button>
</div> </div>
</div> </div>
@@ -21,7 +21,6 @@ import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui"; import { syntaxUIGradients } from "@/data/colors/syntax-ui";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { TProject } from "@/types/project";
import { dimensionToAspectRatio } from "@/utils/geometry"; import { dimensionToAspectRatio } from "@/utils/geometry";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { import {
@@ -30,8 +29,7 @@ import {
PropertyItemLabel, PropertyItemLabel,
PropertyItemValue, PropertyItemValue,
} from "@/components/editor/panels/properties/property-item"; } from "@/components/editor/panels/properties/property-item";
import { HugeiconsIcon } from "@hugeicons/react"; import { ColorPicker } from "@/components/ui/color-picker";
import { DropperIcon } from "@hugeicons/core-free-icons";
export function SettingsView() { export function SettingsView() {
return <ProjectSettingsTabs />; return <ProjectSettingsTabs />;
@@ -56,24 +54,9 @@ function ProjectSettingsTabs() {
label: "Background", label: "Background",
content: ( content: (
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<div className="flex-1 p-5"> <div className="flex-1">
<BackgroundView /> <BackgroundView />
</div> </div>
{/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
<Separator />
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
Custom background
<PlusIcon />
</Button>
</div> */}
{/* another ui */}
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
<span className="text-sm">Custom</span>
<PlusIcon className="" />
</Button>
</div> */}
</div> </div>
), ),
}, },
@@ -83,15 +66,6 @@ function ProjectSettingsTabs() {
); );
} }
function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
const { canvasSize } = activeProject.settings;
return {
width: canvasSize.width,
height: canvasSize.height,
};
}
function ProjectInfoView() { function ProjectInfoView() {
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
@@ -117,7 +91,7 @@ function ProjectInfoView() {
return -1; return -1;
}; };
const currentCanvasSize = getCurrentCanvasSize({ activeProject }); const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({ const presetIndex = findPresetIndexByAspectRatio({
@@ -162,7 +136,7 @@ function ProjectInfoView() {
value={selectedPresetValue} value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })} onValueChange={(value) => handleAspectRatioChange({ value })}
> >
<SelectTrigger className="bg-panel-accent"> <SelectTrigger>
<SelectValue placeholder="Select an aspect ratio" /> <SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -190,7 +164,7 @@ function ProjectInfoView() {
value={activeProject.settings.fps.toString()} value={activeProject.settings.fps.toString()}
onValueChange={handleFpsChange} onValueChange={handleFpsChange}
> >
<SelectTrigger className="bg-panel-accent"> <SelectTrigger>
<SelectValue placeholder="Select a frame rate" /> <SelectValue placeholder="Select a frame rate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -219,7 +193,7 @@ const BlurPreview = memo(
}) => ( }) => (
<button <button
className={cn( className={cn(
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border", "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
isSelected && "border-primary border-2", isSelected && "border-primary border-2",
)} )}
onClick={onSelect} onClick={onSelect}
@@ -265,7 +239,7 @@ const BackgroundPreviews = memo(
<button <button
key={`${index}-${bg}`} key={`${index}-${bg}`}
className={cn( className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border", "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
isColorBackground && isColorBackground &&
bg === currentBackgroundColor && bg === currentBackgroundColor &&
"border-primary border-2", "border-primary border-2",
@@ -347,48 +321,35 @@ function BackgroundView() {
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
); );
const backgroundSections = [
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
{ title: "Pattern craft", backgrounds: patternCraftGradients },
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
];
return ( return (
<div className="flex h-full flex-col gap-4"> <div className="flex h-full flex-col">
<PropertyGroup title="Blur" defaultExpanded={false}> <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div> <div className="flex flex-wrap gap-2">{blurPreviews}</div>
</PropertyGroup> </PropertyGroup>
<PropertyGroup title="Colors" defaultExpanded={false}> {backgroundSections.map((section) => (
<div className="grid w-full grid-cols-4 gap-2"> <PropertyGroup
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border"> key={section.title}
<HugeiconsIcon icon={DropperIcon} className="size-4" /> title={section.title}
defaultExpanded={false}
>
<div className="flex flex-wrap gap-2">
<BackgroundPreviews
backgrounds={section.backgrounds}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={section.useBackgroundColor}
/>
</div> </div>
<BackgroundPreviews </PropertyGroup>
backgrounds={colors} ))}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
useBackgroundColor={true}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={patternCraftGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
<div className="grid w-full grid-cols-4 gap-2">
<BackgroundPreviews
backgrounds={syntaxUIGradients}
currentBackgroundColor={currentBackgroundColor}
isColorBackground={isColorBackground}
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
/>
</div>
</PropertyGroup>
</div> </div>
); );
} }
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Input <Input
placeholder="Search sound effects" placeholder="Search sound effects"
className="bg-panel-accent w-full" className="bg-accent w-full"
containerClassName="w-full" containerClassName="w-full"
value={searchQuery} value={searchQuery}
onChange={({ currentTarget }) => onChange={({ currentTarget }) =>
@@ -406,7 +406,7 @@ function SavedSoundsView() {
if (savedSounds.length === 0) { if (savedSounds.length === 0) {
return ( return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon <HugeiconsIcon
icon={FavouriteIcon} icon={FavouriteIcon}
className="text-muted-foreground size-10" className="text-muted-foreground size-10"
@@ -149,7 +149,7 @@ function CollectionGrid({
function EmptyView({ message }: { message: string }) { function EmptyView({ message }: { message: string }) {
return ( return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon <HugeiconsIcon
icon={HappyIcon} icon={HappyIcon}
className="text-muted-foreground size-10" className="text-muted-foreground size-10"
@@ -27,7 +27,7 @@ export function TextView() {
<DraggableItem <DraggableItem
name="Default text" name="Default text"
preview={ preview={
<div className="bg-panel-accent flex size-full items-center justify-center rounded"> <div className="bg-accent flex size-full items-center justify-center rounded">
<span className="text-xs select-none">Default text</span> <span className="text-xs select-none">Default text</span>
</div> </div>
} }
@@ -26,7 +26,7 @@ function ViewContent({
className?: string; className?: string;
}) { }) {
return ( return (
<ScrollArea className="flex-1"> <ScrollArea className="flex-1 scrollbar-hidden">
<div className={cn("p-5", className)}>{children}</div> <div className={cn("p-5", className)}>{children}</div>
</ScrollArea> </ScrollArea>
); );
@@ -52,7 +52,7 @@ export function PanelBaseView({
onValueChange={onValueChange} onValueChange={onValueChange}
className="flex h-full flex-col" className="flex h-full flex-col"
> >
<div className="bg-panel sticky top-0 z-10"> <div className="bg-background sticky top-0 z-10">
<div className="px-3 pt-3 pb-0"> <div className="px-3 pt-3 pb-0">
<TabsList> <TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
@@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect"; import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop"; 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 { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node"; import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder"; import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time"; import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { EditableTimecode } from "@/components/editable-timecode";
import { invokeAction } from "@/lib/actions";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/utils/ui";
function usePreviewSize() { function usePreviewSize() {
const editor = useEditor(); const editor = useEditor();
@@ -47,32 +59,134 @@ function RenderTreeController() {
} }
export function PreviewPanel() { export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
return ( return (
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm"> <div
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2"> ref={containerRef}
className={cn(
"panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
isFullscreen && "bg-background",
)}
>
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
<PreviewCanvas /> <PreviewCanvas />
<RenderTreeController /> <RenderTreeController />
</div> </div>
<PreviewToolbar
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
);
}
function PreviewToolbar({
isFullscreen,
onToggleFullscreen,
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const editor = useEditor();
const isPlaying = editor.playback.getIsPlaying();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<div className="flex items-center mt-1">
<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({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span>
</div>
<Button
variant="text"
size="icon"
type="button"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
<div className="justify-self-end">
<Button
variant="text"
size="icon"
type="button"
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div> </div>
); );
} }
function PreviewCanvas() { function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1); const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null); const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false); const renderingRef = useRef(false);
const { width, height } = usePreviewSize(); const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
const containerSize = useContainerSize({ containerRef });
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const renderer = useMemo(() => { const renderer = useMemo(() => {
return new CanvasRenderer({ return new CanvasRenderer({
width, width: nativeWidth,
height, height: nativeHeight,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
}, [width, height, activeProject.settings.fps]); }, [nativeWidth, nativeHeight, activeProject.settings.fps]);
const displaySize = useMemo(() => {
if (
!nativeWidth ||
!nativeHeight ||
containerSize.width === 0 ||
containerSize.height === 0
) {
return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
}
const paddingBuffer = 4;
const availableWidth = containerSize.width - paddingBuffer;
const availableHeight = containerSize.height - paddingBuffer;
const aspectRatio = nativeWidth / nativeHeight;
const containerAspect = availableWidth / availableHeight;
const displayWidth =
containerAspect > aspectRatio
? availableHeight * aspectRatio
: availableWidth;
const displayHeight =
containerAspect > aspectRatio
? availableHeight
: availableWidth / aspectRatio;
return { width: displayWidth, height: displayHeight };
}, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
const renderTree = editor.renderer.getRenderTree(); const renderTree = editor.renderer.getRenderTree();
@@ -109,13 +223,18 @@ function PreviewCanvas() {
useRafLoop(render); useRafLoop(render);
return ( return (
<div className="relative"> <div
ref={containerRef}
className="relative flex h-full w-full items-center justify-center"
>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={width} width={nativeWidth}
height={height} height={nativeHeight}
className="block max-h-full max-w-full border" className="block border"
style={{ style={{
width: displaySize.width,
height: displaySize.height,
background: background:
activeProject.settings.background.type === "blur" activeProject.settings.background.type === "blur"
? "transparent" ? "transparent"
@@ -0,0 +1,20 @@
import { HugeiconsIcon } from "@hugeicons/react";
import { Settings05Icon } from "@hugeicons/core-free-icons";
export function EmptyView() {
return (
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={Settings05Icon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium ">It's empty here</p>
<p className="text-muted-foreground text-sm text-balance">
Click an element on the timeline to edit its properties
</p>
</div>
</div>
);
}
@@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AudioProperties } from "./audio-properties"; import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties"; import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties"; import { TextProperties } from "./text-properties";
import { HugeiconsIcon } from "@hugeicons/react"; import { EmptyView } from "./empty-view";
import { Settings05Icon } from "@hugeicons/core-free-icons";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
@@ -18,9 +17,9 @@ export function PropertiesPanel() {
}); });
return ( return (
<> <div className="panel bg-background h-full rounded-sm border overflow-hidden">
{selectedElements.length > 0 ? ( {selectedElements.length > 0 ? (
<ScrollArea className="bg-panel h-full rounded-sm"> <ScrollArea className="h-full">
{elementsWithTracks.map(({ track, element }) => { {elementsWithTracks.map(({ track, element }) => {
if (element.type === "text") { if (element.type === "text") {
return ( return (
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
) : ( ) : (
<EmptyView /> <EmptyView />
)} )}
</>
);
}
function EmptyView() {
return (
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={Settings05Icon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">It's empty here</p>
<p className="text-muted-foreground text-sm text-balance">
Click an element on the timeline to edit its properties
</p>
</div>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
interface PropertyItemProps { interface PropertyItemProps {
direction?: "row" | "column"; direction?: "row" | "column";
@@ -57,35 +57,76 @@ interface PropertyGroupProps {
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
defaultExpanded?: boolean; defaultExpanded?: boolean;
collapsible?: boolean;
className?: string; className?: string;
titleClassName?: string; hasBorderTop?: boolean;
hasBorderBottom?: boolean;
} }
export function PropertyGroup({ export function PropertyGroup({
title, title,
children, children,
defaultExpanded = true, defaultExpanded = true,
collapsible = true,
className, className,
titleClassName, hasBorderTop = true,
hasBorderBottom = true,
}: PropertyGroupProps) { }: PropertyGroupProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return ( return (
<PropertyItem direction="column" className={cn("gap-3", className)}> <div
<button className={cn(
type="button" "flex flex-col",
className="flex items-center gap-1.5 cursor-pointer" hasBorderTop && "border-t",
onClick={() => setIsExpanded(!isExpanded)} hasBorderBottom && "last:border-b",
> className,
<PropertyItemLabel className={cn(titleClassName)}> )}
{title} >
</PropertyItemLabel> {collapsible ? (
<HugeiconsIcon <button
icon={ArrowDownIcon} type="button"
className={cn("size-3", !isExpanded && "-rotate-90")} className="flex items-center justify-between p-3.5 cursor-pointer"
/> onClick={() => setIsExpanded(!isExpanded)}
</button> >
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} <PropertyGroupTitle isExpanded={isExpanded}>
</PropertyItem> {title}
</PropertyGroupTitle>
<HugeiconsIcon
icon={isExpanded ? MinusSignIcon : PlusSignIcon}
className={cn(
"size-3",
isExpanded ? "text-foreground" : "text-muted-foreground",
)}
/>
</button>
) : (
<div className="flex items-center justify-between p-4">
<PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
</div>
)}
{(collapsible ? isExpanded : true) && (
<div className="p-3 pt-0">{children}</div>
)}
</div>
);
}
function PropertyGroupTitle({
children,
isExpanded = false,
}: {
children: React.ReactNode;
isExpanded?: boolean;
}) {
return (
<span
className={cn(
"text-xs font-medium",
isExpanded ? "text-foreground" : "text-muted-foreground",
)}
>
{children}
</span>
); );
} }
File diff suppressed because it is too large Load Diff
@@ -15,12 +15,12 @@ import {
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "../../ui/context-menu"; } from "../../../ui/context-menu";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track"; import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead"; import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../selection-box"; import { SelectionBox } from "../../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator"; import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
@@ -201,7 +201,7 @@ export function Timeline() {
return ( return (
<section <section
className={ className={
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm" "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
} }
{...dragProps} {...dragProps}
aria-label="Timeline" aria-label="Timeline"
@@ -226,17 +226,17 @@ export function Timeline() {
isVisible={showSnapIndicator} isVisible={showSnapIndicator}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="bg-panel flex w-28 shrink-0 flex-col border-r"> <div className="bg-background flex w-28 shrink-0 flex-col border-r">
<div className="bg-panel flex h-4 items-center justify-between px-3"> <div className="bg-background flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span> <span className="opacity-0">.</span>
</div> </div>
<div className="bg-panel flex h-4 items-center justify-between px-3"> <div className="bg-background flex h-4 items-center justify-between px-3">
<span className="opacity-0">.</span> <span className="opacity-0">.</span>
</div> </div>
{tracks.length > 0 && ( {tracks.length > 0 && (
<div <div
ref={trackLabelsRef} ref={trackLabelsRef}
className="bg-panel flex-1 overflow-y-auto" className="bg-background flex-1 overflow-y-auto"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
> >
<ScrollArea className="size-full" ref={trackLabelsScrollRef}> <ScrollArea className="size-full" ref={trackLabelsScrollRef}>
@@ -351,7 +351,7 @@ export function Timeline() {
> >
<div <div
ref={timelineHeaderRef} ref={timelineHeaderRef}
className="bg-panel sticky top-0 z-30 flex flex-col" className="bg-background sticky top-0 z-30 flex flex-col"
> >
<TimelineRuler <TimelineRuler
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
@@ -19,7 +19,7 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "../../ui/context-menu"; } from "../../../ui/context-menu";
import type { import type {
TimelineElement as TimelineElementType, TimelineElement as TimelineElementType,
TimelineTrack, TimelineTrack,
@@ -166,7 +166,10 @@ export function TimelineElement({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="z-200 w-64"> <ContextMenuContent className="z-200 w-64">
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> <ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
>
Split Split
</ActionMenuItem> </ActionMenuItem>
<CopyMenuItem /> <CopyMenuItem />
@@ -185,7 +188,10 @@ export function TimelineElement({
/> />
)} )}
{selectedElements.length === 1 && ( {selectedElements.length === 1 && (
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> <ActionMenuItem
action="duplicate-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Duplicate Duplicate
</ActionMenuItem> </ActionMenuItem>
)} )}
@@ -446,7 +452,10 @@ function ElementContent({
function CopyMenuItem() { function CopyMenuItem() {
return ( return (
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> <ActionMenuItem
action="copy-selected"
icon={<HugeiconsIcon icon={Copy01Icon} />}
>
Copy Copy
</ActionMenuItem> </ActionMenuItem>
); );
@@ -502,7 +511,10 @@ function VisibilityMenuItem({
}; };
return ( return (
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> <ActionMenuItem
action="toggle-elements-visibility-selected"
icon={getIcon()}
>
{isHidden ? "Show" : "Hide"} {isHidden ? "Show" : "Hide"}
</ActionMenuItem> </ActionMenuItem>
); );
@@ -1,4 +1,4 @@
import type { JSX } from "react"; import { type JSX, useLayoutEffect, useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
@@ -44,7 +44,21 @@ export function TimelineRuler({
scrollRef: tracksScrollRef, scrollRef: tracksScrollRef,
}); });
const bufferPx = 200; /**
* 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 visibleStartTime = Math.max( const visibleStartTime = Math.max(
0, 0,
(scrollLeft - bufferPx) / pixelsPerSecond, (scrollLeft - bufferPx) / pixelsPerSecond,
@@ -62,7 +76,11 @@ export function TimelineRuler({
); );
const timelineTicks: Array<JSX.Element> = []; const timelineTicks: Array<JSX.Element> = [];
for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) { for (
let tickIndex = startTickIndex;
tickIndex <= endTickIndex;
tickIndex += 1
) {
const time = tickIndex * tickIntervalSeconds; const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break; if (time > effectiveDuration) break;
@@ -6,7 +6,7 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SkipBack, SplitSquareHorizontal } from "lucide-react"; import { SplitSquareHorizontal } from "lucide-react";
import { import {
SplitButton, SplitButton,
SplitButtonLeft, SplitButtonLeft,
@@ -14,11 +14,9 @@ import {
SplitButtonSeparator, SplitButtonSeparator,
} from "@/components/ui/split-button"; } from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { EditableTimecode } from "@/components/editable-timecode"; import { ScenesView } from "../../scenes-view";
import { ScenesView } from "../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions"; import { type TAction, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
@@ -32,8 +30,6 @@ import {
Link04Icon, Link04Icon,
SearchAddIcon, SearchAddIcon,
SearchMinusIcon, SearchMinusIcon,
PauseIcon,
PlayIcon,
Copy01Icon, Copy01Icon,
AlignLeftIcon, AlignLeftIcon,
AlignRightIcon, AlignRightIcon,
@@ -82,7 +78,6 @@ export function TimelineToolbar({
function ToolbarLeftSection() { function ToolbarLeftSection() {
const editor = useEditor(); const editor = useEditor();
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const isPlaying = editor.playback.getIsPlaying();
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const handleAction = ({ const handleAction = ({
@@ -99,32 +94,6 @@ function ToolbarLeftSection() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}> <TooltipProvider delayDuration={500}>
<ToolbarButton
icon={
isPlaying ? (
<HugeiconsIcon icon={PauseIcon} />
) : (
<HugeiconsIcon icon={PlayIcon} />
)
}
tooltip={isPlaying ? "Pause" : "Play"}
onClick={({ event }) =>
handleAction({ action: "toggle-play", event })
}
/>
<ToolbarButton
icon={<SkipBack />}
tooltip="Go to start"
onClick={({ event }) => handleAction({ action: "goto-start", event })}
/>
<div className="bg-border mx-1 h-6 w-px" />
<TimeDisplay />
<div className="bg-border mx-1 h-6 w-px" />
<ToolbarButton <ToolbarButton
icon={<HugeiconsIcon icon={ScissorIcon} />} icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element" tooltip="Split element"
@@ -179,12 +148,8 @@ function ToolbarLeftSection() {
<Tooltip> <Tooltip>
<ToolbarButton <ToolbarButton
icon={ icon={<HugeiconsIcon icon={Bookmark02Icon} />}
<HugeiconsIcon isActive={currentBookmarked}
icon={Bookmark02Icon}
className={currentBookmarked ? "fill-primary text-primary" : ""}
/>
}
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) => onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event }) handleAction({ action: "toggle-bookmark", event })
@@ -196,34 +161,6 @@ function ToolbarLeftSection() {
); );
} }
function TimeDisplay() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
const totalDuration = editor.timeline.getTotalDuration();
const fps = editor.project.getActive().settings.fps;
return (
<div className="flex flex-row items-center justify-center px-2">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</div>
</div>
);
}
function SceneSelector() { function SceneSelector() {
const editor = useEditor(); const editor = useEditor();
const currentScene = editor.scenes.getActiveScene(); const currentScene = editor.scenes.getActiveScene();
@@ -265,26 +202,15 @@ function ToolbarRightSection({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}> <TooltipProvider delayDuration={500}>
<ToolbarButton <ToolbarButton
icon={ icon={<HugeiconsIcon icon={MagnetIcon} />}
<HugeiconsIcon isActive={snappingEnabled}
icon={MagnetIcon}
className={cn(snappingEnabled ? "text-primary" : "")}
/>
}
tooltip="Auto snapping" tooltip="Auto snapping"
onClick={() => toggleSnapping()} onClick={() => toggleSnapping()}
/> />
<ToolbarButton <ToolbarButton
icon={ icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
<HugeiconsIcon isActive={rippleEditingEnabled}
icon={Link04Icon}
className={cn(
rippleEditingEnabled ? "text-primary" : "",
"scale-110",
)}
/>
}
tooltip="Ripple editing" tooltip="Ripple editing"
onClick={() => toggleRippleEditing()} onClick={() => toggleRippleEditing()}
/> />
@@ -329,21 +255,26 @@ function ToolbarButton({
tooltip, tooltip,
onClick, onClick,
disabled, disabled,
isActive,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
tooltip: string; tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void; onClick: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean; disabled?: boolean;
isActive?: boolean;
}) { }) {
return ( return (
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="text" variant={isActive ? "secondary" : "text"}
size="icon" size="icon"
type="button" type="button"
onClick={(event) => onClick({ event })} onClick={(event) => onClick({ event })}
className={disabled ? "cursor-not-allowed opacity-50" : ""} className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
> >
{icon} {icon}
</Button> </Button>
@@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/types/timeline"; import type { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/utils/ui";
interface TimelineTrackContentProps { interface TimelineTrackContentProps {
track: TimelineTrack; track: TimelineTrack;
@@ -63,13 +62,9 @@ export function TimelineTrackContent({
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
}); });
const hasSelectedElements = track.elements.some((element) =>
isElementSelected({ trackId: track.id, elementId: element.id }),
);
return ( return (
<button <button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} className="size-full"
onClick={(event) => { onClick={(event) => {
if (shouldIgnoreClick?.()) return; if (shouldIgnoreClick?.()) return;
clearElementSelection(); clearElementSelection();
+2 -2
View File
@@ -22,8 +22,8 @@ export function ThemeToggle({
return ( return (
<Button <Button
size="icon" size="icon"
variant="text" variant="ghost"
className={cn("h-7", className)} className={cn("size-8", className)}
onClick={(e) => { onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark"); setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e); onToggle?.(e);
+4 -3
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground hover:bg-primary-hover", "bg-primary text-primary-foreground hover:bg-primary/90",
background: background:
"bg-background text-foreground hover:bg-background/90", "bg-background text-foreground hover:bg-background/90",
foreground: foreground:
@@ -22,13 +22,14 @@ const buttonVariants = cva(
outline: outline:
"border border-border bg-transparent hover:bg-accent/50", "border border-border bg-transparent hover:bg-accent/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input", "bg-secondary text-secondary-foreground border border-secondary-border",
text: "bg-transparent rounded-none opacity-100 hover:opacity-75", text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
ghost: "bg-transparent hover:bg-accent",
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
}, },
size: { size: {
default: "h-9.5 px-4 py-2", default: "h-9.5 px-4 py-2",
sm: "h-8 px-3 text-xs", sm: "h-8 p-1 px-2 text-xs rounded-sm",
lg: "h-10 p-5 px-6", lg: "h-10 p-5 px-6",
icon: "size-7", icon: "size-7",
text: "p-0", text: "p-0",
+15 -4
View File
@@ -6,6 +6,7 @@ import { Input } from "./input";
interface ColorPickerProps { interface ColorPickerProps {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onChangeEnd?: (value: string) => void;
className?: string; className?: string;
containerRef?: React.RefObject<HTMLDivElement | null>; containerRef?: React.RefObject<HTMLDivElement | null>;
} }
@@ -86,7 +87,7 @@ const hsvToHex = (h: number, s: number, v: number) => {
}; };
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => { ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
null, null,
@@ -102,6 +103,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const saturationRef = useRef<HTMLButtonElement>(null); const saturationRef = useRef<HTMLButtonElement>(null);
const hueRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const latestDragColorRef = useRef<string | null>(null);
const [h, s, v] = hexToHsv(value); const [h, s, v] = hexToHsv(value);
const displayHue = s > 0 ? h : internalHue; const displayHue = s > 0 ? h : internalHue;
@@ -144,6 +146,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newS = x; const newS = x;
const newV = 1 - y; const newV = 1 - y;
const newHex = hsvToHex(displayHue, newS, newV); const newHex = hsvToHex(displayHue, newS, newV);
latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
@@ -157,12 +160,17 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
setInternalHue(newH); setInternalHue(newH);
if (s > 0) { if (s > 0) {
const newHex = hsvToHex(newH, s, v); const newHex = hsvToHex(newH, s, v);
latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
} }
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
if (latestDragColorRef.current !== null && onChangeEnd) {
onChangeEnd(latestDragColorRef.current);
latestDragColorRef.current = null;
}
setIsDragging(null); setIsDragging(null);
}; };
@@ -187,6 +195,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newS = x; const newS = x;
const newV = 1 - y; const newV = 1 - y;
const newHex = hsvToHex(displayHue, newS, newV); const newHex = hsvToHex(displayHue, newS, newV);
latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
}; };
@@ -201,6 +210,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
setInternalHue(newH); setInternalHue(newH);
if (s > 0) { if (s > 0) {
const newHex = hsvToHex(newH, s, v); const newHex = hsvToHex(newH, s, v);
latestDragColorRef.current = newHex;
onChange?.(newHex); onChange?.(newHex);
} }
}; };
@@ -235,14 +245,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"bg-panel-accent flex h-9 items-center gap-2 rounded-full px-[0.45rem]", "bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]",
className, className,
)} )}
{...props} {...props}
> >
<button <button
ref={triggerRef} ref={triggerRef}
className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20" className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20"
style={{ backgroundColor: `#${value}` }} style={{ backgroundColor: `#${value}` }}
type="button" type="button"
onClick={() => { onClick={() => {
@@ -259,7 +269,8 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
/> />
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center">
<Input <Input
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0" className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase"
size="sm"
containerClassName="w-full" containerClassName="w-full"
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
+1 -1
View File
@@ -114,7 +114,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva( const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
+3 -3
View File
@@ -6,6 +6,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
import { cn } from "@/utils/ui";
interface FontPickerProps { interface FontPickerProps {
defaultValue?: FontFamily; defaultValue?: FontFamily;
@@ -21,16 +22,15 @@ export function FontPicker({
return ( return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}> <Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger <SelectTrigger
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`} className={cn("w-full", className)}
> >
<SelectValue placeholder="Select a font" className="text-sm" /> <SelectValue placeholder="Select a font" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => ( {FONT_OPTIONS.map((font) => (
<SelectItem <SelectItem
key={font.value} key={font.value}
value={font.value} value={font.value}
className="text-sn"
style={{ fontFamily: font.value }} style={{ fontFamily: font.value }}
> >
{font.label} {font.label}
@@ -55,7 +55,7 @@ export function InputWithBack({
> >
<Button <Button
variant="outline" variant="outline"
className="bg-panel-accent !size-9 rounded-full" className="bg-accent !size-9 rounded-full"
> >
<ArrowLeft /> <ArrowLeft />
</Button> </Button>
@@ -77,7 +77,7 @@ export function InputWithBack({
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input <Input
placeholder={placeholder} placeholder={placeholder}
className="bg-panel-accent w-full pl-9" className="bg-accent w-full pl-9"
value={value} value={value}
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
/> />
+1 -1
View File
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const labelVariants = cva( const labelVariants = cva(
"text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", "text-xs text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
); );
const Label = React.forwardRef< const Label = React.forwardRef<
-1
View File
@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
const Popover = PopoverPrimitive.Root; const Popover = PopoverPrimitive.Root;
+3 -3
View File
@@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value; const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva( const selectItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -35,7 +35,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none", "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
className, className,
)} )}
@@ -159,7 +159,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn("bg-border/60 mx-1 my-2 h-px", className)} className={cn("bg-border mx-1 my-2 h-px", className)}
{...props} {...props}
/> />
)); ));
+1 -1
View File
@@ -17,7 +17,7 @@ const Slider = React.forwardRef<
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full"> <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" /> <SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
+2 -2
View File
@@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border", "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
className, className,
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
ref={ref} ref={ref}
variant="text" variant="text"
className={cn( className={cn(
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100", "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
onClick onClick
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text", : "cursor-default select-text",
+1 -1
View File
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-panel-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -60,10 +60,7 @@ export function useEdgeAutoScroll({
mouseXRelative > viewportWidth - edgeThreshold && mouseXRelative > viewportWidth - edgeThreshold &&
rulerViewport.scrollLeft < scrollMax rulerViewport.scrollLeft < scrollMax
) { ) {
const edgeDistance = Math.max( const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
0,
viewportWidth - edgeThreshold - mouseXRelative,
);
const intensity = 1 - edgeDistance / edgeThreshold; const intensity = 1 - edgeDistance / edgeThreshold;
scrollSpeed = maxScrollSpeed * intensity; scrollSpeed = maxScrollSpeed * intensity;
} }
@@ -44,7 +44,7 @@ export function useTimelineZoom({
null, null,
); );
const [zoomLevel, setZoomLevel] = useState(() => { const [zoomLevel, setZoomLevelRaw] = useState(() => {
if (initialZoom !== undefined) { if (initialZoom !== undefined) {
hasInitializedRef.current = true; hasInitializedRef.current = true;
return Math.max( return Math.max(
@@ -56,6 +56,18 @@ export function useTimelineZoom({
}); });
const previousZoomRef = useRef(zoomLevel); const previousZoomRef = useRef(zoomLevel);
const hasRestoredScrollRef = useRef(false); const hasRestoredScrollRef = useRef(false);
const preZoomScrollLeftRef = useRef(0);
const setZoomLevel = useCallback(
(updater: number | ((prev: number) => number)) => {
const scrollElement = tracksScrollRef.current;
if (scrollElement) {
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
}
setZoomLevelRaw(updater);
},
[tracksScrollRef],
);
const handleWheel = useCallback( const handleWheel = useCallback(
(event: ReactWheelEvent) => { (event: ReactWheelEvent) => {
@@ -82,7 +94,7 @@ export function useTimelineZoom({
return; return;
} }
}, },
[minZoom], [minZoom, setZoomLevel],
); );
useEffect(() => { useEffect(() => {
@@ -99,7 +111,7 @@ export function useTimelineZoom({
} }
return prev; return prev;
}); });
}, [minZoom, initialZoom]); }, [minZoom, initialZoom, setZoomLevel]);
const wrappedSetZoomLevel = useCallback( const wrappedSetZoomLevel = useCallback(
(zoomLevelOrUpdater: number | ((prev: number) => number)) => { (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
@@ -115,7 +127,7 @@ export function useTimelineZoom({
return clampedZoom; return clampedZoom;
}); });
}, },
[minZoom], [minZoom, setZoomLevel],
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -128,7 +140,7 @@ export function useTimelineZoom({
return; return;
} }
const currentScrollLeft = scrollElement.scrollLeft; const currentScrollLeft = preZoomScrollLeftRef.current;
const playheadTime = editor.playback.getCurrentTime(); const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
export function useContainerSize({
containerRef,
}: {
containerRef: React.RefObject<HTMLElement | null>;
}) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setSize({ width, height });
}
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [containerRef]);
return size;
}
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from "react";
export function useFullscreen({
containerRef,
}: {
containerRef: React.RefObject<HTMLElement | null>;
}) {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handleChange = () => {
setIsFullscreen(document.fullscreenElement !== null);
};
document.addEventListener("fullscreenchange", handleChange);
return () => {
document.removeEventListener("fullscreenchange", handleChange);
};
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
}, [containerRef]);
return { isFullscreen, toggleFullscreen };
}
@@ -137,6 +137,16 @@ export function usePreviewInteraction({
const deltaX = currentPos.x - dragStateRef.current.startX; const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY; const deltaY = currentPos.y - dragStateRef.current.startY;
const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
if (!hasMovement) {
dragStateRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
return;
}
// revert to pre-drag state so the command captures the correct undo snapshot // revert to pre-drag state so the command captures the correct undo snapshot
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot); editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
@@ -1,33 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type TextPropertiesTab = "text" | "transform";
export interface TextPropertiesTabMeta {
value: TextPropertiesTab;
label: string;
}
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
{ value: "text", label: "Text" },
{ value: "transform", label: "Transform" },
] as const;
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
}
interface TextPropertiesState {
activeTab: TextPropertiesTab;
setActiveTab: (tab: TextPropertiesTab) => void;
}
export const useTextPropertiesStore = create<TextPropertiesState>()(
persist(
(set) => ({
activeTab: "text",
setActiveTab: (tab) => set({ activeTab: tab }),
}),
{ name: "text-properties" },
),
);