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