another huge refactor

This commit is contained in:
Maze Winther
2025-08-27 00:52:39 +02:00
parent b459b46802
commit adc8cb37fc
20 changed files with 731 additions and 599 deletions
@@ -0,0 +1,139 @@
"use client";
import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
import { HeaderBase } from "../header-base";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import Link from "next/link";
import { RenameProjectDialog } from "../rename-project-dialog";
import { DeleteProjectDialog } from "../delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { useTheme } from "next-themes";
import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
export function EditorHeader() {
const { activeProject, renameProject, deleteProject } = useProjectStore();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const router = useRouter();
const { theme, setTheme } = useTheme();
const handleNameSave = async (newName: string) => {
console.log("handleNameSave", newName);
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
setIsRenameDialogOpen(false);
} catch (error) {
console.error("Failed to rename project:", error);
}
}
};
const handleDelete = () => {
if (activeProject) {
deleteProject(activeProject.id);
setIsDeleteDialogOpen(false);
router.push("/projects");
}
};
const leftContent = (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
className="h-auto py-1.5 px-2.5 flex items-center justify-center"
>
<ChevronDown className="text-muted-foreground" />
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40 z-100">
<Link href="/projects">
<DropdownMenuItem className="flex items-center gap-1.5">
<ArrowLeft className="h-4 w-4" />
Projects
</DropdownMenuItem>
</Link>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setIsRenameDialogOpen(true)}
>
<SquarePen className="h-4 w-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="flex items-center gap-1.5"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash className="h-4 w-4" />
Delete Project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="https://discord.gg/zmR9N35cjK"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5"
>
<FaDiscord className="h-4 w-4" />
Discord
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={handleNameSave}
projectName={activeProject?.name || ""}
/>
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDelete}
projectName={activeProject?.name || ""}
/>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<ExportButton />
<Button
size="icon"
variant="text"
className="h-7"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="!size-[1.1rem]" />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
rightContent={rightContent}
className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
/>
);
}
@@ -0,0 +1,246 @@
"use client";
import { useState } from "react";
import { TransitionUpIcon } from "../icons";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
import { cn } from "@/lib/utils";
import {
exportProject,
getExportMimeType,
getExportFileExtension,
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export";
import { useProjectStore } from "@/stores/project-store";
import { Download, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const { activeProject } = useProjectStore();
const handleExport = () => {
setIsExportPopoverOpen(true);
};
const hasProject = !!activeProject;
return (
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
hasProject
? "cursor-pointer hover:brightness-95"
: "cursor-not-allowed opacity-50"
)}
onClick={hasProject ? handleExport : undefined}
disabled={!hasProject}
onKeyDown={(event) => {
if (hasProject && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
handleExport();
}
}}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
</div>
</div>
</button>
</PopoverTrigger>
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
</Popover>
);
}
function ExportPopover({
onOpenChange,
}: {
onOpenChange: (open: boolean) => void;
}) {
const { activeProject } = useProjectStore();
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality
);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
const handleExport = async () => {
if (!activeProject) return;
setIsExporting(true);
setProgress(0);
setExportResult(null);
const result = await exportProject({
format,
quality,
fps: activeProject.fps,
onProgress: setProgress,
onCancel: () => false, // TODO: Add cancel functionality
});
setIsExporting(false);
setExportResult(result);
if (result.success && result.buffer) {
// Download the file
const mimeType = getExportMimeType(format);
const extension = getExportFileExtension(format);
const blob = new Blob([result.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${activeProject.name}${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
const handleClose = () => {
if (!isExporting) {
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
return (
<PopoverContent className="w-80 mr-4 flex flex-col gap-3">
<>
<div className="flex items-center justify-between">
<h3 className=" font-medium">
{isExporting ? "Exporting project" : "Export project"}
</h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="!size-5 text-foreground/85" />
</Button>
</div>
<div className="flex flex-col gap-4">
{!isExporting && (
<>
<div className="flex flex-col gap-3">
<PropertyGroup
title="Format"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup
value={format}
onValueChange={(value) => setFormat(value as ExportFormat)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mp4" id="mp4" />
<Label htmlFor="mp4">
MP4 (H.264) - Better compatibility
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="webm" id="webm" />
<Label htmlFor="webm">
WebM (VP9) - Smaller file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<PropertyGroup
title="Quality"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup
value={quality}
onValueChange={(value) =>
setQuality(value as ExportQuality)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
</div>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="w-4 h-4" />
Export
</Button>
</>
)}
{isExporting && (
<div className="space-y-4">
<div className="flex flex-col">
<div className="text-center flex items-center justify-between">
<p className="text-sm text-muted-foreground mb-2">
{Math.round(progress * 100)}%
</p>
<p className="text-sm text-muted-foreground mb-2">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="rounded-md w-full"
onClick={() => {}}
>
Cancel
</Button>
</div>
)}
{exportResult && !exportResult.success && (
<div className="text-center space-y-3">
<div className="text-red-600 font-medium">Export failed</div>
<p className="text-sm text-muted-foreground">
{exportResult.error || "Unknown error occurred"}
</p>
<Button variant="outline" onClick={() => setExportResult(null)}>
Try Again
</Button>
</div>
)}
</div>
</>
</PopoverContent>
);
}
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
import { BaseView } from "./base-view";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { Language, LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
@@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { BaseView } from "./base-view";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
PropertyItem,
PropertyItemLabel,
@@ -1,5 +1,5 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { BaseView } from "./base-view";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
@@ -0,0 +1,138 @@
"use client";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { Button } from "../ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const getStepTitle = () => {
switch (step) {
case 0:
return "Welcome to OpenCut Beta! 🎉";
case 1:
return "⚠️ This is a super early beta!";
case 2:
return "🦋 Have fun testing!";
default:
return "OpenCut Onboarding";
}
};
const renderStepContent = () => {
switch (step) {
case 0:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 1:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title={getStepTitle()} />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 2:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title={getStepTitle()} />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none pt-2">
<DialogTitle>
<span className="sr-only">{getStepTitle()}</span>
</DialogTitle>
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
function Title({ title }: { title: string }) {
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-foreground/80 underline"
>
{children}
</a>
),
}}
>
{description}
</ReactMarkdown>
</div>
);
}
function NextButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button onClick={onClick} variant="default" className="w-full">
{children}
<ArrowRightIcon className="w-4 h-4" />
</Button>
);
}
@@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface BaseViewProps {
interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
value?: string;
onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
@@ -28,29 +30,38 @@ function ViewContent({
);
}
export function BaseView({
export function PanelBaseView({
children,
defaultTab,
value,
onValueChange,
tabs,
className = "",
ref,
}: BaseViewProps) {
}: PanelBaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<Tabs
defaultValue={defaultTab}
value={value}
onValueChange={onValueChange}
className="flex flex-col h-full"
>
<div className="sticky top-0 z-10 bg-panel">
<div className="px-3 pt-4 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<Separator className="mt-4" />
</div>
<Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
@@ -0,0 +1,91 @@
"use client";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
const PRESET_LABELS: Record<PanelPreset, string> = {
default: "Default",
media: "Media",
inspector: "Inspector",
"vertical-preview": "Vertical Preview",
};
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
default: "Media, preview, and inspector on top row, timeline on bottom",
media: "Full height media on left, preview and inspector on top row",
inspector: "Full height inspector on right, media and preview on top row",
"vertical-preview": "Full height preview on right for vertical videos",
};
export function PanelPresetSelector() {
const { activePreset, setActivePreset, resetPreset } = usePanelStore();
const handlePresetChange = (preset: PanelPreset) => {
setActivePreset(preset);
};
const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
event.stopPropagation();
resetPreset(preset);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 flex items-center gap-1 text-xs"
title="Panel Presets"
>
<LayoutPanelTop className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
className="flex items-start justify-between gap-2 py-2 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PRESET_LABELS[preset]}
</span>
{activePreset === preset && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
{PRESET_DESCRIPTIONS[preset]}
</p>
</div>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
onClick={(e) => handleResetPreset(preset, e)}
title={`Reset ${PRESET_LABELS[preset]} preset`}
>
<RotateCcw className="h-3 w-3" />
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -538,6 +538,7 @@ export function PreviewPanel() {
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
// Blit offscreen to visible canvas
@@ -57,6 +57,7 @@ interface PropertyGroupProps {
children: React.ReactNode;
defaultExpanded?: boolean;
className?: string;
titleClassName?: string;
}
export function PropertyGroup({
@@ -64,6 +65,7 @@ export function PropertyGroup({
children,
defaultExpanded = true,
className,
titleClassName,
}: PropertyGroupProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -73,7 +75,7 @@ export function PropertyGroup({
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<PropertyItemLabel className="cursor-pointer">
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
{title}
</PropertyItemLabel>
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
@@ -6,8 +6,14 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; // Add Switch import
import { Switch } from "@/components/ui/switch";
import { useState, useRef } from "react";
import { PanelBaseView } from "@/components/editor/panel-base-view";
import {
TEXT_PROPERTIES_TABS,
isTextPropertiesTab,
useTextPropertiesStore,
} from "@/stores/text-properties-store";
import {
PropertyItem,
PropertyItemLabel,
@@ -22,6 +28,7 @@ export function TextProperties({
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
const { activeTab, setActiveTab } = useTextPropertiesStore();
// Local state for input values to allow temporary empty/invalid states
const [fontSizeInput, setFontSizeInput] = useState(
@@ -105,199 +112,232 @@ export function TextProperties({
};
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-18 resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItem direction="row">
<PropertyItemLabel>Style</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Button
variant={element.fontWeight === "bold" ? "default" : "outline"}
size="sm"
onClick={() =>
<PanelBaseView
defaultTab="transform"
value={activeTab}
onValueChange={(v) => {
if (isTextPropertiesTab(v)) setActiveTab(v);
}}
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
value: t.value,
label: t.label,
content:
t.value === "transform" ? (
<div className="space-y-6"></div>
) : (
<div className="space-y-6">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-18 resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontWeight:
element.fontWeight === "bold" ? "normal" : "bold",
content: e.target.value,
})
}
className="h-8 px-3 font-bold"
>
B
</Button>
<Button
variant={element.fontStyle === "italic" ? "default" : "outline"}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
fontStyle:
element.fontStyle === "italic" ? "normal" : "italic",
})
}
className="h-8 px-3 italic"
>
I
</Button>
<Button
variant={
element.textDecoration === "underline" ? "default" : "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
textDecoration:
element.textDecoration === "underline"
? "none"
: "underline",
})
}
className="h-8 px-3 underline"
>
U
</Button>
<Button
variant={
element.textDecoration === "line-through"
? "default"
: "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
textDecoration:
element.textDecoration === "line-through"
? "none"
: "line-through",
})
}
className="h-8 px-3 line-through"
>
S
</Button>
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, {
fontFamily: value,
})
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItem direction="row">
<PropertyItemLabel>Style</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Button
variant={
element.fontWeight === "bold" ? "default" : "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
fontWeight:
element.fontWeight === "bold" ? "normal" : "bold",
})
}
className="h-8 px-3 font-bold"
>
B
</Button>
<Button
variant={
element.fontStyle === "italic" ? "default" : "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
fontStyle:
element.fontStyle === "italic"
? "normal"
: "italic",
})
}
className="h-8 px-3 italic"
>
I
</Button>
<Button
variant={
element.textDecoration === "underline"
? "default"
: "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
textDecoration:
element.textDecoration === "underline"
? "none"
: "underline",
})
}
className="h-8 px-3 underline"
>
U
</Button>
<Button
variant={
element.textDecoration === "line-through"
? "default"
: "outline"
}
size="sm"
onClick={() =>
updateTextElement(trackId, element.id, {
textDecoration:
element.textDecoration === "line-through"
? "none"
: "line-through",
})
}
className="h-8 px-3 line-through"
>
S
</Button>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) => {
updateTextElement(trackId, element.id, {
fontSize: value,
});
setFontSizeInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={fontSizeInput}
min={8}
max={300}
onChange={(e) => handleFontSizeChange(e.target.value)}
onBlur={handleFontSizeBlur}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="row">
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<Input
type="color"
value={element.color || "#ffffff"}
onChange={(e) => {
const color = e.target.value;
updateTextElement(trackId, element.id, { color });
}}
className="w-full cursor-pointer rounded-full"
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Opacity</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.opacity * 100]}
min={0}
max={100}
step={1}
onValueChange={([value]) => {
updateTextElement(trackId, element.id, {
opacity: value / 100,
});
setOpacityInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={opacityInput}
min={0}
max={100}
onChange={(e) => handleOpacityChange(e.target.value)}
onBlur={handleOpacityBlur}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<div className="flex items-center justify-between">
<PropertyItemLabel>Background</PropertyItemLabel>
<div className="flex items-center space-x-2">
<Switch
id="transparent-bg-toggle"
checked={element.backgroundColor === "transparent"}
onCheckedChange={handleTransparentToggle}
/>
<label
htmlFor="transparent-bg-toggle"
className="text-sm font-medium"
>
Transparent
</label>
</div>
</div>
<PropertyItemValue>
<Input
type="color"
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current
: element.backgroundColor || "#000000"
}
onChange={(e) => handleColorChange(e.target.value)}
className="w-full cursor-pointer rounded-full"
disabled={element.backgroundColor === "transparent"}
/>
</PropertyItemValue>
</PropertyItem>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) => {
updateTextElement(trackId, element.id, { fontSize: value });
setFontSizeInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={fontSizeInput}
min={8}
max={300}
onChange={(e) => handleFontSizeChange(e.target.value)}
onBlur={handleFontSizeBlur}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="row">
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<Input
type="color"
value={element.color || "#ffffff"}
onChange={(e) => {
const color = e.target.value;
updateTextElement(trackId, element.id, { color });
}}
className="w-full cursor-pointer rounded-full"
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<div className="flex items-center justify-between">
<PropertyItemLabel>Background</PropertyItemLabel>
<div className="flex items-center space-x-2">
<Switch
id="transparent-bg-toggle"
checked={element.backgroundColor === "transparent"}
onCheckedChange={handleTransparentToggle}
/>
<label htmlFor="transparent-bg-toggle" className="text-sm font-medium">
Transparent
</label>
</div>
</div>
<PropertyItemValue>
<Input
type="color"
value={
element.backgroundColor === "transparent"
? lastSelectedColor.current
: element.backgroundColor || "#000000"
}
onChange={(e) => handleColorChange(e.target.value)}
className="w-full cursor-pointer rounded-full"
disabled={element.backgroundColor === "transparent"}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Opacity</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
value={[element.opacity * 100]}
min={0}
max={100}
step={1}
onValueChange={([value]) => {
updateTextElement(trackId, element.id, {
opacity: value / 100,
});
setOpacityInput(value.toString());
}}
className="w-full"
/>
<Input
type="number"
value={opacityInput}
min={0}
max={100}
onChange={(e) => handleOpacityChange(e.target.value)}
onBlur={handleOpacityBlur}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
),
}))}
/>
);
}