From adc8cb37fcec21afac848b1e5dd724394c344050 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 27 Aug 2025 00:52:39 +0200 Subject: another huge refactor --- apps/web/src/components/editor/editor-header.tsx | 139 +++++++ apps/web/src/components/editor/export-button.tsx | 246 ++++++++++++ .../editor/media-panel/views/base-view.tsx | 67 ---- .../editor/media-panel/views/captions.tsx | 2 +- .../editor/media-panel/views/settings.tsx | 2 +- .../components/editor/media-panel/views/text.tsx | 2 +- apps/web/src/components/editor/onboarding.tsx | 138 +++++++ apps/web/src/components/editor/panel-base-view.tsx | 78 ++++ .../components/editor/panel-preset-selector.tsx | 91 +++++ apps/web/src/components/editor/preview-panel.tsx | 1 + .../editor/properties-panel/property-item.tsx | 4 +- .../editor/properties-panel/text-properties.tsx | 412 +++++++++++---------- 12 files changed, 925 insertions(+), 257 deletions(-) create mode 100644 apps/web/src/components/editor/editor-header.tsx create mode 100644 apps/web/src/components/editor/export-button.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/base-view.tsx create mode 100644 apps/web/src/components/editor/onboarding.tsx create mode 100644 apps/web/src/components/editor/panel-base-view.tsx create mode 100644 apps/web/src/components/editor/panel-preset-selector.tsx (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx new file mode 100644 index 00000000..ad2a5b5a --- /dev/null +++ b/apps/web/src/components/editor/editor-header.tsx @@ -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 = ( +
+ + + + + + + + + Projects + + + setIsRenameDialogOpen(true)} + > + + Rename project + + setIsDeleteDialogOpen(true)} + > + + Delete Project + + + + + + Discord + + + + + + +
+ ); + + const rightContent = ( + + ); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx new file mode 100644 index 00000000..e24d20df --- /dev/null +++ b/apps/web/src/components/editor/export-button.tsx @@ -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 ( + + + + + {hasProject && } + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const { activeProject } = useProjectStore(); + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState(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 ( + + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+ +
+ +
+ {!isExporting && ( + <> +
+ + setFormat(value as ExportFormat)} + > +
+ + +
+
+ + +
+
+
+ + + + setQuality(value as ExportQuality) + } + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + )} + + {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ + +
+ )} + + {exportResult && !exportResult.success && ( +
+
Export failed
+

+ {exportResult.error || "Unknown error occurred"} +

+ +
+ )} +
+ +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx deleted file mode 100644 index 2cd33535..00000000 --- a/apps/web/src/components/editor/media-panel/views/base-view.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -interface BaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - tabs?: { - value: string; - label: string; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.RefObject; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - -
{children}
-
- ); -} - -export function BaseView({ - children, - defaultTab, - tabs, - className = "", - ref, -}: BaseViewProps) { - return ( -
- {!tabs || tabs.length === 0 ? ( - {children} - ) : ( - -
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - -
- - {tabs.map((tab) => ( - - {tab.content} - - ))} -
- )} -
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 393481a5..59403a8d 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -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"; diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index d33b4cce..df1ec54f 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -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, diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 5d522c84..22fc2413 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -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"; diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx new file mode 100644 index 00000000..8ee60690 --- /dev/null +++ b/apps/web/src/components/editor/onboarding.tsx @@ -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 ( +
+
+ + <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> + ); +} diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx new file mode 100644 index 00000000..0cfb8f40 --- /dev/null +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -0,0 +1,78 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject<HTMLDivElement>; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <ScrollArea className="flex-1"> + <div className={`p-5 h-full ${className}`}>{children}</div> + </ScrollArea> + ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( + <div className={`h-full flex flex-col ${className}`} ref={ref}> + {!tabs || tabs.length === 0 ? ( + <ViewContent className={className}>{children}</ViewContent> + ) : ( + <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> + {tabs.map((tab) => ( + <TabsContent + key={tab.value} + value={tab.value} + className="mt-0 flex-1 flex flex-col min-h-0" + > + <ViewContent>{tab.content}</ViewContent> + </TabsContent> + ))} + </Tabs> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx new file mode 100644 index 00000000..e6ae97b3 --- /dev/null +++ b/apps/web/src/components/editor/panel-preset-selector.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index d988e7fb..1654624c 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -538,6 +538,7 @@ export function PreviewPanel() { activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, }); // Blit offscreen to visible canvas diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index dc6b6d74..f7d5dbda 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -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")} /> diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 744c2be5..139c7005 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -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={() => - 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={() => + <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, { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", + content: e.target.value, }) } - 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 + /> + <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"> - <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 + /> + </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> - </div> + /> + </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> + ), + }))} + /> ); } -- cgit v1.3.1