diff options
Diffstat (limited to 'apps/web/src/components')
42 files changed, 998 insertions, 748 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index d32f067a..61f23372 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -122,8 +122,8 @@ export function EditableTimecode({ onKeyDown={handleKeyDown} onBlur={handleBlur} className={cn( - "border-none bg-transparent font-mono text-xs outline-none", - "focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1", + "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none", + "focus:bg-background focus:border-primary focus:rounded", "text-primary tabular-nums", hasError && "text-destructive focus:border-destructive", className, diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index d74d78d7..e70bda37 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,8 +1,7 @@ "use client"; import { Button } from "../ui/button"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -17,23 +16,21 @@ import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { ExportButton } from "./export-button"; import { ThemeToggle } from "../theme-toggle"; -import { SOCIAL_LINKS } from "@/constants/site-constants"; +import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { toast } from "sonner"; import { useEditor } from "@/hooks/use-editor"; -import { - ArrowLeft02Icon, - Edit03Icon, - Delete02Icon, - CommandIcon, -} from "@hugeicons/core-free-icons"; +import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; +import Image from "next/image"; +import { cn } from "@/utils/ui"; export function EditorHeader() { return ( - <header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5"> - <div className="flex items-center gap-2"> + <header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5"> + <div className="flex items-center gap-1"> <ProjectDropdown /> + <EditableProjectName /> </div> <nav className="flex items-center gap-2"> <ExportButton /> @@ -111,14 +108,14 @@ function ProjectDropdown() { <> <DropdownMenu> <DropdownMenuTrigger asChild> - <Button - variant="secondary" - className="flex h-auto items-center justify-center px-2.5 py-1.5" - > - <ChevronDown className="text-muted-foreground" /> - <span className="mr-2 text-[0.85rem]"> - {activeProject?.metadata.name} - </span> + <Button variant="ghost" size="icon" className="p-1 rounded-sm size-8"> + <Image + src={DEFAULT_LOGO_URL} + alt="Project thumbnail" + width={32} + height={32} + className="invert dark:invert-0 size-5" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="z-100 w-52"> @@ -130,21 +127,7 @@ function ProjectDropdown() { <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" /> Exit project </DropdownMenuItem> - <DropdownMenuItem - className="flex items-center gap-1.5" - onClick={() => setOpenDialog("rename")} - > - <HugeiconsIcon icon={Edit03Icon} className="size-4" /> - Rename project - </DropdownMenuItem> - <DropdownMenuItem - variant="destructive" - className="flex items-center gap-1.5" - onClick={() => setOpenDialog("delete")} - > - <HugeiconsIcon icon={Delete02Icon} className="size-4" /> - Delete project - </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem className="flex items-center gap-1.5" @@ -185,3 +168,79 @@ function ProjectDropdown() { </> ); } + +function EditableProjectName() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + const originalNameRef = useRef(""); + + const projectName = activeProject?.metadata.name || ""; + + const startEditing = () => { + if (isEditing) return; + originalNameRef.current = projectName; + setIsEditing(true); + + requestAnimationFrame(() => { + inputRef.current?.select(); + }); + }; + + const saveEdit = async () => { + if (!inputRef.current || !activeProject) return; + const newName = inputRef.current.value.trim(); + setIsEditing(false); + + if (!newName) { + inputRef.current.value = originalNameRef.current; + return; + } + + if (newName !== originalNameRef.current) { + try { + await editor.project.renameProject({ + id: activeProject.metadata.id, + name: newName, + }); + } catch (error) { + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + inputRef.current?.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + if (inputRef.current) { + inputRef.current.value = originalNameRef.current; + } + setIsEditing(false); + inputRef.current?.blur(); + } + }; + + return ( + <input + ref={inputRef} + type="text" + defaultValue={projectName} + readOnly={!isEditing} + onClick={startEditing} + onBlur={saveEdit} + onKeyDown={handleKeyDown} + style={{ fieldSizing: "content" }} + className={cn( + "text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground", + isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent", + )} + /> + ); +} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 942efcff..f39c18e2 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/utils/ui"; import { getExportMimeType, getExportFileExtension } from "@/lib/export"; -import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; +import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, @@ -44,9 +44,7 @@ export function ExportButton() { type="button" className={cn( "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", - hasProject - ? "cursor-pointer" - : "cursor-not-allowed opacity-50", + hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50", )} onClick={hasProject ? handleExport : undefined} disabled={!hasProject} @@ -141,20 +139,12 @@ function ExportPopover({ } }; - const handleClose = () => { - if (!isExporting) { - onOpenChange(false); - setExportResult(null); - setProgress(0); - } - }; - const handleCancel = () => { cancelRequestedRef.current = true; }; return ( - <PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3"> + <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0"> {exportResult && !exportResult.success ? ( <ExportError error={exportResult.error || "Unknown error occurred"} @@ -162,23 +152,20 @@ function ExportPopover({ /> ) : ( <> - <div className="flex items-center justify-between"> - <h3 className="font-medium"> + <div className="flex items-center justify-between p-3 border-b"> + <h3 className="font-medium text-sm"> {isExporting ? "Exporting project" : "Export project"} </h3> - <Button variant="text" size="icon" onClick={handleClose}> - <X className="text-foreground/85 !size-5" /> - </Button> </div> <div className="flex flex-col gap-4"> {!isExporting && ( <> - <div className="flex flex-col gap-3"> + <div className="flex flex-col"> <PropertyGroup title="Format" - titleClassName="text-sm" defaultExpanded={false} + hasBorderTop={false} > <RadioGroup value={format} @@ -203,11 +190,7 @@ function ExportPopover({ </RadioGroup> </PropertyGroup> - <PropertyGroup - title="Quality" - titleClassName="text-sm" - defaultExpanded={false} - > + <PropertyGroup title="Quality" defaultExpanded={false}> <RadioGroup value={quality} onValueChange={(value) => { @@ -237,11 +220,7 @@ function ExportPopover({ </RadioGroup> </PropertyGroup> - <PropertyGroup - title="Audio" - titleClassName="text-sm" - defaultExpanded={false} - > + <PropertyGroup title="Audio" defaultExpanded={false}> <div className="flex items-center space-x-2"> <Checkbox id="include-audio" @@ -257,15 +236,17 @@ function ExportPopover({ </PropertyGroup> </div> - <Button onClick={handleExport} className="w-full gap-2"> - <Download className="size-4" /> - Export - </Button> + <div className="p-3 pt-0"> + <Button onClick={handleExport} className="w-full gap-2"> + <Download className="size-4" /> + Export + </Button> + </div> </> )} {isExporting && ( - <div className="space-y-4"> + <div className="space-y-4 p-3"> <div className="flex flex-col"> <div className="flex items-center justify-between text-center"> <p className="text-muted-foreground mb-2 text-sm"> diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 10cb1f8c..517fdc92 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -110,7 +110,7 @@ export function DraggableItem({ <AspectRatio ratio={aspectRatio} className={cn( - "bg-panel-accent relative overflow-hidden", + "bg-accent relative overflow-hidden", isRounded && "rounded-sm", isDraggable && "[&::-webkit-drag-ghost]:opacity-0", )} @@ -215,7 +215,7 @@ function PlusButton({ <Button size="icon" className={cn( - "bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5", + "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", className, )} onClick={(e) => { diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 9161cb1d..e7bd3d4f 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -43,7 +43,7 @@ export function AssetsPanel() { }; return ( - <div className="bg-panel flex h-full"> + <div className="panel bg-background flex h-full rounded-sm border overflow-hidden"> <TabBar /> <Separator orientation="vertical" /> <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 2c950fdc..31842306 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -6,6 +6,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; import { cn } from "@/utils/ui"; import { TAB_KEYS, @@ -15,9 +16,9 @@ import { export function TabBar() { const { activeTab, setActiveTab } = useAssetsPanelStore(); - const scrollRef = useRef<HTMLDivElement>(null); const [showTopFade, setShowTopFade] = useState(false); const [showBottomFade, setShowBottomFade] = useState(false); + const scrollRef = useRef<HTMLDivElement>(null); const checkScrollPosition = useCallback(() => { const element = scrollRef.current; @@ -48,32 +49,24 @@ export function TabBar() { <div className="relative flex"> <div ref={scrollRef} - className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" + className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto" > {TAB_KEYS.map((tabKey) => { const tab = tabs[tabKey]; return ( <Tooltip key={tabKey} delayDuration={10}> <TooltipTrigger asChild> - <button - type="button" + <Button + variant={activeTab === tabKey ? "secondary" : "text"} aria-label={tab.label} className={cn( - "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75", - activeTab === tabKey - ? "text-primary !opacity-100" - : "text-muted-foreground", + "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", + activeTab !== tabKey && "border border-transparent text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setActiveTab(tabKey); - } - }} > - <tab.icon className=" " /> - </button> + <tab.icon /> + </Button> </TooltipTrigger> <TooltipContent side="right" @@ -108,8 +101,8 @@ function FadeOverlay({ className={cn( "pointer-events-none absolute right-0 left-0 h-6", direction === "top" && show - ? "from-panel top-0 bg-gradient-to-b to-transparent" - : "from-panel bottom-0 bg-gradient-to-t to-transparent", + ? "from-background top-0 bg-gradient-to-b to-transparent" + : "from-background bottom-0 bg-gradient-to-t to-transparent", )} /> ); diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index d93baeff..daebf2c6 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Select, @@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service"; import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { buildCaptionChunks } from "@/lib/transcription/caption"; import { Spinner } from "@/components/ui/spinner"; +import { Label } from "@/components/ui/label"; export function Captions() { const [selectedLanguage, setSelectedLanguage] = @@ -112,12 +112,13 @@ export function Captions() { ref={containerRef} className="flex h-full flex-col justify-between" > - <PropertyGroup title="Language"> + <div className="flex flex-col gap-3"> + <Label>Language</Label> <Select value={selectedLanguage} onValueChange={(value) => handleLanguageChange({ value })} > - <SelectTrigger className="bg-panel-accent h-8 w-full text-xs"> + <SelectTrigger> <SelectValue placeholder="Select a language" /> </SelectTrigger> <SelectContent> @@ -129,7 +130,7 @@ export function Captions() { ))} </SelectContent> </Select> - </PropertyGroup> + </div> <div className="flex flex-col gap-4"> {error && ( diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx index 76ae0064..59395fd8 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -202,7 +202,7 @@ export function MediaView() { className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > - <div className="bg-panel py-2 px-4 flex items-center justify-between border-b"> + <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b"> <span className="text-muted-foreground text-sm">Assets</span> <div className="flex items-center gap-0"> <TooltipProvider> @@ -320,10 +320,10 @@ export function MediaView() { onClick={openFilePicker} disabled={isProcessing} size="sm" - className="items-center justify-center gap-1.5 ml-1.5" + className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3" > <HugeiconsIcon icon={CloudUploadIcon} /> - Upload + Import </Button> </div> </div> diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index b8c54510..26afe566 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -21,7 +21,6 @@ import { colors } from "@/data/colors/solid"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; import { useEditor } from "@/hooks/use-editor"; import { useEditorStore } from "@/stores/editor-store"; -import type { TProject } from "@/types/project"; import { dimensionToAspectRatio } from "@/utils/geometry"; import { cn } from "@/utils/ui"; import { @@ -30,8 +29,7 @@ import { PropertyItemLabel, PropertyItemValue, } from "@/components/editor/panels/properties/property-item"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DropperIcon } from "@hugeicons/core-free-icons"; +import { ColorPicker } from "@/components/ui/color-picker"; export function SettingsView() { return <ProjectSettingsTabs />; @@ -56,24 +54,9 @@ function ProjectSettingsTabs() { label: "Background", content: ( <div className="flex h-full flex-col justify-between"> - <div className="flex-1 p-5"> + <div className="flex-1"> <BackgroundView /> </div> - {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg"> - <Separator /> - <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none"> - Custom background - <PlusIcon /> - </Button> - </div> */} - - {/* another ui */} - {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0"> - <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full"> - <span className="text-sm">Custom</span> - <PlusIcon className="" /> - </Button> - </div> */} </div> ), }, @@ -83,15 +66,6 @@ function ProjectSettingsTabs() { ); } -function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { - const { canvasSize } = activeProject.settings; - - return { - width: canvasSize.width, - height: canvasSize.height, - }; -} - function ProjectInfoView() { const editor = useEditor(); const activeProject = editor.project.getActive(); @@ -117,7 +91,7 @@ function ProjectInfoView() { return -1; }; - const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentCanvasSize = activeProject.settings.canvasSize; const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; const presetIndex = findPresetIndexByAspectRatio({ @@ -162,7 +136,7 @@ function ProjectInfoView() { value={selectedPresetValue} onValueChange={(value) => handleAspectRatioChange({ value })} > - <SelectTrigger className="bg-panel-accent"> + <SelectTrigger> <SelectValue placeholder="Select an aspect ratio" /> </SelectTrigger> <SelectContent> @@ -190,7 +164,7 @@ function ProjectInfoView() { value={activeProject.settings.fps.toString()} onValueChange={handleFpsChange} > - <SelectTrigger className="bg-panel-accent"> + <SelectTrigger> <SelectValue placeholder="Select a frame rate" /> </SelectTrigger> <SelectContent> @@ -219,7 +193,7 @@ const BlurPreview = memo( }) => ( <button className={cn( - "border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border", + "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", isSelected && "border-primary border-2", )} onClick={onSelect} @@ -265,7 +239,7 @@ const BackgroundPreviews = memo( <button key={`${index}-${bg}`} className={cn( - "border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border", + "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", isColorBackground && bg === currentBackgroundColor && "border-primary border-2", @@ -347,48 +321,35 @@ function BackgroundView() { [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], ); + const backgroundSections = [ + { title: "Colors", backgrounds: colors, useBackgroundColor: true }, + { title: "Pattern craft", backgrounds: patternCraftGradients }, + { title: "Syntax UI", backgrounds: syntaxUIGradients }, + ]; + return ( - <div className="flex h-full flex-col gap-4"> - <PropertyGroup title="Blur" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div> + <div className="flex h-full flex-col"> + <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> </PropertyGroup> - <PropertyGroup title="Colors" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border"> - <HugeiconsIcon icon={DropperIcon} className="size-4" /> + {backgroundSections.map((section) => ( + <PropertyGroup + key={section.title} + title={section.title} + defaultExpanded={false} + > + <div className="flex flex-wrap gap-2"> + <BackgroundPreviews + backgrounds={section.backgrounds} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + useBackgroundColor={section.useBackgroundColor} + /> </div> - <BackgroundPreviews - backgrounds={colors} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - useBackgroundColor={true} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Pattern craft" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <BackgroundPreviews - backgrounds={patternCraftGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Syntax UI" defaultExpanded={false}> - <div className="grid w-full grid-cols-4 gap-2"> - <BackgroundPreviews - backgrounds={syntaxUIGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - /> - </div> - </PropertyGroup> + </PropertyGroup> + ))} </div> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 84a5d0bf..30c70a83 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -235,7 +235,7 @@ function SoundEffectsView() { <div className="flex items-center gap-3"> <Input placeholder="Search sound effects" - className="bg-panel-accent w-full" + className="bg-accent w-full" containerClassName="w-full" value={searchQuery} onChange={({ currentTarget }) => @@ -406,7 +406,7 @@ function SavedSoundsView() { if (savedSounds.length === 0) { return ( - <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> <HugeiconsIcon icon={FavouriteIcon} className="text-muted-foreground size-10" diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 6b48e775..68ba91d2 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -149,7 +149,7 @@ function CollectionGrid({ function EmptyView({ message }: { message: string }) { return ( - <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> <HugeiconsIcon icon={HappyIcon} className="text-muted-foreground size-10" diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 6ccf5d98..6360c363 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -27,7 +27,7 @@ export function TextView() { <DraggableItem name="Default text" preview={ - <div className="bg-panel-accent flex size-full items-center justify-center rounded"> + <div className="bg-accent flex size-full items-center justify-center rounded"> <span className="text-xs select-none">Default text</span> </div> } diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx index eec063ec..dd6151d3 100644 --- a/apps/web/src/components/editor/panels/panel-base-view.tsx +++ b/apps/web/src/components/editor/panels/panel-base-view.tsx @@ -26,7 +26,7 @@ function ViewContent({ className?: string; }) { return ( - <ScrollArea className="flex-1"> + <ScrollArea className="flex-1 scrollbar-hidden"> <div className={cn("p-5", className)}>{children}</div> </ScrollArea> ); @@ -52,7 +52,7 @@ export function PanelBaseView({ onValueChange={onValueChange} className="flex h-full flex-col" > - <div className="bg-panel sticky top-0 z-10"> + <div className="bg-background sticky top-0 z-10"> <div className="px-3 pt-3 pb-0"> <TabsList> {tabs.map((tab) => ( diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index c579b15b..902547bd 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/hooks/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; +import { useContainerSize } from "@/hooks/use-container-size"; +import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { getLastFrameTime } from "@/lib/time"; +import { formatTimeCode, getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { invokeAction } from "@/lib/actions"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { cn } from "@/utils/ui"; function usePreviewSize() { const editor = useEditor(); @@ -47,32 +59,134 @@ function RenderTreeController() { } export function PreviewPanel() { + const containerRef = useRef<HTMLDivElement>(null); + const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + return ( - <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm"> - <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2"> + <div + ref={containerRef} + className={cn( + "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border", + isFullscreen && "bg-background", + )} + > + <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> <PreviewCanvas /> <RenderTreeController /> </div> + <PreviewToolbar + isFullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} + /> + </div> + ); +} + +function PreviewToolbar({ + isFullscreen, + onToggleFullscreen, +}: { + isFullscreen: boolean; + onToggleFullscreen: () => void; +}) { + const editor = useEditor(); + const isPlaying = editor.playback.getIsPlaying(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( + <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> + <div className="flex items-center mt-1"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <span className="text-muted-foreground px-2 font-mono text-xs">/</span> + <span className="text-muted-foreground font-mono text-xs"> + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </span> + </div> + + <Button + variant="text" + size="icon" + type="button" + onClick={() => invokeAction("toggle-play")} + > + <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> + </Button> + + <div className="justify-self-end"> + <Button + variant="text" + size="icon" + type="button" + onClick={onToggleFullscreen} + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + <HugeiconsIcon icon={FullScreenIcon} /> + </Button> + </div> </div> ); } function PreviewCanvas() { const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); - const { width, height } = usePreviewSize(); + const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); + const containerSize = useContainerSize({ containerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); const renderer = useMemo(() => { return new CanvasRenderer({ - width, - height, + width: nativeWidth, + height: nativeHeight, fps: activeProject.settings.fps, }); - }, [width, height, activeProject.settings.fps]); + }, [nativeWidth, nativeHeight, activeProject.settings.fps]); + + const displaySize = useMemo(() => { + if ( + !nativeWidth || + !nativeHeight || + containerSize.width === 0 || + containerSize.height === 0 + ) { + return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 }; + } + + const paddingBuffer = 4; + const availableWidth = containerSize.width - paddingBuffer; + const availableHeight = containerSize.height - paddingBuffer; + + const aspectRatio = nativeWidth / nativeHeight; + const containerAspect = availableWidth / availableHeight; + + const displayWidth = + containerAspect > aspectRatio + ? availableHeight * aspectRatio + : availableWidth; + const displayHeight = + containerAspect > aspectRatio + ? availableHeight + : availableWidth / aspectRatio; + + return { width: displayWidth, height: displayHeight }; + }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]); const renderTree = editor.renderer.getRenderTree(); @@ -109,13 +223,18 @@ function PreviewCanvas() { useRafLoop(render); return ( - <div className="relative"> + <div + ref={containerRef} + className="relative flex h-full w-full items-center justify-center" + > <canvas ref={canvasRef} - width={width} - height={height} - className="block max-h-full max-w-full border" + width={nativeWidth} + height={nativeHeight} + className="block border" style={{ + width: displaySize.width, + height: displaySize.height, background: activeProject.settings.background.type === "blur" ? "transparent" diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx new file mode 100644 index 00000000..8ccf07ee --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -0,0 +1,20 @@ +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; + +export function EmptyView() { + return ( + <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={Settings05Icon} + className="text-muted-foreground/75 size-10" + strokeWidth={1} + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium ">It's empty here</p> + <p className="text-muted-foreground text-sm text-balance"> + Click an element on the timeline to edit its properties + </p> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 3a552b93..05ad5396 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; @@ -18,9 +17,9 @@ export function PropertiesPanel() { }); return ( - <> + <div className="panel bg-background h-full rounded-sm border overflow-hidden"> {selectedElements.length > 0 ? ( - <ScrollArea className="bg-panel h-full rounded-sm"> + <ScrollArea className="h-full"> {elementsWithTracks.map(({ track, element }) => { if (element.type === "text") { return ( @@ -45,24 +44,6 @@ export function PropertiesPanel() { ) : ( <EmptyView /> )} - </> - ); -} - -function EmptyView() { - return ( - <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={Settings05Icon} - className="text-muted-foreground/75 size-10" - strokeWidth={1} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">It's empty here</p> - <p className="text-muted-foreground text-sm text-balance"> - Click an element on the timeline to edit its properties - </p> - </div> </div> ); } diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx index 6d2fe444..a682097a 100644 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowDownIcon } from "@hugeicons/core-free-icons"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; interface PropertyItemProps { direction?: "row" | "column"; @@ -57,35 +57,76 @@ interface PropertyGroupProps { title: string; children: React.ReactNode; defaultExpanded?: boolean; + collapsible?: boolean; className?: string; - titleClassName?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; } export function PropertyGroup({ title, children, defaultExpanded = true, + collapsible = true, className, - titleClassName, + hasBorderTop = true, + hasBorderBottom = true, }: PropertyGroupProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); return ( - <PropertyItem direction="column" className={cn("gap-3", className)}> - <button - type="button" - className="flex items-center gap-1.5 cursor-pointer" - onClick={() => setIsExpanded(!isExpanded)} - > - <PropertyItemLabel className={cn(titleClassName)}> - {title} - </PropertyItemLabel> - <HugeiconsIcon - icon={ArrowDownIcon} - className={cn("size-3", !isExpanded && "-rotate-90")} - /> - </button> - {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} - </PropertyItem> + <div + className={cn( + "flex flex-col", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {collapsible ? ( + <button + type="button" + className="flex items-center justify-between p-3.5 cursor-pointer" + onClick={() => setIsExpanded(!isExpanded)} + > + <PropertyGroupTitle isExpanded={isExpanded}> + {title} + </PropertyGroupTitle> + <HugeiconsIcon + icon={isExpanded ? MinusSignIcon : PlusSignIcon} + className={cn( + "size-3", + isExpanded ? "text-foreground" : "text-muted-foreground", + )} + /> + </button> + ) : ( + <div className="flex items-center justify-between p-4"> + <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle> + </div> + )} + {(collapsible ? isExpanded : true) && ( + <div className="p-3 pt-0">{children}</div> + )} + </div> + ); +} + +function PropertyGroupTitle({ + children, + isExpanded = false, +}: { + children: React.ReactNode; + isExpanded?: boolean; +}) { + return ( + <span + className={cn( + "text-xs font-medium", + isExpanded ? "text-foreground" : "text-muted-foreground", + )} + > + {children} + </span> ); } diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 35230c75..22e50e4e 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -5,29 +5,17 @@ import type { TextElement } from "@/types/timeline"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { useState, useRef } from "react"; +import { useReducer, useRef } from "react"; import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; import { - TEXT_PROPERTIES_TABS, - isTextPropertiesTab, - useTextPropertiesStore, -} from "@/stores/text-properties-store"; -import { + PropertyGroup, PropertyItem, PropertyItemLabel, PropertyItemValue, } from "./property-item"; import { ColorPicker } from "@/components/ui/color-picker"; -import { cn } from "@/utils/ui"; -import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { LayoutGridIcon } from "@hugeicons/core-free-icons"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; @@ -40,48 +28,86 @@ export function TextProperties({ trackId: string; }) { const editor = useEditor(); - const { activeTab, setActiveTab } = useTextPropertiesStore(); const containerRef = useRef<HTMLDivElement>(null); - const [fontSizeInput, setFontSizeInput] = useState( - element.fontSize.toString(), - ); - const [opacityInput, setOpacityInput] = useState( - Math.round(element.opacity * 100).toString(), - ); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + const isEditingFontSize = useRef(false); + const isEditingOpacity = useRef(false); + const isEditingContent = useRef(false); + const fontSizeDraft = useRef(""); + const opacityDraft = useRef(""); + const contentDraft = useRef(""); + + const fontSizeDisplay = isEditingFontSize.current + ? fontSizeDraft.current + : element.fontSize.toString(); + const opacityDisplay = isEditingOpacity.current + ? opacityDraft.current + : Math.round(element.opacity * 100).toString(); + const contentDisplay = isEditingContent.current + ? contentDraft.current + : element.content; const lastSelectedColor = useRef(DEFAULT_COLOR); const initialFontSizeRef = useRef<number | null>(null); const initialOpacityRef = useRef<number | null>(null); + const initialContentRef = useRef<string | null>(null); + const initialColorRef = useRef<string | null>(null); + const initialBgColorRef = useRef<string | null>(null); const handleFontSizeChange = ({ value }: { value: string }) => { - setFontSizeInput(value); + fontSizeDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; + } const parsed = parseInt(value, 10); const fontSize = Number.isNaN(parsed) ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); editor.timeline.updateElements({ updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: false, }); } }; const handleFontSizeBlur = () => { - const parsed = parseInt(fontSizeInput, 10); - const fontSize = Number.isNaN(parsed) - ? element.fontSize - : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - setFontSizeInput(fontSize.toString()); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - }); + if (initialFontSizeRef.current !== null) { + const parsed = parseInt(fontSizeDraft.current, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: initialFontSizeRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], + pushHistory: true, + }); + initialFontSizeRef.current = null; + } + isEditingFontSize.current = false; + fontSizeDraft.current = ""; + forceRender(); }; const handleOpacityChange = ({ value }: { value: string }) => { - setOpacityInput(value); + opacityDraft.current = value; + forceRender(); if (value.trim() !== "") { + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } const parsed = parseInt(value, 10); const opacityPercent = Number.isNaN(parsed) ? Math.round(element.opacity * 100) @@ -94,234 +120,333 @@ export function TextProperties({ updates: { opacity: opacityPercent / 100 }, }, ], + pushHistory: false, }); } }; const handleOpacityBlur = () => { - const parsed = parseInt(opacityInput, 10); - const opacityPercent = Number.isNaN(parsed) - ? Math.round(element.opacity * 100) - : clamp({ value: parsed, min: 0, max: 100 }); - setOpacityInput(opacityPercent.toString()); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - }); + if (initialOpacityRef.current !== null) { + const parsed = parseInt(opacityDraft.current, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: initialOpacityRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; + } + isEditingOpacity.current = false; + opacityDraft.current = ""; + forceRender(); }; const handleColorChange = ({ color }: { color: string }) => { if (color !== "transparent") { lastSelectedColor.current = color; } - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - }); + if (initialBgColorRef.current === null) { + initialBgColorRef.current = element.backgroundColor; + } + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + pushHistory: false, + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], + }); + } }; - const handleTransparentToggle = ({ - isTransparent, - }: { - isTransparent: boolean; - }) => { - const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: newColor }, - }, - ], - }); + const handleColorChangeEnd = ({ color }: { color: string }) => { + if (initialBgColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: initialBgColorRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: `#${color}` }, + }, + ], + pushHistory: true, + }); + initialBgColorRef.current = null; + } }; return ( - <PanelBaseView - defaultTab="transform" - value={activeTab} - onValueChange={(v) => { - if (isTextPropertiesTab(v)) setActiveTab(v); - }} - ref={containerRef} - tabs={TEXT_PROPERTIES_TABS.map((t) => ({ - value: t.value, - label: t.label, - content: - t.value === "transform" ? ( - <div className="space-y-6"></div> - ) : ( - <div className="space-y-6"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="bg-panel-accent min-h-20" - onChange={(e) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { content: e.target.value }, - }, - ], - }) - } - /> - <PropertyItem direction="column"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => + <div className="flex h-full flex-col" ref={containerRef}> + <PanelBaseView className="p-0"> + <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}> + <Textarea + placeholder="Name" + value={contentDisplay} + className="bg-accent min-h-20" + onFocus={() => { + isEditingContent.current = true; + contentDraft.current = element.content; + initialContentRef.current = element.content; + forceRender(); + }} + onChange={(event) => { + contentDraft.current = event.target.value; + forceRender(); + if (initialContentRef.current === null) { + initialContentRef.current = element.content; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: event.target.value }, + }, + ], + pushHistory: false, + }); + }} + onBlur={() => { + if (initialContentRef.current !== null) { + const finalContent = contentDraft.current; + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: initialContentRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: finalContent }, + }, + ], + pushHistory: true, + }); + initialContentRef.current = null; + } + isEditingContent.current = false; + contentDraft.current = ""; + forceRender(); + }} + /> + </PropertyGroup> + <PropertyGroup title="Typography" collapsible={false}> + <div className="space-y-6"> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { fontFamily: value }, + updates: { + fontWeight: + element.fontWeight === "bold" + ? "normal" + : "bold", + }, }, ], }) } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" - ? "normal" - : "bold", - }, + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", }, - ], - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, + }, + ], + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", }, - ], - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, + }, + ], + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", }, - ], - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" + }, + ], + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => { + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; } - size="sm" - onClick={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: value }, + }, + ], + pushHistory: false, + }); + }} + onValueCommit={([value]) => { + if (initialFontSizeRef.current !== null) { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", + fontSize: initialFontSizeRef.current, }, }, ], - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - step={1} - onValueChange={([value]) => { - if (initialFontSizeRef.current === null) { - initialFontSizeRef.current = element.fontSize; - } + pushHistory: false, + }); editor.timeline.updateElements({ updates: [ { @@ -330,61 +455,48 @@ export function TextProperties({ updates: { fontSize: value }, }, ], - pushHistory: false, + pushHistory: true, }); - setFontSizeInput(value.toString()); - }} - onValueCommit={([value]) => { - if (initialFontSizeRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: initialFontSizeRef.current, - }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: value }, - }, - ], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeInput} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onChange={(e) => - handleFontSizeChange({ value: e.target.value }) + initialFontSizeRef.current = null; } - onBlur={handleFontSizeBlur} - className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <ColorPicker - value={uppercase({ - string: (element.color || "FFFFFF").replace("#", ""), - })} - onChange={(color) => { + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeDisplay} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={() => { + isEditingFontSize.current = true; + fontSizeDraft.current = element.fontSize.toString(); + forceRender(); + }} + onChange={(e) => + handleFontSizeChange({ value: e.target.value }) + } + onBlur={handleFontSizeBlur} + className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + </PropertyGroup> + <PropertyGroup title="Appearance" collapsible={false}> + <div className="space-y-6"> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => { + if (initialColorRef.current === null) { + initialColorRef.current = element.color || "#FFFFFF"; + } + if (initialColorRef.current !== null) { editor.timeline.updateElements({ updates: [ { @@ -393,131 +505,146 @@ export function TextProperties({ updates: { color: `#${color}` }, }, ], + pushHistory: false, + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }); + } + }} + onChangeEnd={(color) => { + if (initialColorRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: initialColorRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + pushHistory: true, + }); + initialColorRef.current = null; + } + }} + containerRef={containerRef} + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: false, }); }} - containerRef={containerRef} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - if (initialOpacityRef.current === null) { - initialOpacityRef.current = element.opacity; - } + onValueCommit={([value]) => { + if (initialOpacityRef.current !== null) { editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { opacity: value / 100 }, + updates: { opacity: initialOpacityRef.current }, }, ], pushHistory: false, }); - setOpacityInput(value.toString()); - }} - onValueCommit={([value]) => { - if (initialOpacityRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: initialOpacityRef.current }, - }, - ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, - }, - ], - pushHistory: true, - }); - initialOpacityRef.current = null; - } - }} - className="w-full" - /> - <Input - type="number" - value={opacityInput} - min={0} - max={100} - onChange={(e) => - handleOpacityChange({ value: e.target.value }) - } - onBlur={handleOpacityBlur} - className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Background</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <ColorPicker - value={capitalizeFirstLetter({ - string: - element.backgroundColor === "transparent" - ? lastSelectedColor.current.replace("#", "") - : element.backgroundColor.replace("#", ""), - })} - onChange={(color) => - handleColorChange({ color: `#${color}` }) - } - containerRef={containerRef} - className={ - element.backgroundColor === "transparent" - ? "pointer-events-none opacity-50" - : "" + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; } - /> - - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="icon" - onClick={() => - handleTransparentToggle({ - isTransparent: - element.backgroundColor !== "transparent", - }) - } - className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" - > - <HugeiconsIcon - icon={LayoutGridIcon} - className={cn( - "text-foreground", - element.backgroundColor === "transparent" && - "text-primary", - )} - /> - </Button> - </TooltipTrigger> - <TooltipContent>Transparent background</TooltipContent> - </Tooltip> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - ), - }))} - /> + }} + className="w-full" + /> + <Input + type="number" + value={opacityDisplay} + min={0} + max={100} + onFocus={() => { + isEditingOpacity.current = true; + opacityDraft.current = Math.round( + element.opacity * 100, + ).toString(); + forceRender(); + }} + onChange={(e) => + handleOpacityChange({ value: e.target.value }) + } + onBlur={handleOpacityBlur} + className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => + handleColorChange({ color: `#${color}` }) + } + onChangeEnd={(color) => handleColorChangeEnd({ color })} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + </PropertyItemValue> + </PropertyItem> + </div> + </PropertyGroup> + </PanelBaseView> + </div> ); } diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx index bbc5cb83..bbc5cb83 100644 --- a/apps/web/src/components/editor/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index cfcb1e23..cfcb1e23 100644 --- a/apps/web/src/components/editor/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index f31c6e28..f31c6e28 100644 --- a/apps/web/src/components/editor/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 2ff17345..94edc2d0 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -15,12 +15,12 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../../ui/context-menu"; +} from "../../../ui/context-menu"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; import { useState, useRef, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "../selection-box"; +import { SelectionBox } from "../../selection-box"; import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; @@ -201,7 +201,7 @@ export function Timeline() { return ( <section className={ - "bg-panel relative flex h-full flex-col overflow-hidden rounded-sm" + "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border" } {...dragProps} aria-label="Timeline" @@ -226,17 +226,17 @@ export function Timeline() { isVisible={showSnapIndicator} /> <div className="flex flex-1 overflow-hidden"> - <div className="bg-panel flex w-28 shrink-0 flex-col border-r"> - <div className="bg-panel flex h-4 items-center justify-between px-3"> + <div className="bg-background flex w-28 shrink-0 flex-col border-r"> + <div className="bg-background flex h-4 items-center justify-between px-3"> <span className="opacity-0">.</span> </div> - <div className="bg-panel flex h-4 items-center justify-between px-3"> + <div className="bg-background flex h-4 items-center justify-between px-3"> <span className="opacity-0">.</span> </div> {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="bg-panel flex-1 overflow-y-auto" + className="bg-background flex-1 overflow-y-auto" style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} > <ScrollArea className="size-full" ref={trackLabelsScrollRef}> @@ -351,7 +351,7 @@ export function Timeline() { > <div ref={timelineHeaderRef} - className="bg-panel sticky top-0 z-30 flex flex-col" + className="bg-background sticky top-0 z-30 flex flex-col" > <TimelineRuler zoomLevel={zoomLevel} diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 914edccb..914edccb 100644 --- a/apps/web/src/components/editor/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index fd292ad8..59ce88da 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -19,7 +19,7 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../../ui/context-menu"; +} from "../../../ui/context-menu"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -166,7 +166,10 @@ export function TimelineElement({ </div> </ContextMenuTrigger> <ContextMenuContent className="z-200 w-64"> - <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> + <ActionMenuItem + action="split" + icon={<HugeiconsIcon icon={ScissorIcon} />} + > Split </ActionMenuItem> <CopyMenuItem /> @@ -185,7 +188,10 @@ export function TimelineElement({ /> )} {selectedElements.length === 1 && ( - <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + <ActionMenuItem + action="duplicate-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > Duplicate </ActionMenuItem> )} @@ -446,7 +452,10 @@ function ElementContent({ function CopyMenuItem() { return ( - <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + <ActionMenuItem + action="copy-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > Copy </ActionMenuItem> ); @@ -502,7 +511,10 @@ function VisibilityMenuItem({ }; return ( - <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> + <ActionMenuItem + action="toggle-elements-visibility-selected" + icon={getIcon()} + > {isHidden ? "Show" : "Hide"} </ActionMenuItem> ); diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 8af35dc9..8af35dc9 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index dfa0534b..b0261aa2 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import { type JSX, useLayoutEffect, useRef } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; @@ -44,7 +44,21 @@ export function TimelineRuler({ scrollRef: tracksScrollRef, }); - const bufferPx = 200; + /** + * widens the virtualization buffer during zoom transitions. + * useScrollPosition lags one frame behind the scroll adjustment + * that useLayoutEffect applies after a zoom change. + */ + const prevZoomRef = useRef(zoomLevel); + const isZoomTransition = zoomLevel !== prevZoomRef.current; + const bufferPx = isZoomTransition + ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) + : 200; + + useLayoutEffect(() => { + prevZoomRef.current = zoomLevel; + }, [zoomLevel]); + const visibleStartTime = Math.max( 0, (scrollLeft - bufferPx) / pixelsPerSecond, @@ -62,7 +76,11 @@ export function TimelineRuler({ ); const timelineTicks: Array<JSX.Element> = []; - for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) { + for ( + let tickIndex = startTickIndex; + tickIndex <= endTickIndex; + tickIndex += 1 + ) { const time = tickIndex * tickIntervalSeconds; if (time > effectiveDuration) break; diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 9402d25e..9402d25e 100644 --- a/apps/web/src/components/editor/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 6ac6fba5..0b02c63f 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -6,7 +6,7 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; -import { SkipBack, SplitSquareHorizontal } from "lucide-react"; +import { SplitSquareHorizontal } from "lucide-react"; import { SplitButton, SplitButtonLeft, @@ -14,11 +14,9 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { ScenesView } from "../scenes-view"; +import { ScenesView } from "../../scenes-view"; import { type TAction, invokeAction } from "@/lib/actions"; import { cn } from "@/utils/ui"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -32,8 +30,6 @@ import { Link04Icon, SearchAddIcon, SearchMinusIcon, - PauseIcon, - PlayIcon, Copy01Icon, AlignLeftIcon, AlignRightIcon, @@ -82,7 +78,6 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const isPlaying = editor.playback.getIsPlaying(); const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ @@ -100,32 +95,6 @@ function ToolbarLeftSection() { <div className="flex items-center gap-1"> <TooltipProvider delayDuration={500}> <ToolbarButton - icon={ - isPlaying ? ( - <HugeiconsIcon icon={PauseIcon} /> - ) : ( - <HugeiconsIcon icon={PlayIcon} /> - ) - } - tooltip={isPlaying ? "Pause" : "Play"} - onClick={({ event }) => - handleAction({ action: "toggle-play", event }) - } - /> - - <ToolbarButton - icon={<SkipBack />} - tooltip="Go to start" - onClick={({ event }) => handleAction({ action: "goto-start", event })} - /> - - <div className="bg-border mx-1 h-6 w-px" /> - - <TimeDisplay /> - - <div className="bg-border mx-1 h-6 w-px" /> - - <ToolbarButton icon={<HugeiconsIcon icon={ScissorIcon} />} tooltip="Split element" onClick={({ event }) => handleAction({ action: "split", event })} @@ -179,12 +148,8 @@ function ToolbarLeftSection() { <Tooltip> <ToolbarButton - icon={ - <HugeiconsIcon - icon={Bookmark02Icon} - className={currentBookmarked ? "fill-primary text-primary" : ""} - /> - } + icon={<HugeiconsIcon icon={Bookmark02Icon} />} + isActive={currentBookmarked} tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) @@ -196,34 +161,6 @@ function ToolbarLeftSection() { ); } -function TimeDisplay() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const totalDuration = editor.timeline.getTotalDuration(); - const fps = editor.project.getActive().settings.fps; - - return ( - <div className="flex flex-row items-center justify-center px-2"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <div className="text-muted-foreground px-2 font-mono text-xs">/</div> - <div className="text-muted-foreground text-center font-mono text-xs"> - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </div> - </div> - ); -} - function SceneSelector() { const editor = useEditor(); const currentScene = editor.scenes.getActiveScene(); @@ -265,26 +202,15 @@ function ToolbarRightSection({ <div className="flex items-center gap-1"> <TooltipProvider delayDuration={500}> <ToolbarButton - icon={ - <HugeiconsIcon - icon={MagnetIcon} - className={cn(snappingEnabled ? "text-primary" : "")} - /> - } + icon={<HugeiconsIcon icon={MagnetIcon} />} + isActive={snappingEnabled} tooltip="Auto snapping" onClick={() => toggleSnapping()} /> <ToolbarButton - icon={ - <HugeiconsIcon - icon={Link04Icon} - className={cn( - rippleEditingEnabled ? "text-primary" : "", - "scale-110", - )} - /> - } + icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />} + isActive={rippleEditingEnabled} tooltip="Ripple editing" onClick={() => toggleRippleEditing()} /> @@ -329,21 +255,26 @@ function ToolbarButton({ tooltip, onClick, disabled, + isActive, }: { icon: React.ReactNode; tooltip: string; onClick: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; + isActive?: boolean; }) { return ( <Tooltip delayDuration={200}> <TooltipTrigger asChild> <Button - variant="text" + variant={isActive ? "secondary" : "text"} size="icon" type="button" onClick={(event) => onClick({ event })} - className={disabled ? "cursor-not-allowed opacity-50" : ""} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} > {icon} </Button> diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 47a9f282..b7b06d5b 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import type { ElementDragState } from "@/types/timeline"; import { useEditor } from "@/hooks/use-editor"; -import { cn } from "@/utils/ui"; interface TimelineTrackContentProps { track: TimelineTrack; @@ -63,13 +62,9 @@ export function TimelineTrackContent({ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, }); - const hasSelectedElements = track.elements.some((element) => - isElementSelected({ trackId: track.id, elementId: element.id }), - ); - return ( <button - className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} + className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; clearElementSelection(); diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index 394d75b2..b6e8d274 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -22,8 +22,8 @@ export function ThemeToggle({ return (
<Button
size="icon"
- variant="text"
- className={cn("h-7", className)}
+ variant="ghost"
+ className={cn("size-8", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 6c063eff..2fc2c2e6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -10,7 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: - "bg-primary text-primary-foreground hover:bg-primary-hover", + "bg-primary text-primary-foreground hover:bg-primary/90", background: "bg-background text-foreground hover:bg-background/90", foreground: @@ -22,13 +22,14 @@ const buttonVariants = cva( outline: "border border-border bg-transparent hover:bg-accent/50", secondary: - "bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input", + "bg-secondary text-secondary-foreground border border-secondary-border", text: "bg-transparent rounded-none opacity-100 hover:opacity-75", + ghost: "bg-transparent hover:bg-accent", link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", }, size: { default: "h-9.5 px-4 py-2", - sm: "h-8 px-3 text-xs", + sm: "h-8 p-1 px-2 text-xs rounded-sm", lg: "h-10 p-5 px-6", icon: "size-7", text: "p-0", diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index a57a86e0..0ea1a14d 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -6,6 +6,7 @@ import { Input } from "./input"; interface ColorPickerProps { value?: string; onChange?: (value: string) => void; + onChangeEnd?: (value: string) => void; className?: string; containerRef?: React.RefObject<HTMLDivElement | null>; } @@ -86,7 +87,7 @@ const hsvToHex = (h: number, s: number, v: number) => { }; const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( - ({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => { + ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => { const [isOpen, setIsOpen] = useState(false); const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>( null, @@ -102,6 +103,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const saturationRef = useRef<HTMLButtonElement>(null); const hueRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); + const latestDragColorRef = useRef<string | null>(null); const [h, s, v] = hexToHsv(value); const displayHue = s > 0 ? h : internalHue; @@ -144,6 +146,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newS = x; const newV = 1 - y; const newHex = hsvToHex(displayHue, newS, newV); + latestDragColorRef.current = newHex; onChange?.(newHex); } @@ -157,12 +160,17 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( setInternalHue(newH); if (s > 0) { const newHex = hsvToHex(newH, s, v); + latestDragColorRef.current = newHex; onChange?.(newHex); } } }; const handleMouseUp = () => { + if (latestDragColorRef.current !== null && onChangeEnd) { + onChangeEnd(latestDragColorRef.current); + latestDragColorRef.current = null; + } setIsDragging(null); }; @@ -187,6 +195,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( const newS = x; const newV = 1 - y; const newHex = hsvToHex(displayHue, newS, newV); + latestDragColorRef.current = newHex; onChange?.(newHex); }; @@ -201,6 +210,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( setInternalHue(newH); if (s > 0) { const newHex = hsvToHex(newH, s, v); + latestDragColorRef.current = newHex; onChange?.(newHex); } }; @@ -235,14 +245,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( <div ref={ref} className={cn( - "bg-panel-accent flex h-9 items-center gap-2 rounded-full px-[0.45rem]", + "bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]", className, )} {...props} > <button ref={triggerRef} - className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20" + className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20" style={{ backgroundColor: `#${value}` }} type="button" onClick={() => { @@ -259,7 +269,8 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( /> <div className="flex flex-1 items-center"> <Input - className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0" + className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase" + size="sm" containerClassName="w-full" value={inputValue} onChange={handleInputChange} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 0f77a4ce..f5d96125 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -114,7 +114,7 @@ const CommandItem = React.forwardRef< <CommandPrimitive.Item ref={ref} className={cn( - "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className, )} {...props} diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index a7736e6e..d8a9f594 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 1806b48a..4b8e364a 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -6,6 +6,7 @@ import { SelectValue, } from "@/components/ui/select"; import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants"; +import { cn } from "@/utils/ui"; interface FontPickerProps { defaultValue?: FontFamily; @@ -21,16 +22,15 @@ export function FontPicker({ return ( <Select defaultValue={defaultValue} onValueChange={onValueChange}> <SelectTrigger - className={`bg-panel-accent h-9 w-full text-base ${className || ""}`} + className={cn("w-full", className)} > - <SelectValue placeholder="Select a font" className="text-sm" /> + <SelectValue placeholder="Select a font" /> </SelectTrigger> <SelectContent> {FONT_OPTIONS.map((font) => ( <SelectItem key={font.value} value={font.value} - className="text-sn" style={{ fontFamily: font.value }} > {font.label} diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index 392ab3c3..f8265779 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -55,7 +55,7 @@ export function InputWithBack({ >
<Button
variant="outline"
- className="bg-panel-accent !size-9 rounded-full"
+ className="bg-accent !size-9 rounded-full"
>
<ArrowLeft />
</Button>
@@ -77,7 +77,7 @@ export function InputWithBack({ <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
placeholder={placeholder}
- className="bg-panel-accent w-full pl-9"
+ className="bg-accent w-full pl-9"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index 0ea7be03..a9a5166f 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; const labelVariants = cva( - "text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + "text-xs text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", ); const Label = React.forwardRef< diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 34738303..36ae5a69 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; - import { cn } from "@/utils/ui"; const Popover = PopoverPrimitive.Root; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 79462d4a..fd9c7ac5 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -35,7 +35,7 @@ const SelectTrigger = React.forwardRef< <SelectPrimitive.Trigger ref={ref} className={cn( - "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none", className, )} @@ -159,7 +159,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} - className={cn("bg-border/60 mx-1 my-2 h-px", className)} + className={cn("bg-border mx-1 my-2 h-px", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 994159a1..57ddbcf0 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -17,7 +17,7 @@ const Slider = React.forwardRef< )} {...props} > - <SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full"> + <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full"> <SliderPrimitive.Range className="bg-primary absolute h-full" /> </SliderPrimitive.Track> <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" /> diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index 26e54cab..aa8b331a 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( <div
ref={ref}
className={cn(
- "border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
+ "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
className,
)}
{...props}
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef< ref={ref}
variant="text"
className={cn(
- "bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
+ "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
onClick
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text",
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 3668477c..d2844b63 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef< <TabsPrimitive.Trigger ref={ref} className={cn( - "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-panel-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", + "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50", className, )} {...props} |
