diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
| commit | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (patch) | |
| tree | 11500ff0fcc4a933650e3e91f1ea00fd315c4d0c | |
| parent | 33a8705f0d967891402c61dd3192e6bad24921ef (diff) | |
| download | opencut-fca99d6126c31fbb18ed9f1034cee6f940b040e8.tar.gz | |
refactor
51 files changed, 1355 insertions, 1045 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index e652e928..97093912 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/resizable"; import { AssetsPanel } from "@/components/editor/panels/assets"; 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 { EditorHeader } from "@/components/editor/editor-header"; import { EditorProvider } from "@/components/providers/editor-provider"; @@ -65,7 +65,7 @@ function EditorLayout() { defaultSize={panels.tools} minSize={15} maxSize={40} - className="min-w-0 rounded-sm" + className="min-w-0" > <AssetsPanel /> </ResizablePanel> @@ -86,7 +86,7 @@ function EditorLayout() { defaultSize={panels.properties} minSize={15} maxSize={40} - className="min-w-0 rounded-sm" + className="min-w-0" > <PropertiesPanel /> </ResizablePanel> diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index d0da1f51..37b5fe16 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8,88 +8,115 @@ @plugin "tailwindcss-animate"; :root { - --background: hsl(0, 0%, 100%); - --foreground: hsl(0 0% 11%); - --card: hsl(0, 0%, 100%); - --card-foreground: hsl(0 0% 11%); - --popover: hsl(0, 0%, 100%); - --popover-foreground: hsl(0 0% 2%); - --primary: hsl(203, 100%, 50%); - --primary-hover: hsl(203, 100%, 45%); - --primary-foreground: hsl(0, 0%, 100%); - --secondary: hsl(216 13% 94%); - --secondary-foreground: hsl(0 0% 2%); - --muted: hsl(0 0% 85.1%); - --muted-foreground: hsl(0 0% 50%); - --accent: hsl(216, 13%, 88%); - --accent-foreground: hsl(0 0% 2%); - --destructive: hsl(0, 83%, 50%); - --destructive-foreground: hsl(0, 0%, 100%); - --constructive: hsl(141, 71%, 48%); - --constructive-foreground: hsl(0, 0%, 100%); - --border: hsl(0 0% 88%); - --input: hsl(0 0% 85.1%); - --ring: hsl(0, 0%, 55%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 96.1%); - --sidebar-foreground: hsl(0 0% 2%); - --sidebar-primary: hsl(0 0% 2%); - --sidebar-primary-foreground: hsl(0 0% 91%); - --sidebar-accent: hsl(0 0% 85.1%); - --sidebar-accent-foreground: hsl(0 0% 2%); - --sidebar-border: hsl(0 0% 85.1%); - --sidebar-ring: hsl(0 0% 16.9%); - --panel-background: hsl(216 13% 94%); - --panel-accent: hsl(216, 8%, 88%); - --sidebar: hsl(0 0% 98%); + --background: hsl(0, 0%, 100%); + --foreground: hsl(0 0% 11%); + --card: hsl(0, 0%, 100%); + --card-foreground: hsl(0 0% 11%); + --popover: hsl(0, 0%, 100%); + --popover-hover: hsl(0, 0%, 96%); + --popover-foreground: hsl(0 0% 2%); + --primary: #009dff; + --primary-foreground: hsl(0, 0%, 100%); + --secondary: hsl(204, 100%, 97%); + --secondary-border: hsl(204, 100%, 94%); + --secondary-foreground: hsl(200, 98%, 39%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 50%); + --accent: hsl(0, 0%, 96%); + --accent-foreground: hsl(0 0% 2%); + --destructive: hsl(0, 83%, 50%); + --destructive-foreground: hsl(0, 0%, 100%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0, 0%, 100%); + --border: hsl(0 0% 91%); + --input: hsl(0 0% 85.1%); + --ring: hsl(0, 0%, 55%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 96.1%); + --sidebar-foreground: hsl(0 0% 2%); + --sidebar-primary: hsl(0 0% 2%); + --sidebar-primary-foreground: hsl(0 0% 91%); + --sidebar-accent: hsl(0 0% 85.1%); + --sidebar-accent-foreground: hsl(0 0% 2%); + --sidebar-border: hsl(0 0% 85.1%); + --sidebar-ring: hsl(0 0% 16.9%); + --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 { - --background: hsl(0 0% 4%); - --foreground: hsl(0 0% 89%); - --card: hsl(0 0% 4%); - --card-foreground: hsl(0 0% 89%); - --popover: hsl(0 0% 14.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: hsl(203, 100%, 50%); - --primary-hover: hsl(203, 100%, 45%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 14.9%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0, 0%, 28%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 83%, 55%); - --destructive-foreground: hsl(0 0% 98%); - --constructive: hsl(141, 71%, 48%); - --constructive-foreground: hsl(0 0% 100%); - --border: hsl(0 0% 17%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 3.9%); - --sidebar-foreground: hsl(0 0% 98%); - --sidebar-primary: hsl(0 0% 98%); - --sidebar-primary-foreground: hsl(0 0% 9%); - --sidebar-accent: hsl(0 0% 14.9%); - --sidebar-accent-foreground: hsl(0 0% 98%); - --sidebar-border: hsl(0 0% 14.9%); - --sidebar-ring: hsl(0 0% 83.1%); - --panel-background: hsl(0 0% 11%); - --panel-accent: hsl(0 0% 15%); - --sidebar: hsl(240 5.9% 10%); + --background: hsl(0, 0%, 7%); + --foreground: hsl(0 0% 87%); + --card: hsl(0, 0%, 7%); + --card-foreground: hsl(0 0% 87%); + --popover: hsl(0, 0%, 16%); + --popover-hover: hsl(0, 0%, 22%); + --popover-foreground: hsl(0 0% 95%); + --secondary: hsl(204, 100%, 12%); + --secondary-border: hsl(204, 100%, 15%); + --secondary-foreground: hsl(200, 98%, 61%); + --muted: hsl(0 0% 20%); + --accent: hsl(0, 0%, 14%); + --accent-foreground: hsl(0 0% 95%); + --constructive: hsl(141, 71%, 52%); + --border: hsl(0 0% 16%); + --input: hsl(0 0% 20%); + --ring: hsl(0, 0%, 50%); + --sidebar-background: hsl(0 0% 8%); + --sidebar-foreground: hsl(0 0% 95%); + --sidebar-primary: hsl(0 0% 95%); + --sidebar-primary-foreground: hsl(0 0% 15%); + --sidebar-accent: hsl(0 0% 20%); + --sidebar-accent-foreground: hsl(0 0% 95%); + --sidebar-border: hsl(0 0% 20%); + --sidebar-ring: hsl(0 0% 83.1%); + --sidebar: hsl(0 0% 6%); +} + +.dark .panel { + --background: hsl(0 0% 10%); + --foreground: hsl(0 0% 85%); + --card: hsl(0, 0%, 10%); + --card-foreground: hsl(0 0% 85%); + --secondary: hsl(204, 100%, 12%); + --secondary-border: hsl(204, 100%, 17%); + --secondary-foreground: hsl(200, 98%, 63%); + --muted: hsl(0 0% 22%); + --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 { - /* + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still 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 color utility to any element that depends on these defaults. */ - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } - /* Other default base styles */ - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - /* Prevent back/forward swipe */ - overscroll-behavior-x: contain; - } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + /* Other default base styles */ + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + /* Prevent back/forward swipe */ + overscroll-behavior-x: contain; + } + ::selection { + @apply bg-primary/35 selection:text-primary-foreground; + } } @theme inline { - /* Responsive breakpoints */ - --breakpoint-xs: 30rem; + /* Responsive breakpoints */ + --breakpoint-xs: 30rem; - /* Typography */ - --font-sans: var(--font-inter), sans-serif; + /* Typography */ + --font-sans: var(--font-inter), sans-serif; - /* Font sizes */ - --text-xl: 1.20rem; + /* Font sizes */ + --text-xl: 1.2rem; --text-base: 0.92rem; - --text-base--line-height: calc(1.5 / 0.95); - --text-xs: 0.75rem; - --text-xs--line-height: calc(1 / 0.8); - - /* Border radius */ - --radius-lg: 0.82rem; - --radius-md: 0.65rem; - --radius-sm: 0.35rem; + --text-base--line-height: calc(1.5 / 0.95); + --text-xs: 0.75rem; + --text-sm: 0.85rem; + --text-xs--line-height: calc(1 / 0.8); - /* Palette mapped to root design tokens */ - --color-background: var(--background); - --color-foreground: var(--foreground); + /* Border radius */ + --radius-lg: 0.82rem; + --radius-md: 0.65rem; + --radius-sm: 0.35rem; - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); + /* Palette mapped to root design tokens */ + --color-background: var(--background); + --color-foreground: var(--foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-primary-hover: var(--primary-hover); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); + --color-popover: var(--popover); + --color-popover-hover: var(--popover-hover); + --color-popover-foreground: var(--popover-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-border: var(--secondary-border); + --color-secondary-foreground: var(--secondary-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-constructive: var(--constructive); - --color-constructive-foreground: var(--constructive-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + --color-constructive: var(--constructive); + --color-constructive-foreground: var(--constructive-foreground); - /* Chart colors */ - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - /* Sidebar */ - --color-sidebar: var(--sidebar-background); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + /* Chart colors */ + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); - /* Panel */ - --color-panel: var(--panel-background); - --color-panel-accent: var(--panel-accent); + /* Sidebar */ + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); - /* Animations */ - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; + /* Animations */ + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--radix-accordion-content-height); - } - } + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } } @utility scrollbar-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } } @utility scrollbar-x-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar:horizontal { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar:horizontal { + display: none; + } } @utility scrollbar-y-hidden { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar:vertical { - display: none; - } + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar:vertical { + display: none; + } } @utility scrollbar-thin { - &::-webkit-scrollbar { - width: 6px; - height: 8px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; - } - &::-webkit-scrollbar-thumb:hover { - background: var(--muted-foreground); - } + &::-webkit-scrollbar { + width: 6px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 5d17a46c..492e0cbe 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -71,6 +71,11 @@ const formatProjectDuration = ({ 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() { const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); const editor = useEditor(); @@ -147,34 +152,23 @@ function ProjectsHeader() { </BreadcrumbList> </Breadcrumb> - <div className="hidden md:flex rounded-md border p-1 h-10"> - <button - type="button" - className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`} - onClick={() => setViewMode({ viewMode: "grid" })} - onKeyDown={(event) => - event.key === "Enter" && setViewMode({ viewMode: "grid" }) - } - aria-label="Grid view" - aria-pressed={isHydrated && viewMode === "grid"} - > - <HugeiconsIcon icon={GridViewIcon} className="size-4" /> - </button> - <button - type="button" - className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`} - 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 className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10"> + {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + <Button + key={mode} + variant="ghost" + size="icon" + className={cn( + "rounded-sm hover:bg-background", + isHydrated && viewMode === mode && "!bg-accent", + )} + onClick={() => setViewMode({ viewMode: mode })} + aria-label={label} + aria-pressed={isHydrated && viewMode === mode} + > + <HugeiconsIcon icon={icon} className="size-4" /> + </Button> + ))} </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="flex md:hidden items-center gap-4"> - <Button - variant="text" - onClick={() => setViewMode({ viewMode: "grid" })} - > - <HugeiconsIcon - icon={GridViewIcon} - className={cn( - viewMode === "grid" ? "text-primary" : "text-muted-foreground", - )} - /> - </Button> - <Button - variant="text" - onClick={() => setViewMode({ viewMode: "list" })} - > - <HugeiconsIcon - icon={LeftToRightListDashIcon} - className={cn( - viewMode === "list" ? "text-primary" : "text-muted-foreground", - )} - /> - </Button> + {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + <Button + key={mode} + variant="text" + onClick={() => setViewMode({ viewMode: mode })} + aria-label={label} + > + <HugeiconsIcon + icon={icon} + className={cn( + viewMode === mode ? "text-primary" : "text-muted-foreground", + )} + /> + </Button> + ))} </div> </div> {selectedProjectCount > 0 ? <ProjectActions /> : null} diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index d32f067a..61f23372 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -122,8 +122,8 @@ export function EditableTimecode({ onKeyDown={handleKeyDown} onBlur={handleBlur} className={cn( - "border-none bg-transparent font-mono text-xs outline-none", - "focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1", + "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none", + "focus:bg-background focus:border-primary focus:rounded", "text-primary tabular-nums", hasError && "text-destructive focus:border-destructive", className, diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index d74d78d7..e70bda37 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,8 +1,7 @@ "use client"; import { Button } from "../ui/button"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -17,23 +16,21 @@ import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { ExportButton } from "./export-button"; 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 { useEditor } from "@/hooks/use-editor"; -import { - ArrowLeft02Icon, - Edit03Icon, - Delete02Icon, - CommandIcon, -} from "@hugeicons/core-free-icons"; +import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; +import Image from "next/image"; +import { cn } from "@/utils/ui"; export function EditorHeader() { return ( - <header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5"> - <div className="flex items-center gap-2"> + <header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5"> + <div className="flex items-center gap-1"> <ProjectDropdown /> + <EditableProjectName /> </div> <nav className="flex items-center gap-2"> <ExportButton /> @@ -111,14 +108,14 @@ function ProjectDropdown() { <> <DropdownMenu> <DropdownMenuTrigger asChild> - <Button - variant="secondary" - className="flex h-auto items-center justify-center px-2.5 py-1.5" - > - <ChevronDown className="text-muted-foreground" /> - <span className="mr-2 text-[0.85rem]"> - {activeProject?.metadata.name} - </span> + <Button variant="ghost" size="icon" className="p-1 rounded-sm size-8"> + <Image + src={DEFAULT_LOGO_URL} + alt="Project thumbnail" + width={32} + height={32} + className="invert dark:invert-0 size-5" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="z-100 w-52"> @@ -130,21 +127,7 @@ function ProjectDropdown() { <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" /> Exit project </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 /> <DropdownMenuItem 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", + )} + /> + ); +} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 942efcff..f39c18e2 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; import { getExportMimeType, getExportFileExtension } from "@/lib/export"; -import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; +import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, @@ -44,9 +44,7 @@ export function ExportButton() { type="button" className={cn( "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", - hasProject - ? "cursor-pointer" - : "cursor-not-allowed opacity-50", + hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50", )} onClick={hasProject ? handleExport : undefined} disabled={!hasProject} @@ -141,20 +139,12 @@ function ExportPopover({ } }; - const handleClose = () => { - if (!isExporting) { - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; - const handleCancel = () => { cancelRequestedRef.current = true; }; 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 ? ( <ExportError error={exportResult.error || "Unknown error occurred"} @@ -162,23 +152,20 @@ function ExportPopover({ /> ) : ( <> - <div className="flex items-center justify-between"> - <h3 className="font-medium"> + <div className="flex items-center justify-between p-3 border-b"> + <h3 className="font-medium text-sm"> {isExporting ? "Exporting project" : "Export project"} </h3> - <Button variant="text" size="icon" onClick={handleClose}> - <X className="text-foreground/85 !size-5" /> - </Button> </div> <div className="flex flex-col gap-4"> {!isExporting && ( <> - <div className="flex flex-col gap-3"> + <div className="flex flex-col"> <PropertyGroup title="Format" - titleClassName="text-sm" defaultExpanded={false} + hasBorderTop={false} > <RadioGroup value={format} @@ -203,11 +190,7 @@ function ExportPopover({ </RadioGroup> </PropertyGroup> - <PropertyGroup - title="Quality" - titleClassName="text-sm" - defaultExpanded={false} - > + <PropertyGroup title="Quality" defaultExpanded={false}> <RadioGroup value={quality} onValueChange={(value) => { @@ -237,11 +220,7 @@ function ExportPopover({ </RadioGroup> </PropertyGroup> - <PropertyGroup - title="Audio" - titleClassName="text-sm" - defaultExpanded={false} - > + <PropertyGroup title="Audio" defaultExpanded={false}> <div className="flex items-center space-x-2"> <Checkbox id="include-audio" @@ -257,15 +236,17 @@ function ExportPopover({ </PropertyGroup> </div> - <Button onClick={handleExport} className="w-full gap-2"> - <Download className="size-4" /> - Export - </Button> + <div className="p-3 pt-0"> + <Button onClick={handleExport} className="w-full gap-2"> + <Download className="size-4" /> + Export + </Button> + </div> </> )} {isExporting && ( - <div className="space-y-4"> + <div className="space-y-4 p-3"> <div className="flex flex-col"> <div className="flex items-center justify-between text-center"> <p className="text-muted-foreground mb-2 text-sm"> diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 10cb1f8c..517fdc92 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -110,7 +110,7 @@ export function DraggableItem({ <AspectRatio ratio={aspectRatio} className={cn( - "bg-panel-accent relative overflow-hidden", + "bg-accent relative overflow-hidden", isRounded && "rounded-sm", isDraggable && "[&::-webkit-drag-ghost]:opacity-0", )} @@ -215,7 +215,7 @@ function PlusButton({ <Button size="icon" 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, )} onClick={(e) => { diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 9161cb1d..e7bd3d4f 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -43,7 +43,7 @@ export function AssetsPanel() { }; return ( - <div className="bg-panel flex h-full"> + <div className="panel bg-background flex h-full rounded-sm border overflow-hidden"> <TabBar /> <Separator orientation="vertical" /> <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 2c950fdc..31842306 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -6,6 +6,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; import { cn } from "@/utils/ui"; import { TAB_KEYS, @@ -15,9 +16,9 @@ import { export function TabBar() { const { activeTab, setActiveTab } = useAssetsPanelStore(); - const scrollRef = useRef<HTMLDivElement>(null); const [showTopFade, setShowTopFade] = useState(false); const [showBottomFade, setShowBottomFade] = useState(false); + const scrollRef = useRef<HTMLDivElement>(null); const checkScrollPosition = useCallback(() => { const element = scrollRef.current; @@ -48,32 +49,24 @@ export function TabBar() { <div className="relative flex"> <div ref={scrollRef} - className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" + 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) => { const tab = tabs[tabKey]; return ( <Tooltip key={tabKey} delayDuration={10}> <TooltipTrigger asChild> - <button - type="button" + <Button + variant={activeTab === tabKey ? "secondary" : "text"} aria-label={tab.label} className={cn( - "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75", - activeTab === tabKey - ? "text-primary !opacity-100" - : "text-muted-foreground", + "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", + activeTab !== tabKey && "border border-transparent text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setActiveTab(tabKey); - } - }} > - <tab.icon className=" " /> - </button> + <tab.icon /> + </Button> </TooltipTrigger> <TooltipContent side="right" @@ -108,8 +101,8 @@ function FadeOverlay({ className={cn( "pointer-events-none absolute right-0 left-0 h-6", direction === "top" && show - ? "from-panel top-0 bg-gradient-to-b to-transparent" - : "from-panel bottom-0 bg-gradient-to-t to-transparent", + ? "from-background top-0 bg-gradient-to-b to-transparent" + : "from-background bottom-0 bg-gradient-to-t to-transparent", )} /> ); diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index d93baeff..daebf2c6 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Select, @@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service"; import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { buildCaptionChunks } from "@/lib/transcription/caption"; import { Spinner } from "@/components/ui/spinner"; +import { Label } from "@/components/ui/label"; export function Captions() { const [selectedLanguage, setSelectedLanguage] = @@ -112,12 +112,13 @@ export function Captions() { ref={containerRef} className="flex h-full flex-col justify-between" > - <PropertyGroup title="Language"> + <div className="flex flex-col gap-3"> + <Label>Language</Label> <Select value={selectedLanguage} onValueChange={(value) => handleLanguageChange({ value })} > - <SelectTrigger className="bg-panel-accent h-8 w-full text-xs"> + <SelectTrigger> <SelectValue placeholder="Select a language" /> </SelectTrigger> <SelectContent> @@ -129,7 +130,7 @@ export function Captions() { ))} </SelectContent> </Select> - </PropertyGroup> + </div> <div className="flex flex-col gap-4"> {error && ( diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx index 76ae0064..59395fd8 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -202,7 +202,7 @@ export function MediaView() { className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > - <div className="bg-panel py-2 px-4 flex items-center justify-between border-b"> + <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> <div className="flex items-center gap-0"> <TooltipProvider> @@ -320,10 +320,10 @@ export function MediaView() { onClick={openFilePicker} disabled={isProcessing} 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} /> - Upload + Import </Button> </div> </div> diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index b8c54510..26afe566 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -21,7 +21,6 @@ import { colors } from "@/data/colors/solid"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; import { useEditor } from "@/hooks/use-editor"; import { useEditorStore } from "@/stores/editor-store"; -import type { TProject } from "@/types/project"; import { dimensionToAspectRatio } from "@/utils/geometry"; import { cn } from "@/utils/ui"; import { @@ -30,8 +29,7 @@ import { PropertyItemLabel, PropertyItemValue, } from "@/components/editor/panels/properties/property-item"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DropperIcon } from "@hugeicons/core-free-icons"; +import { ColorPicker } from "@/components/ui/color-picker"; export function SettingsView() { return <ProjectSettingsTabs />; @@ -56,24 +54,9 @@ function ProjectSettingsTabs() { label: "Background", content: ( <div className="flex h-full flex-col justify-between"> - <div className="flex-1 p-5"> + <div className="flex-1"> <BackgroundView /> </div> - {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg"> - <Separator /> - <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none"> - Custom background - <PlusIcon /> - </Button> - </div> */} - - {/* another ui */} - {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0"> - <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full"> - <span className="text-sm">Custom</span> - <PlusIcon className="" /> - </Button> - </div> */} </div> ), }, @@ -83,15 +66,6 @@ function ProjectSettingsTabs() { ); } -function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { - const { canvasSize } = activeProject.settings; - - return { - width: canvasSize.width, - height: canvasSize.height, - }; -} - function ProjectInfoView() { const editor = useEditor(); const activeProject = editor.project.getActive(); @@ -117,7 +91,7 @@ function ProjectInfoView() { return -1; }; - const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentCanvasSize = activeProject.settings.canvasSize; const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; const presetIndex = findPresetIndexByAspectRatio({ @@ -162,7 +136,7 @@ function ProjectInfoView() { value={selectedPresetValue} onValueChange={(value) => handleAspectRatioChange({ value })} > - <SelectTrigger className="bg-panel-accent"> + <SelectTrigger> <SelectValue placeholder="Select an aspect ratio" /> </SelectTrigger> <SelectContent> @@ -190,7 +164,7 @@ function ProjectInfoView() { value={activeProject.settings.fps.toString()} onValueChange={handleFpsChange} > - <SelectTrigger className="bg-panel-accent"> + <SelectTrigger> <SelectValue placeholder="Select a frame rate" /> </SelectTrigger> <SelectContent> @@ -219,7 +193,7 @@ const BlurPreview = memo( }) => ( <button 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", )} onClick={onSelect} @@ -265,7 +239,7 @@ const BackgroundPreviews = memo( <button key={`${index}-${bg}`} 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 && bg === currentBackgroundColor && "border-primary border-2", @@ -347,48 +321,35 @@ function BackgroundView() { [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], ); + const backgroundSections = [ + { title: "Colors", backgrounds: colors, useBackgroundColor: true }, + { title: "Pattern craft", backgrounds: patternCraftGradients }, + { title: "Syntax UI", backgrounds: syntaxUIGradients }, + ]; + return ( - <div className="flex h-full flex-col gap-4"> - <PropertyGroup title="Blur" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div> + <div className="flex h-full flex-col"> + <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> </PropertyGroup> - <PropertyGroup title="Colors" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border"> - <HugeiconsIcon icon={DropperIcon} className="size-4" /> + {backgroundSections.map((section) => ( + <PropertyGroup + key={section.title} + 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> - <BackgroundPreviews - backgrounds={colors} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - useBackgroundColor={true} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Pattern craft" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <BackgroundPreviews - backgrounds={patternCraftGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Syntax UI" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <BackgroundPreviews - backgrounds={syntaxUIGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - /> - </div> - </PropertyGroup> + </PropertyGroup> + ))} </div> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 84a5d0bf..30c70a83 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -235,7 +235,7 @@ function SoundEffectsView() { <div className="flex items-center gap-3"> <Input placeholder="Search sound effects" - className="bg-panel-accent w-full" + className="bg-accent w-full" containerClassName="w-full" value={searchQuery} onChange={({ currentTarget }) => @@ -406,7 +406,7 @@ function SavedSoundsView() { if (savedSounds.length === 0) { 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 icon={FavouriteIcon} className="text-muted-foreground size-10" diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 6b48e775..68ba91d2 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -149,7 +149,7 @@ function CollectionGrid({ function EmptyView({ message }: { message: string }) { 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 icon={HappyIcon} className="text-muted-foreground size-10" diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 6ccf5d98..6360c363 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -27,7 +27,7 @@ export function TextView() { <DraggableItem name="Default text" 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> </div> } diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx index eec063ec..dd6151d3 100644 --- a/apps/web/src/components/editor/panels/panel-base-view.tsx +++ b/apps/web/src/components/editor/panels/panel-base-view.tsx @@ -26,7 +26,7 @@ function ViewContent({ className?: string; }) { return ( - <ScrollArea className="flex-1"> + <ScrollArea className="flex-1 scrollbar-hidden"> <div className={cn("p-5", className)}>{children}</div> </ScrollArea> ); @@ -52,7 +52,7 @@ export function PanelBaseView({ onValueChange={onValueChange} 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"> <TabsList> {tabs.map((tab) => ( diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index c579b15b..902547bd 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/hooks/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; +import { useContainerSize } from "@/hooks/use-container-size"; +import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { getLastFrameTime } from "@/lib/time"; +import { formatTimeCode, getLastFrameTime } from "@/lib/time"; 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() { const editor = useEditor(); @@ -47,32 +59,134 @@ function RenderTreeController() { } export function PreviewPanel() { + const containerRef = useRef<HTMLDivElement>(null); + const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + return ( - <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm"> - <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2"> + <div + 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 /> <RenderTreeController /> </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> ); } function PreviewCanvas() { const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); - const { width, height } = usePreviewSize(); + const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); + const containerSize = useContainerSize({ containerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); const renderer = useMemo(() => { return new CanvasRenderer({ - width, - height, + width: nativeWidth, + height: nativeHeight, 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(); @@ -109,13 +223,18 @@ function PreviewCanvas() { useRafLoop(render); return ( - <div className="relative"> + <div + ref={containerRef} + className="relative flex h-full w-full items-center justify-center" + > <canvas ref={canvasRef} - width={width} - height={height} - className="block max-h-full max-w-full border" + width={nativeWidth} + height={nativeHeight} + className="block border" style={{ + width: displaySize.width, + height: displaySize.height, background: activeProject.settings.background.type === "blur" ? "transparent" diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx new file mode 100644 index 00000000..8ccf07ee --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -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> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 3a552b93..05ad5396 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; @@ -18,9 +17,9 @@ export function PropertiesPanel() { }); return ( - <> + <div className="panel bg-background h-full rounded-sm border overflow-hidden"> {selectedElements.length > 0 ? ( - <ScrollArea className="bg-panel h-full rounded-sm"> + <ScrollArea className="h-full"> {elementsWithTracks.map(({ track, element }) => { if (element.type === "text") { return ( @@ -45,24 +44,6 @@ export function PropertiesPanel() { ) : ( <EmptyView /> )} - </> - ); -} - -function EmptyView() { - return ( - <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={Settings05Icon} - className="text-muted-foreground/75 size-10" - strokeWidth={1} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">It's empty here</p> - <p className="text-muted-foreground text-sm text-balance"> - Click an element on the timeline to edit its properties - </p> - </div> </div> ); } diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx index 6d2fe444..a682097a 100644 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowDownIcon } from "@hugeicons/core-free-icons"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; interface PropertyItemProps { direction?: "row" | "column"; @@ -57,35 +57,76 @@ interface PropertyGroupProps { title: string; children: React.ReactNode; defaultExpanded?: boolean; + collapsible?: boolean; className?: string; - titleClassName?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; } export function PropertyGroup({ title, children, defaultExpanded = true, + collapsible = true, className, - titleClassName, + hasBorderTop = true, + hasBorderBottom = true, }: PropertyGroupProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); return ( - <PropertyItem direction="column" className={cn("gap-3", className)}> - <button - type="button" - className="flex items-center gap-1.5 cursor-pointer" - onClick={() => setIsExpanded(!isExpanded)} - > - <PropertyItemLabel className={cn(titleClassName)}> - {title} - </PropertyItemLabel> - <HugeiconsIcon - icon={ArrowDownIcon} - className={cn("size-3", !isExpanded && "-rotate-90")} - /> - </button> - {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} - </PropertyItem> + <div + className={cn( + "flex flex-col", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {collapsible ? ( + <button + type="button" + className="flex items-center justify-between p-3.5 cursor-pointer" + onClick={() => setIsExpanded(!isExpanded)} + > + <PropertyGroupTitle isExpanded={isExpanded}> + {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> ); } diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 35230c75..22e50e4e 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -5,29 +5,17 @@ import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { useState, useRef } from "react"; +import { useReducer, useRef } from "react"; import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; import { - TEXT_PROPERTIES_TABS, - isTextPropertiesTab, - useTextPropertiesStore, -} from "@/stores/text-properties-store"; -import { + PropertyGroup, PropertyItem, PropertyItemLabel, PropertyItemValue, } from "./property-item"; import { ColorPicker } from "@/components/ui/color-picker"; -import { cn } from "@/utils/ui"; -import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { LayoutGridIcon } from "@hugeicons/core-free-icons"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; @@ -40,48 +28,86 @@ export function TextProperties({ trackId: string; }) { const editor = useEditor(); - const { activeTab, setActiveTab } = useTextPropertiesStore(); const containerRef = useRef<HTMLDivElement>(null); - const [fontSizeInput, setFontSizeInput] = useState( - element.fontSize.toString(), - ); - const [opacityInput, setOpacityInput] = useState( - Math.round(element.opacity * 100).toString(), - ); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + const isEditingFontSize = useRef(false); + const isEditingOpacity = useRef(false); + const isEditingContent = useRef(false); + const fontSizeDraft = useRef(""); + const opacityDraft = useRef(""); + const contentDraft = useRef(""); + + const fontSizeDisplay = isEditingFontSize.current + ? fontSizeDraft.current + : element.fontSize.toString(); + const opacityDisplay = isEditingOpacity.current + ? opacityDraft.current + : Math.round(element.opacity * 100).toString(); + const contentDisplay = isEditingContent.current + ? contentDraft.current + : element.content; const lastSelectedColor = useRef(DEFAULT_COLOR); const initialFontSizeRef = useRef<number | null>(null); const initialOpacityRef = useRef<number | null>(null); + const initialContentRef = useRef<string | null>(null); + const initialColorRef = useRef<string | null>(null); + const initialBgColorRef = useRef<string | null>(null); const handleFontSizeChange = ({ value }: { value: string }) => { - setFontSizeInput(value); + fontSizeDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; + } const parsed = parseInt(value, 10); const fontSize = Number.isNaN(parsed) ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); editor.timeline.updateElements({ updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: false, }); } }; const handleFontSizeBlur = () => { - const parsed = parseInt(fontSizeInput, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - setFontSizeInput(fontSize.toString()); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - }); + if (initialFontSizeRef.current !== null) { + const parsed = parseInt(fontSizeDraft.current, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: initialFontSizeRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: true, + }); + initialFontSizeRef.current = null; + } + isEditingFontSize.current = false; + fontSizeDraft.current = ""; + forceRender(); }; const handleOpacityChange = ({ value }: { value: string }) => { - setOpacityInput(value); + opacityDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } const parsed = parseInt(value, 10); const opacityPercent = Number.isNaN(parsed) ? Math.round(element.opacity * 100) @@ -94,234 +120,333 @@ export function TextProperties({ updates: { opacity: opacityPercent / 100 }, }, ], + pushHistory: false, }); } }; const handleOpacityBlur = () => { - const parsed = parseInt(opacityInput, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - setOpacityInput(opacityPercent.toString()); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - }); + if (initialOpacityRef.current !== null) { + const parsed = parseInt(opacityDraft.current, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: initialOpacityRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; + } + isEditingOpacity.current = false; + opacityDraft.current = ""; + forceRender(); }; const handleColorChange = ({ color }: { color: string }) => { if (color !== "transparent") { lastSelectedColor.current = color; } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - }); + if (initialBgColorRef.current === null) { + initialBgColorRef.current = element.backgroundColor; + } + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + pushHistory: false, + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + }); + } }; - const handleTransparentToggle = ({ - isTransparent, - }: { - isTransparent: boolean; - }) => { - const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: newColor }, - }, - ], - }); + const handleColorChangeEnd = ({ color }: { color: string }) => { + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: initialBgColorRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: `#${color}` }, + }, + ], + pushHistory: true, + }); + initialBgColorRef.current = null; + } }; return ( - <PanelBaseView - defaultTab="transform" - value={activeTab} - onValueChange={(v) => { - if (isTextPropertiesTab(v)) setActiveTab(v); - }} - ref={containerRef} - tabs={TEXT_PROPERTIES_TABS.map((t) => ({ - value: t.value, - label: t.label, - content: - t.value === "transform" ? ( - <div className="space-y-6"></div> - ) : ( - <div className="space-y-6"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="bg-panel-accent min-h-20" - onChange={(e) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { content: e.target.value }, - }, - ], - }) - } - /> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => + <div className="flex h-full flex-col" ref={containerRef}> + <PanelBaseView className="p-0"> + <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> + <Textarea + placeholder="Name" + value={contentDisplay} + className="bg-accent min-h-20" + onFocus={() => { + isEditingContent.current = true; + contentDraft.current = element.content; + initialContentRef.current = element.content; + forceRender(); + }} + onChange={(event) => { + contentDraft.current = event.target.value; + forceRender(); + if (initialContentRef.current === null) { + initialContentRef.current = element.content; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: event.target.value }, + }, + ], + pushHistory: false, + }); + }} + onBlur={() => { + if (initialContentRef.current !== null) { + const finalContent = contentDraft.current; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: initialContentRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: finalContent }, + }, + ], + pushHistory: true, + }); + initialContentRef.current = null; + } + isEditingContent.current = false; + contentDraft.current = ""; + forceRender(); + }} + /> + </PropertyGroup> + <PropertyGroup title="Typography" collapsible={false}> + <div className="space-y-6"> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { fontFamily: value }, + updates: { + fontWeight: + element.fontWeight === "bold" + ? "normal" + : "bold", + }, }, ], }) } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" - ? "normal" - : "bold", - }, + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", }, - ], - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, + }, + ], + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", }, - ], - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, + }, + ], + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", }, - ], - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" + }, + ], + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => { + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; } - size="sm" - onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: value }, + }, + ], + pushHistory: false, + }); + }} + onValueCommit={([value]) => { + if (initialFontSizeRef.current !== null) { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", + fontSize: initialFontSizeRef.current, }, }, ], - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } + pushHistory: false, + }); editor.timeline.updateElements({ updates: [ { @@ -330,61 +455,48 @@ export function TextProperties({ updates: { fontSize: value }, }, ], - pushHistory: false, + pushHistory: true, }); - setFontSizeInput(value.toString()); - }} - onValueCommit={([value]) => { - if (initialFontSizeRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: initialFontSizeRef.current, - }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeInput} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onChange={(e) => - handleFontSizeChange({ value: e.target.value }) + initialFontSizeRef.current = null; } - onBlur={handleFontSizeBlur} - className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeDisplay} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={() => { + isEditingFontSize.current = true; + fontSizeDraft.current = element.fontSize.toString(); + forceRender(); + }} + onChange={(e) => + handleFontSizeChange({ value: e.target.value }) + } + onBlur={handleFontSizeBlur} + className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + </PropertyGroup> + <PropertyGroup title="Appearance" collapsible={false}> + <div className="space-y-6"> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => { + if (initialColorRef.current === null) { + initialColorRef.current = element.color || "#FFFFFF"; + } + if (initialColorRef.current !== null) { editor.timeline.updateElements({ updates: [ { @@ -393,131 +505,146 @@ export function TextProperties({ updates: { color: `#${color}` }, }, ], + pushHistory: false, + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }); + } + }} + onChangeEnd={(color) => { + if (initialColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: initialColorRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + pushHistory: true, + }); + initialColorRef.current = null; + } + }} + containerRef={containerRef} + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: false, }); }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } + onValueCommit={([value]) => { + if (initialOpacityRef.current !== null) { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { opacity: value / 100 }, + updates: { opacity: initialOpacityRef.current }, }, ], pushHistory: false, }); - setOpacityInput(value.toString()); - }} - onValueCommit={([value]) => { - if (initialOpacityRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={opacityInput} - min={0} - max={100} - onChange={(e) => - handleOpacityChange({ value: e.target.value }) - } - onBlur={handleOpacityBlur} - className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Background</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <ColorPicker - value={capitalizeFirstLetter({ - string: - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", ""), - })} - onChange={(color) => - handleColorChange({ color: `#${color}` }) - } - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; } - /> - - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="icon" - onClick={() => - handleTransparentToggle({ - isTransparent: - element.backgroundColor !== "transparent", - }) - } - className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" - > - <HugeiconsIcon - icon={LayoutGridIcon} - className={cn( - "text-foreground", - element.backgroundColor === "transparent" && - "text-primary", - )} - /> - </Button> - </TooltipTrigger> - <TooltipContent>Transparent background</TooltipContent> - </Tooltip> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - ), - }))} - /> + }} + className="w-full" + /> + <Input + type="number" + value={opacityDisplay} + min={0} + max={100} + onFocus={() => { + isEditingOpacity.current = true; + opacityDraft.current = Math.round( + element.opacity * 100, + ).toString(); + forceRender(); + }} + onChange={(e) => + handleOpacityChange({ value: e.target.value }) + } + onBlur={handleOpacityBlur} + className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => + handleColorChange({ color: `#${color}` }) + } + onChangeEnd={(color) => handleColorChangeEnd({ color })} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + </PropertyItemValue> + </PropertyItem> + </div> + </PropertyGroup> + </PanelBaseView> + </div> ); } diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx index bbc5cb83..bbc5cb83 100644 --- a/apps/web/src/components/editor/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index cfcb1e23..cfcb1e23 100644 --- a/apps/web/src/components/editor/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index f31c6e28..f31c6e28 100644 --- a/apps/web/src/components/editor/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 2ff17345..94edc2d0 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -15,12 +15,12 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../../ui/context-menu"; +} from "../../../ui/context-menu"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; import { useState, useRef, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "../selection-box"; +import { SelectionBox } from "../../selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; @@ -201,7 +201,7 @@ export function Timeline() { return ( <section 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} aria-label="Timeline" @@ -226,17 +226,17 @@ export function Timeline() { isVisible={showSnapIndicator} /> <div className="flex flex-1 overflow-hidden"> - <div className="bg-panel flex w-28 shrink-0 flex-col border-r"> - <div className="bg-panel flex h-4 items-center justify-between px-3"> + <div className="bg-background flex w-28 shrink-0 flex-col border-r"> + <div className="bg-background flex h-4 items-center justify-between px-3"> <span className="opacity-0">.</span> </div> - <div className="bg-panel flex h-4 items-center justify-between px-3"> + <div className="bg-background flex h-4 items-center justify-between px-3"> <span className="opacity-0">.</span> </div> {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="bg-panel flex-1 overflow-y-auto" + className="bg-background flex-1 overflow-y-auto" style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} > <ScrollArea className="size-full" ref={trackLabelsScrollRef}> @@ -351,7 +351,7 @@ export function Timeline() { > <div 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 zoomLevel={zoomLevel} diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 914edccb..914edccb 100644 --- a/apps/web/src/components/editor/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index fd292ad8..59ce88da 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -19,7 +19,7 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../ui/context-menu"; +} from "../../../ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -166,7 +166,10 @@ export function TimelineElement({ </div> </ContextMenuTrigger> <ContextMenuContent className="z-200 w-64"> - <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> + <ActionMenuItem + action="split" + icon={<HugeiconsIcon icon={ScissorIcon} />} + > Split </ActionMenuItem> <CopyMenuItem /> @@ -185,7 +188,10 @@ export function TimelineElement({ /> )} {selectedElements.length === 1 && ( - <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + <ActionMenuItem + action="duplicate-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > Duplicate </ActionMenuItem> )} @@ -446,7 +452,10 @@ function ElementContent({ function CopyMenuItem() { return ( - <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + <ActionMenuItem + action="copy-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > Copy </ActionMenuItem> ); @@ -502,7 +511,10 @@ function VisibilityMenuItem({ }; return ( - <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> + <ActionMenuItem + action="toggle-elements-visibility-selected" + icon={getIcon()} + > {isHidden ? "Show" : "Hide"} </ActionMenuItem> ); diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 8af35dc9..8af35dc9 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index dfa0534b..b0261aa2 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import { type JSX, useLayoutEffect, useRef } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; @@ -44,7 +44,21 @@ export function TimelineRuler({ 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( 0, (scrollLeft - bufferPx) / pixelsPerSecond, @@ -62,7 +76,11 @@ export function TimelineRuler({ ); 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; if (time > effectiveDuration) break; diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..9402d25e 100644 --- a/apps/web/src/components/editor/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 6ac6fba5..0b02c63f 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -6,7 +6,7 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; -import { SkipBack, SplitSquareHorizontal } from "lucide-react"; +import { SplitSquareHorizontal } from "lucide-react"; import { SplitButton, SplitButtonLeft, @@ -14,11 +14,9 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; 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 { cn } from "@/utils/ui"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -32,8 +30,6 @@ import { Link04Icon, SearchAddIcon, SearchMinusIcon, - PauseIcon, - PlayIcon, Copy01Icon, AlignLeftIcon, AlignRightIcon, @@ -82,7 +78,6 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const isPlaying = editor.playback.getIsPlaying(); const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ @@ -100,32 +95,6 @@ function ToolbarLeftSection() { <div className="flex items-center gap-1"> <TooltipProvider delayDuration={500}> <ToolbarButton - icon={ - isPlaying ? ( - <HugeiconsIcon icon={PauseIcon} /> - ) : ( - <HugeiconsIcon icon={PlayIcon} /> - ) - } - tooltip={isPlaying ? "Pause" : "Play"} - onClick={({ event }) => - handleAction({ action: "toggle-play", event }) - } - /> - - <ToolbarButton - icon={<SkipBack />} - tooltip="Go to start" - onClick={({ event }) => handleAction({ action: "goto-start", event })} - /> - - <div className="bg-border mx-1 h-6 w-px" /> - - <TimeDisplay /> - - <div className="bg-border mx-1 h-6 w-px" /> - - <ToolbarButton icon={<HugeiconsIcon icon={ScissorIcon} />} tooltip="Split element" onClick={({ event }) => handleAction({ action: "split", event })} @@ -179,12 +148,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton - icon={ - <HugeiconsIcon - icon={Bookmark02Icon} - className={currentBookmarked ? "fill-primary text-primary" : ""} - /> - } + icon={<HugeiconsIcon icon={Bookmark02Icon} />} + isActive={currentBookmarked} tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ 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() { const editor = useEditor(); const currentScene = editor.scenes.getActiveScene(); @@ -265,26 +202,15 @@ function ToolbarRightSection({ <div className="flex items-center gap-1"> <TooltipProvider delayDuration={500}> <ToolbarButton - icon={ - <HugeiconsIcon - icon={MagnetIcon} - className={cn(snappingEnabled ? "text-primary" : "")} - /> - } + icon={<HugeiconsIcon icon={MagnetIcon} />} + isActive={snappingEnabled} tooltip="Auto snapping" onClick={() => toggleSnapping()} /> <ToolbarButton - icon={ - <HugeiconsIcon - icon={Link04Icon} - className={cn( - rippleEditingEnabled ? "text-primary" : "", - "scale-110", - )} - /> - } + icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />} + isActive={rippleEditingEnabled} tooltip="Ripple editing" onClick={() => toggleRippleEditing()} /> @@ -329,21 +255,26 @@ function ToolbarButton({ tooltip, onClick, disabled, + isActive, }: { icon: React.ReactNode; tooltip: string; onClick: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; + isActive?: boolean; }) { return ( <Tooltip delayDuration={200}> <TooltipTrigger asChild> <Button - variant="text" + variant={isActive ? "secondary" : "text"} size="icon" type="button" onClick={(event) => onClick({ event })} - className={disabled ? "cursor-not-allowed opacity-50" : ""} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} > {icon} </Button> diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 47a9f282..b7b06d5b 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/types/timeline"; import { useEditor } from "@/hooks/use-editor"; -import { cn } from "@/utils/ui"; interface TimelineTrackContentProps { track: TimelineTrack; @@ -63,13 +62,9 @@ export function TimelineTrackContent({ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, }); - const hasSelectedElements = track.elements.some((element) => - isElementSelected({ trackId: track.id, elementId: element.id }), - ); - return ( <button - className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} + className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; clearElementSelection(); diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index 394d75b2..b6e8d274 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -22,8 +22,8 @@ export function ThemeToggle({ return (
<Button
size="icon"
- variant="text"
- className={cn("h-7", className)}
+ variant="ghost"
+ className={cn("size-8", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 6c063eff..2fc2c2e6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -10,7 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: - "bg-primary text-primary-foreground hover:bg-primary-hover", + "bg-primary text-primary-foreground hover:bg-primary/90", background: "bg-background text-foreground hover:bg-background/90", foreground: @@ -22,13 +22,14 @@ const buttonVariants = cva( outline: "border border-border bg-transparent hover:bg-accent/50", 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", + ghost: "bg-transparent hover:bg-accent", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", }, size: { 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", icon: "size-7", text: "p-0", diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index a57a86e0..0ea1a14d 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -6,6 +6,7 @@ import { Input } from "./input"; interface ColorPickerProps { value?: string; onChange?: (value: string) => void; + onChangeEnd?: (value: string) => void; className?: string; containerRef?: React.RefObject<HTMLDivElement | null>; } @@ -86,7 +87,7 @@ const hsvToHex = (h: number, s: number, v: number) => { }; 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 [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( null, @@ -102,6 +103,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const saturationRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); + const latestDragColorRef = useRef<string | null>(null); const [h, s, v] = hexToHsv(value); const displayHue = s > 0 ? h : internalHue; @@ -144,6 +146,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newS = x; const newV = 1 - y; const newHex = hsvToHex(displayHue, newS, newV); + latestDragColorRef.current = newHex; onChange?.(newHex); } @@ -157,12 +160,17 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( setInternalHue(newH); if (s > 0) { const newHex = hsvToHex(newH, s, v); + latestDragColorRef.current = newHex; onChange?.(newHex); } } }; const handleMouseUp = () => { + if (latestDragColorRef.current !== null && onChangeEnd) { + onChangeEnd(latestDragColorRef.current); + latestDragColorRef.current = null; + } setIsDragging(null); }; @@ -187,6 +195,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newS = x; const newV = 1 - y; const newHex = hsvToHex(displayHue, newS, newV); + latestDragColorRef.current = newHex; onChange?.(newHex); }; @@ -201,6 +210,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( setInternalHue(newH); if (s > 0) { const newHex = hsvToHex(newH, s, v); + latestDragColorRef.current = newHex; onChange?.(newHex); } }; @@ -235,14 +245,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <div ref={ref} 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, )} {...props} > <button 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}` }} type="button" onClick={() => { @@ -259,7 +269,8 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( /> <div className="flex flex-1 items-center"> <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" value={inputValue} onChange={handleInputChange} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 0f77a4ce..f5d96125 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -114,7 +114,7 @@ const CommandItem = React.forwardRef< <CommandPrimitive.Item ref={ref} 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, )} {...props} diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index a7736e6e..d8a9f594 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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: { variant: { diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 1806b48a..4b8e364a 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -6,6 +6,7 @@ import { SelectValue, } from "@/components/ui/select"; import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; +import { cn } from "@/utils/ui"; interface FontPickerProps { defaultValue?: FontFamily; @@ -21,16 +22,15 @@ export function FontPicker({ return ( <Select defaultValue={defaultValue} onValueChange={onValueChange}> <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> <SelectContent> {FONT_OPTIONS.map((font) => ( <SelectItem key={font.value} value={font.value} - className="text-sn" style={{ fontFamily: font.value }} > {font.label} diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index 392ab3c3..f8265779 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -55,7 +55,7 @@ export function InputWithBack({ >
<Button
variant="outline"
- className="bg-panel-accent !size-9 rounded-full"
+ className="bg-accent !size-9 rounded-full"
>
<ArrowLeft />
</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" />
<Input
placeholder={placeholder}
- className="bg-panel-accent w-full pl-9"
+ className="bg-accent w-full pl-9"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index 0ea7be03..a9a5166f 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; 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< diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 34738303..36ae5a69 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; - import { cn } from "@/utils/ui"; const Popover = PopoverPrimitive.Root; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 79462d4a..fd9c7ac5 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; 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: { variant: { @@ -35,7 +35,7 @@ const SelectTrigger = React.forwardRef< <SelectPrimitive.Trigger ref={ref} 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", className, )} @@ -159,7 +159,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator 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} /> )); diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 994159a1..57ddbcf0 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -17,7 +17,7 @@ const Slider = React.forwardRef< )} {...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.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" /> diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index 26e54cab..aa8b331a 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( <div
ref={ref}
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,
)}
{...props}
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef< ref={ref}
variant="text"
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
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text",
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 3668477c..d2844b63 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef< <TabsPrimitive.Trigger ref={ref} 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, )} {...props} diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts index 095c0599..fc5df4d9 100644 --- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts +++ b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts @@ -60,10 +60,7 @@ export function useEdgeAutoScroll({ mouseXRelative > viewportWidth - edgeThreshold && rulerViewport.scrollLeft < scrollMax ) { - const edgeDistance = Math.max( - 0, - viewportWidth - edgeThreshold - mouseXRelative, - ); + const edgeDistance = Math.max(0, viewportWidth - mouseXRelative); const intensity = 1 - edgeDistance / edgeThreshold; scrollSpeed = maxScrollSpeed * intensity; } diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index 13024103..10563a05 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -44,7 +44,7 @@ export function useTimelineZoom({ null, ); - const [zoomLevel, setZoomLevel] = useState(() => { + const [zoomLevel, setZoomLevelRaw] = useState(() => { if (initialZoom !== undefined) { hasInitializedRef.current = true; return Math.max( @@ -56,6 +56,18 @@ export function useTimelineZoom({ }); const previousZoomRef = useRef(zoomLevel); 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( (event: ReactWheelEvent) => { @@ -82,7 +94,7 @@ export function useTimelineZoom({ return; } }, - [minZoom], + [minZoom, setZoomLevel], ); useEffect(() => { @@ -99,7 +111,7 @@ export function useTimelineZoom({ } return prev; }); - }, [minZoom, initialZoom]); + }, [minZoom, initialZoom, setZoomLevel]); const wrappedSetZoomLevel = useCallback( (zoomLevelOrUpdater: number | ((prev: number) => number)) => { @@ -115,7 +127,7 @@ export function useTimelineZoom({ return clampedZoom; }); }, - [minZoom], + [minZoom, setZoomLevel], ); useLayoutEffect(() => { @@ -128,7 +140,7 @@ export function useTimelineZoom({ return; } - const currentScrollLeft = scrollElement.scrollLeft; + const currentScrollLeft = preZoomScrollLeftRef.current; const playheadTime = editor.playback.getCurrentTime(); const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); diff --git a/apps/web/src/hooks/use-container-size.ts b/apps/web/src/hooks/use-container-size.ts new file mode 100644 index 00000000..8b46f20e --- /dev/null +++ b/apps/web/src/hooks/use-container-size.ts @@ -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; +} diff --git a/apps/web/src/hooks/use-fullscreen.ts b/apps/web/src/hooks/use-fullscreen.ts new file mode 100644 index 00000000..193441c9 --- /dev/null +++ b/apps/web/src/hooks/use-fullscreen.ts @@ -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 }; +} diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 6aeb0c9b..31b33504 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -137,6 +137,16 @@ export function usePreviewInteraction({ const deltaX = currentPos.x - dragStateRef.current.startX; 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 editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot); diff --git a/apps/web/src/stores/text-properties-store.ts b/apps/web/src/stores/text-properties-store.ts deleted file mode 100644 index c7476b46..00000000 --- a/apps/web/src/stores/text-properties-store.ts +++ /dev/null @@ -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" },
- ),
-);
|
