diff options
Diffstat (limited to 'apps/web/src/components/editor/panels')
35 files changed, 2954 insertions, 1982 deletions
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 517fdc92..be4176ce 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -163,7 +163,9 @@ export function DraggableItem({ <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> {preview} </div> - <span className="w-full flex-1 truncate text-sm text-left">{name}</span> + <span className="w-full flex-1 truncate text-sm text-left"> + {name} + </span> </button> </div> )} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index e7bd3d4f..3600d00b 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; import { TabBar } from "./tabbar"; import { Captions } from "./views/captions"; -import { MediaView } from "./views/media"; +import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 31842306..61777a7c 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -61,7 +61,8 @@ export function TabBar() { aria-label={tab.label} className={cn( "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", - activeTab !== tabKey && "border border-transparent text-muted-foreground", + activeTab !== tabKey && + "border border-transparent text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} > diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 59395fd8..808961f1 100644 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; @@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; import { useRevealItem } from "@/hooks/use-reveal-item"; import { processMediaAssets } from "@/lib/media/processing"; -import { - buildImageElement, - buildUploadAudioElement, - buildVideoElement, -} from "@/lib/timeline/element-utils"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; -import type { CreateTimelineElement } from "@/types/timeline"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -132,7 +128,15 @@ export function MediaView() { asset: MediaAsset; startTime: number; }): boolean => { - const element = createElementFromMedia({ asset, startTime }); + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); editor.timeline.insertElement({ element, placement: { mode: "auto" }, @@ -194,171 +198,166 @@ export function MediaView() { const renderCompactPreview = (item: MediaAsset) => previewComponents.get(`compact-${item.id}`); - return ( - <> - <input {...fileInputProps} /> - - <div - className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - <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> - <Tooltip> - <TooltipTrigger asChild> + const mediaActions = ( + <div> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={() => + setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid") + } + disabled={isProcessing} + className="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> <Button size="icon" - variant="text" - onClick={() => - setMediaViewMode( - mediaViewMode === "grid" ? "list" : "grid", - ) - } + variant="ghost" disabled={isProcessing} className="items-center justify-center" > - {mediaViewMode === "grid" ? ( - <HugeiconsIcon icon={LeftToRightListDashIcon} /> - ) : ( - <HugeiconsIcon icon={GridViewIcon} /> - )} + <HugeiconsIcon icon={SortingOneNineIcon} /> </Button> - </TooltipTrigger> - <TooltipContent> - <p> - {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} - </p> - </TooltipContent> - <Tooltip> - <DropdownMenu> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="text" - disabled={isProcessing} - className="items-center justify-center" - > - <HugeiconsIcon icon={SortingOneNineIcon} /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <DropdownMenuContent align="end"> - <SortMenuItem - label="Name" - sortKey="name" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Type" - sortKey="type" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="Duration" - sortKey="duration" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - <SortMenuItem - label="File size" - sortKey="size" - currentSortBy={sortBy} - currentSortOrder={sortOrder} - onSort={({ key }) => { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - </DropdownMenuContent> - </DropdownMenu> - <TooltipContent> - <p> - Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) - </p> - </TooltipContent> - </Tooltip> - </Tooltip> - </TooltipProvider> - <Button - variant="outline" - onClick={openFilePicker} - disabled={isProcessing} - size="sm" - className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3" - > - <HugeiconsIcon icon={CloudUploadIcon} /> - Import - </Button> - </div> - </div> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={openFilePicker} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5 ml-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Import + </Button> + </div> + ); - <div className="scrollbar-thin size-full overflow-y-auto pt-1"> - <div className="w-full flex-1 p-3 pt-0"> - {isDragOver || filteredMediaItems.length === 0 ? ( - <MediaDragOverlay - isVisible={true} - isProcessing={isProcessing} - progress={progress} - onClick={openFilePicker} - /> - ) : mediaViewMode === "grid" ? ( - <GridView - items={filteredMediaItems} - renderPreview={renderPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> - ) : ( - <ListView - items={filteredMediaItems} - renderPreview={renderCompactPreview} - onRemove={handleRemove} - onAddToTimeline={addElementAtTime} - highlightedId={highlightedId} - registerElement={registerElement} - /> - )} - </div> - </div> - </div> + return ( + <> + <input {...fileInputProps} /> + + <PanelView + title="Assets" + actions={mediaActions} + className={isDragOver ? "bg-accent/30" : ""} + {...dragProps} + > + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={openFilePicker} + /> + ) : mediaViewMode === "grid" ? ( + <GridView + items={filteredMediaItems} + renderPreview={renderPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + ) : ( + <ListView + items={filteredMediaItems} + renderPreview={renderCompactPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + )} + </PanelView> </> ); } @@ -636,40 +635,3 @@ function SortMenuItem({ </DropdownMenuItem> ); } - -function createElementFromMedia({ - asset, - startTime, -}: { - asset: MediaAsset; - startTime: number; -}): CreateTimelineElement { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - - switch (asset.type) { - case "video": - return buildVideoElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "image": - return buildImageElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "audio": - return buildUploadAudioElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - default: - throw new Error(`Unsupported media type: ${asset.type}`); - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx new file mode 100644 index 00000000..c613bc2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/utils/ui"; + +interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { + title?: string; + actions?: React.ReactNode; + children: React.ReactNode; + contentClassName?: string; + hideHeader?: boolean; + ref?: React.Ref<HTMLDivElement>; + onScroll?: React.UIEventHandler<HTMLDivElement>; + scrollRef?: React.Ref<HTMLDivElement>; +} + +export function PanelView({ + title, + actions, + children, + className, + contentClassName, + hideHeader = false, + ref, + onScroll, + scrollRef, + ...rest +}: PanelViewProps) { + return ( + <div + className={cn("relative flex h-full flex-col", className)} + ref={ref} + {...rest} + > + {!hideHeader && ( + <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b"> + <span className="text-muted-foreground text-sm">{title}</span> + {actions} + </div> + )} + <div + className={cn( + "scrollbar-thin size-full overflow-y-auto", + hideHeader ? "pt-4" : "pt-2", + )} + ref={scrollRef} + onScroll={onScroll} + > + <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}> + {children} + </div> + </div> + </div> + ); +} 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 daebf2c6..7a3b16bf 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,5 @@ import { Button } from "@/components/ui/button"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -108,10 +108,7 @@ export function Captions() { }; return ( - <BaseView - ref={containerRef} - className="flex h-full flex-col justify-between" - > + <PanelView title="Captions" ref={containerRef}> <div className="flex flex-col gap-3"> <Label>Language</Label> <Select @@ -148,6 +145,6 @@ export function Captions() { {isProcessing ? processingStep : "Generate transcript"} </Button> </div> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx new file mode 100644 index 00000000..457c08a9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx @@ -0,0 +1,332 @@ +// @ts-nocheck + +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +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 { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/section"; + +export function SettingsView() { + return ( + <PanelView title="Project settings" contentClassName="px-0"> + <div className="flex flex-col gap-8"> + <ProjectInfoView /> + <BackgroundView /> + </div> + </PanelView> + ); +} + +function ProjectInfoView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { canvasPresets } = useEditorStore(); + + const findPresetIndexByAspectRatio = ({ + presets, + targetAspectRatio, + }: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; + }) => { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; + }; + + const currentCanvasSize = activeProject.settings.canvasSize; + const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); + const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; + const presetIndex = findPresetIndexByAspectRatio({ + presets: canvasPresets, + targetAspectRatio: currentAspectRatio, + }); + const originalPresetValue = "original"; + const selectedPresetValue = + presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + + const handleAspectRatioChange = ({ value }: { value: string }) => { + if (value === originalPresetValue) { + const canvasSize = originalCanvasSize ?? currentCanvasSize; + editor.project.updateSettings({ + settings: { canvasSize }, + }); + return; + } + const index = parseInt(value, 10); + const preset = canvasPresets[index]; + if (preset) { + editor.project.updateSettings({ settings: { canvasSize: preset } }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }; + + return ( + <div className="flex flex-col gap-4"> + <PropertyItem direction="column"> + <PropertyItemLabel>Name</PropertyItemLabel> + <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Aspect ratio</PropertyItemLabel> + <PropertyItemValue> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} + > + <SelectTrigger> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Frame rate</PropertyItemLabel> + <PropertyItemValue> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( + <button + className={cn( + "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} + type="button" + aria-label={`Select ${blur.label} blur`} + > + <Image + src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + loading="eager" + /> + <div className="absolute right-1 bottom-1 left-1 text-center"> + <span className="rounded bg-black/50 px-1 text-xs text-white"> + {blur.label} + </span> + </div> + </button> + ), +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: ({ bg }: { bg: string }) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg, index) => ( + <button + key={`${index}-${bg}`} + className={cn( + "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", + isColorBackground && + bg === currentBackgroundColor && + "border-primary border-2", + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => handleColorSelect({ bg })} + type="button" + aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); + + const handleBlurSelect = useCallback( + async ({ blurIntensity }: { blurIntensity: number }) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async ({ color }: { color: string }) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const currentBlurIntensity = + activeProject.settings.background.type === "blur" + ? activeProject.settings.background.blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = + activeProject.settings.background.type === "color" + ? activeProject.settings.background.color + : DEFAULT_COLOR; + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} + /> + )), + [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 flex-col"> + <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> + </PropertyGroup> + + {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> + </PropertyGroup> + ))} + </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 26afe566..7e10ac9d 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -1,8 +1,6 @@ "use client"; -import Image from "next/image"; -import { memo, useCallback, useMemo } from "react"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -10,63 +8,54 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, - FPS_PRESETS, -} from "@/constants/project-constants"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { FPS_PRESETS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; import { useEditorStore } from "@/stores/editor-store"; import { dimensionToAspectRatio } from "@/utils/geometry"; -import { cn } from "@/utils/ui"; import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "@/components/editor/panels/properties/property-item"; -import { ColorPicker } from "@/components/ui/color-picker"; + Section, + SectionContent, + SectionHeader, +} from "@/components/editor/panels/properties/section"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useState } from "react"; export function SettingsView() { - return <ProjectSettingsTabs />; -} + const [open, setOpen] = useState(false); -function ProjectSettingsTabs() { return ( - <BaseView - defaultTab="project-info" - tabs={[ - { - value: "project-info", - label: "Project info", - content: ( - <div className="p-5"> - <ProjectInfoView /> - </div> - ), - }, - { - value: "background", - label: "Background", - content: ( - <div className="flex h-full flex-col justify-between"> - <div className="flex-1"> - <BackgroundView /> + <PanelView contentClassName="px-0" hideHeader> + <div className="flex flex-col"> + <Section hasBorderTop={false}> + <SectionContent> + <ProjectInfoContent /> + </SectionContent> + </Section> + <Popover open={open} onOpenChange={setOpen}> + <Section className="cursor-pointer"> + <PopoverTrigger asChild> + <div> + <SectionHeader title="Background"> + <div className="size-4 rounded-sm bg-red-500" /> + </SectionHeader> </div> - </div> - ), - }, - ]} - className="flex h-full flex-col justify-between p-0" - /> + </PopoverTrigger> + </Section> + <PopoverContent> + <div className="size-4 rounded-sm bg-red-500" /> + </PopoverContent> + </Popover> + </div> + </PanelView> ); } -function ProjectInfoView() { +function ProjectInfoContent() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); @@ -124,232 +113,55 @@ function ProjectInfoView() { return ( <div className="flex flex-col gap-4"> - <PropertyItem direction="column"> - <PropertyItemLabel>Name</PropertyItemLabel> - <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Aspect ratio</PropertyItemLabel> - <PropertyItemValue> - <Select - value={selectedPresetValue} - onValueChange={(value) => handleAspectRatioChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - <SelectItem value={originalPresetValue}>Original</SelectItem> - {canvasPresets.map((preset, index) => { - const label = dimensionToAspectRatio({ - width: preset.width, - height: preset.height, - }); - return ( - <SelectItem key={label} value={index.toString()}> - {label} - </SelectItem> - ); - })} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Frame rate</PropertyItemLabel> - <PropertyItemValue> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - </div> - ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - <button - className={cn( - "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} - type="button" - aria-label={`Select ${blur.label} blur`} - > - <Image - src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - loading="eager" - /> - <div className="absolute right-1 bottom-1 left-1 text-center"> - <span className="rounded bg-black/50 px-1 text-xs text-white"> - {blur.label} + <div className="flex flex-col gap-2"> + <Label>Name</Label> + <span className="leading-none text-sm"> + {activeProject.metadata.name} </span> </div> - </button> - ), -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - handleColorSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - handleColorSelect: ({ bg }: { bg: string }) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg, index) => ( - <button - key={`${index}-${bg}`} - className={cn( - "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", - isColorBackground && - bg === currentBackgroundColor && - "border-primary border-2", - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => handleColorSelect({ bg })} - type="button" - aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - handleColorSelect, - useBackgroundColor, - ], - ); - }, -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -function BackgroundView() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); - - const handleBlurSelect = useCallback( - async ({ blurIntensity }: { blurIntensity: number }) => { - await editor.project.updateSettings({ - settings: { background: { type: "blur", blurIntensity } }, - }); - }, - [editor.project], - ); - - const handleColorSelect = useCallback( - async ({ color }: { color: string }) => { - await editor.project.updateSettings({ - settings: { background: { type: "color", color } }, - }); - }, - [editor.project], - ); - - const currentBlurIntensity = - activeProject.settings.background.type === "blur" - ? activeProject.settings.background.blurIntensity - : DEFAULT_BLUR_INTENSITY; - - const currentBackgroundColor = - activeProject.settings.background.type === "color" - ? activeProject.settings.background.color - : DEFAULT_COLOR; - - const isBlurBackground = activeProject.settings.background.type === "blur"; - const isColorBackground = activeProject.settings.background.type === "color"; - - const blurPreviews = useMemo( - () => - blurLevels.map((blur) => ( - <BlurPreview - key={blur.value} - blur={blur} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} - /> - )), - [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"> - <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}> - <div className="flex flex-wrap gap-2">{blurPreviews}</div> - </PropertyGroup> - - {backgroundSections.map((section) => ( - <PropertyGroup - key={section.title} - title={section.title} - defaultExpanded={false} + <div className="flex flex-col gap-2"> + <Label>Aspect ratio</Label> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} > - <div className="flex flex-wrap gap-2"> - <BackgroundPreviews - backgrounds={section.backgrounds} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} - useBackgroundColor={section.useBackgroundColor} - /> - </div> - </PropertyGroup> - ))} + <SelectTrigger className="w-fit"> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </div> + <div className="flex flex-col gap-2"> + <Label>Frame rate</Label> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-fit"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> </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 30c70a83..b1c57c3d 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-accent w-full" + className="w-full" containerClassName="w-full" value={searchQuery} onChange={({ currentTarget }) => 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 68ba91d2..671effec 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -4,144 +4,104 @@ import Image from "next/image"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Button } from "@/components/ui/button"; -import { InputWithBack } from "@/components/ui/input-with-back"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { - buildIconSvgUrl, - getIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; + resolveStickerId, + type StickerItem as StickerData, +} from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; -import type { StickerCategory } from "@/types/stickers"; import { cn } from "@/utils/ui"; import { - ArrowRightIcon, HappyIcon, ClockIcon, - LayoutGridIcon, MultiplicationSignIcon, - SparklesIcon, - HashtagIcon, + Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Spinner } from "@/components/ui/spinner"; - -function isStickerCategory(value: string): value is StickerCategory { - return STICKER_CATEGORIES.includes(value as StickerCategory); -} +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OcSlidersVerticalIcon } from "@opencut/ui/icons"; +import type { StickerCategory } from "@/types/stickers"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { parseStickerId } from "@/lib/stickers/sticker-id"; export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( - <BaseView - value={selectedCategory} - onValueChange={(v) => { - if (isStickerCategory(v)) { - setSelectedCategory({ category: v }); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, - content: <StickersContentView category="all" />, - }, - { - value: "general", - label: "Icons", - icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, - content: <StickersContentView category="general" />, - }, - { - value: "brands", - label: "Brands", - icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, - content: <StickersContentView category="brands" />, - }, - { - value: "emoji", - label: "Emoji", - icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, - content: <StickersContentView category="emoji" />, - }, - ]} - className="flex h-full flex-col overflow-hidden p-0" - /> + <PanelView + title="Stickers" + actions={ + <div className="flex items-center"> + <Select + value={selectedCategory} + onValueChange={(value: StickerCategory) => + setSelectedCategory({ category: value }) + } + > + <SelectTrigger variant="outline" size="sm" className="mr-1.5"> + <SelectValue placeholder="All" /> + </SelectTrigger> + <SelectContent> + {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( + <SelectItem key={category} value={category}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button variant="ghost" size="icon"> + <HugeiconsIcon icon={Search01Icon} className="!size-3.5" /> + </Button> + + <Button variant="ghost" size="icon"> + <OcSlidersVerticalIcon className="!size-3.5" /> + </Button> + </div> + } + > + <StickersContentView /> + </PanelView> ); } function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, + items, + shouldCapSize = false, }: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; + items: StickerData[]; + shouldCapSize?: boolean; }) { const gridStyle: CSSProperties & { "--sticker-min": string; "--sticker-max"?: string; } = { - gridTemplateColumns: capSize + gridTemplateColumns: shouldCapSize ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", "--sticker-min": "96px", - ...(capSize ? { "--sticker-max": "160px" } : {}), + ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), }; return ( <div className="grid gap-2" style={gridStyle}> - {icons.map((iconName) => ( - <StickerItem - key={iconName} - iconName={iconName} - onAdd={onAdd} - isAdding={addingSticker === iconName} - capSize={capSize} - /> - ))} - </div> - ); -} - -function CollectionGrid({ - collections, - onSelectCollection, -}: { - collections: Array<{ - prefix: string; - name: string; - total: number; - category?: string; - }>; - onSelectCollection: ({ prefix }: { prefix: string }) => void; -}) { - return ( - <div className="grid grid-cols-1 gap-2"> - {collections.map((collection) => ( - <CollectionItem - key={collection.prefix} - title={collection.name} - subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} - onClick={() => onSelectCollection({ prefix: collection.prefix })} - /> + {items.map((item) => ( + <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> ))} </div> ); @@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) { ); } -function StickersContentView({ category }: { category: StickerCategory }) { +function StickersContentView() { const { searchQuery, - selectedCollection, viewMode, - collections, - currentCollection, searchResults, recentStickers, - isLoadingCollections, - isLoadingCollection, isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, clearRecentStickers, - setSelectedCategory, - addingSticker, } = useStickersStore(); - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - const filteredCollections = useMemo(() => { - if (category === "all") { - return Object.entries(collections).map(([prefix, collection]) => ({ - prefix, - name: collection.name, - total: collection.total, - category: collection.category, - })); - } - - const collectionList = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - name: c.name, - total: collection.total, - } - : null; - }) - .filter(Boolean) as Array<{ - prefix: string; - name: string; - total: number; - }>; - }, [collections, category]); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: () => setCollectionsToShow((prev) => prev + 20), - hasMore: filteredCollections.length > collectionsToShow, - isLoading: isLoadingCollections, - enabled: viewMode === "browse" && !selectedCollection && category === "all", - }); - - useEffect(() => { - if (Object.keys(collections).length === 0) { - loadCollections(); - } - }, [collections, loadCollections]); - - useEffect(() => { - const timer = setTimeout(() => { - if (localSearchQuery !== searchQuery) { - setSearchQuery({ query: localSearchQuery }); - if (localSearchQuery.trim()) { - searchStickers({ query: localSearchQuery }); - } - } - }, 500); - - return () => clearTimeout(timer); - }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline({ iconName }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { + const itemsToDisplay = useMemo(() => { if (viewMode === "search" && searchResults) { - return searchResults.icons; - } - - if (viewMode === "collection" && currentCollection) { - const icons: string[] = []; - - if (currentCollection.uncategorized) { - icons.push( - ...currentCollection.uncategorized.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - } - - if (currentCollection.categories) { - Object.values(currentCollection.categories).forEach((categoryIcons) => { - icons.push( - ...categoryIcons.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - }); - } - - return icons.slice(0, 100); + return searchResults.items; } return []; - }, [viewMode, searchResults, currentCollection]); + }, [viewMode, searchResults]); - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); + const recentStickerItems = useMemo(() => { + const items: StickerData[] = []; + for (const stickerId of recentStickers) { + const recentStickerItem = toRecentStickerItem({ stickerId }); + if (recentStickerItem) { + items.push(recentStickerItem); + } } - }, [isInCollection]); + return items; + }, [recentStickers]); return ( - <div className="mt-1 flex h-full flex-col gap-5 p-4"> - <div className="space-y-3"> - <InputWithBack - isExpanded={isInCollection} - setIsExpanded={(expanded) => { - if (!expanded && isInCollection) { - setSelectedCollection({ collection: null }); - } - }} - placeholder={ - category === "all" - ? "Search all stickers" - : category === "general" - ? "Search icons" - : category === "brands" - ? "Search brands" - : "Search Emojis" - } - value={localSearchQuery} - onChange={setLocalSearchQuery} - disableAnimation={true} - /> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="h-full flex-1" - ref={scrollAreaRef} - onScrollCapture={handleScroll} - > - <div className="flex h-full flex-col gap-4"> - {recentStickers.length > 0 && viewMode === "browse" && ( - <div className="h-full"> - <div className="mb-2 flex items-center gap-2"> - <HugeiconsIcon - icon={ClockIcon} - className="text-muted-foreground size-4" - /> - <span className="text-sm font-medium">Recent</span> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <button - type="button" - onClick={clearRecentStickers} - className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" - > - <HugeiconsIcon - icon={MultiplicationSignIcon} - className="text-muted-foreground size-3" - /> - </button> - </TooltipTrigger> - <TooltipContent> - <p>Clear recent stickers</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <StickerGrid - icons={recentStickers.slice(0, 12)} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize - /> - </div> - )} - - {viewMode === "collection" && selectedCollection && ( - <div className="h-full"> - {isLoadingCollection ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : showCollectionItems ? ( - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - /> - ) : ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - )} - </div> - )} - - {viewMode === "search" && ( - <div className="h-full"> - {isSearching ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : searchResults?.icons.length ? ( - <> - <div className="mb-3 flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize + <div className="flex h-full flex-col gap-4"> + {recentStickerItems.length > 0 && viewMode === "browse" && ( + <div className="flex h-full flex-col gap-2"> + <div className="flex items-center gap-2"> + <HugeiconsIcon + icon={ClockIcon} + className="text-muted-foreground size-4" + /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={clearRecentStickers} + className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" + > + <HugeiconsIcon + icon={MultiplicationSignIcon} + className="text-muted-foreground size-3" /> - </> - ) : searchQuery ? ( - <div className="flex flex-col items-center justify-center gap-3 py-8"> - <EmptyView - message={`No stickers found for "${searchQuery}"`} - /> - {category !== "all" && ( - <Button - variant="outline" - onClick={() => { - const q = localSearchQuery || searchQuery; - if (q) { - setSearchQuery({ query: q }); - } - setSelectedCategory({ category: "all" }); - if (q) { - searchStickers({ query: q }); - } - }} - > - Search in all icons - </Button> - )} - </div> - ) : null} - </div> - )} - - {viewMode === "browse" && !selectedCollection && ( - <div className="h-full space-y-4"> - {isLoadingCollections ? ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ) : ( - <> - {category !== "all" && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections} - onSelectCollection={({ prefix }) => - setSelectedCollection({ collection: prefix }) - } - /> - </div> - )} + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize /> + </div> + )} - {category === "all" && filteredCollections.length > 0 && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections.slice( - 0, - collectionsToShow, - )} - onSelectCollection={({ prefix }) => - setSelectedCollection({ collection: prefix }) - } - /> - </div> - )} - </> - )} + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : searchResults?.items.length ? ( + <div className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> </div> - )} - </div> - </ScrollArea> - </div> + <StickerGrid items={itemsToDisplay} shouldCapSize /> + </div> + ) : searchQuery ? ( + <EmptyView message={`No stickers found for "${searchQuery}"`} /> + ) : null} + </div> + )} </div> ); } -interface CollectionItemProps { - title: string; - subtitle: string; - onClick: () => void; -} - -function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { - return ( - <Button - variant="outline" - className="h-auto justify-between rounded-md py-2" - onClick={onClick} - > - <div className="text-left"> - <p className="font-medium">{title}</p> - <p className="text-muted-foreground text-xs">{subtitle}</p> - </div> - <HugeiconsIcon icon={ArrowRightIcon} className="size-4" /> - </Button> - ); -} - interface StickerItemProps { - iconName: string; - onAdd: (iconName: string) => void; - isAdding?: boolean; - capSize?: boolean; + item: StickerData; + shouldCapSize?: boolean; } -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); +function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { + const { addingSticker, addStickerToTimeline } = useStickersStore(); + const isAdding = addingSticker === item.id; + const [hasImageError, setHasImageError] = useState(false); useEffect(() => { - if (!iconName) { + if (!item.id) { return; } - setImageError(false); - setHostIndex(0); - }, [iconName]); + setHasImageError(false); + }, [item.id]); - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; + const displayName = item.name; + + const handleAdd = async () => { + try { + await addStickerToTimeline({ + stickerId: item.id, + name: item.name, + }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; - const preview = imageError ? ( + const preview = hasImageError ? ( <div className="flex size-full items-center justify-center p-2"> <span className="text-muted-foreground text-center text-xs break-all"> {displayName} @@ -536,21 +249,13 @@ function StickerItem({ ) : ( <div className="flex size-full items-center justify-center p-4"> <Image - src={ - hostIndex === 0 - ? getIconSvgUrl(iconName, { width: 64, height: 64 }) - : buildIconSvgUrl( - ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], - iconName, - { width: 64, height: 64 }, - ) - } + src={item.previewUrl} alt={displayName} width={64} height={64} className="size-full object-contain" style={ - capSize + shouldCapSize ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)", @@ -558,12 +263,7 @@ function StickerItem({ : undefined } onError={() => { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } + setHasImageError(true); }} loading="lazy" unoptimized @@ -572,44 +272,63 @@ function StickerItem({ ); return ( - <Tooltip> - <TooltipTrigger asChild> - <div - className={cn( - "relative", - isAdding && "pointer-events-none opacity-50", - )} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={{ - id: iconName, - type: "sticker", - name: displayName, - iconName, - }} - onAddToTimeline={() => onAdd(iconName)} - aspectRatio={1} - shouldShowLabel={false} - isRounded={true} - variant="card" - className="" - containerClassName="w-full" - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - </TooltipTrigger> - <TooltipContent> - <div className="space-y-1"> - <p className="font-medium">{displayName}</p> - <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + <div + className={cn("relative", isAdding && "pointer-events-none opacity-50")} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }} + onAddToTimeline={handleAdd} + aspectRatio={1} + shouldShowLabel={false} + isRounded + variant="card" + containerClassName="w-full" + /> + {isAdding && ( + <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> + <Spinner className="size-6 text-white" /> </div> - </TooltipContent> - </Tooltip> + )} + </div> + ); +} + +function getStickerNameFromId({ stickerId }: { stickerId: string }): string { + const stickerIdParts = stickerId.split(":"); + if (stickerIdParts.length <= 1) { + return stickerId; + } + return ( + stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? + stickerId ); } + +function toRecentStickerItem({ + stickerId, +}: { + stickerId: string; +}): StickerData | null { + try { + const { providerId } = parseStickerId({ stickerId }); + return { + id: stickerId, + provider: providerId, + name: getStickerNameFromId({ stickerId }), + previewUrl: resolveStickerId({ + stickerId, + options: { width: 64, height: 64 }, + }), + metadata: {}, + }; + } catch { + return null; + } +} 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 6360c363..3673acc3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,5 +1,5 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { buildTextElement } from "@/lib/timeline/element-utils"; @@ -23,7 +23,7 @@ export function TextView() { }; return ( - <BaseView> + <PanelView title="Text"> <DraggableItem name="Default text" preview={ @@ -41,6 +41,6 @@ export function TextView() { onAddToTimeline={handleAddToTimeline} shouldShowLabel={false} /> - </BaseView> + </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx deleted file mode 100644 index dd6151d3..00000000 --- a/apps/web/src/components/editor/panels/panel-base-view.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "@/utils/ui"; - -interface PanelBaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - value?: string; - onValueChange?: (value: string) => void; - tabs?: { - value: string; - label: string; - icon?: React.ReactNode; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.Ref<HTMLDivElement>; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <ScrollArea className="flex-1 scrollbar-hidden"> - <div className={cn("p-5", className)}>{children}</div> - </ScrollArea> - ); -} - -export function PanelBaseView({ - children, - defaultTab, - value, - onValueChange, - tabs, - className = "", - ref, -}: PanelBaseViewProps) { - return ( - <div className={cn("flex h-full flex-col", className)} ref={ref}> - {!tabs || tabs.length === 0 ? ( - <ViewContent className={className}>{children}</ViewContent> - ) : ( - <Tabs - defaultValue={defaultTab} - value={value} - onValueChange={onValueChange} - className="flex h-full flex-col" - > - <div className="bg-background sticky top-0 z-10"> - <div className="px-3 pt-3 pb-0"> - <TabsList> - {tabs.map((tab) => ( - <TabsTrigger key={tab.value} value={tab.value}> - {tab.icon ? ( - <span className="mr-1 inline-flex items-center"> - {tab.icon} - </span> - ) : null} - {tab.label} - </TabsTrigger> - ))} - </TabsList> - </div> - <Separator className="mt-3" /> - </div> - {tabs.map((tab) => ( - <TabsContent - key={tab.value} - value={tab.value} - className="mt-0 flex min-h-0 flex-1 flex-col" - > - <ViewContent className={className}>{tab.content}</ViewContent> - </TabsContent> - ))} - </Tabs> - )} - </div> - ); -} diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx new file mode 100644 index 00000000..4ace25ac --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { findCurrentScene } from "@/lib/scenes"; +import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; + +export function BookmarkNoteOverlay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const activeProject = editor.project.getActive(); + + if (!activeProject) { + return null; + } + + const activeScene = findCurrentScene({ + scenes: activeProject.scenes, + currentSceneId: activeProject.currentSceneId, + }); + + if (!activeScene) { + return null; + } + + const bookmarks = activeScene.bookmarks; + const activeBookmarks = getBookmarksActiveAtTime({ + bookmarks, + time: currentTime, + }); + const bookmarksWithNotes = activeBookmarks.filter( + (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", + ); + + if (bookmarksWithNotes.length === 0) { + return null; + } + + return ( + <div + className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5" + aria-live="polite" + > + {bookmarksWithNotes.map((bookmark) => ( + <div + key={bookmark.time} + className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm" + style={{ + backgroundColor: "rgb(0 0 0 / 0.5)", + borderLeft: bookmark.color + ? `3px solid ${bookmark.color}` + : "3px solid var(--primary)", + }} + > + {bookmark.note} + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx new file mode 100644 index 00000000..d5cf535a --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, +} from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; + +export function PreviewContextMenu({ + onToggleFullscreen, + containerRef, +}: { + onToggleFullscreen: () => void; + containerRef: React.RefObject<HTMLElement | null>; +}) { + const { overlays, setOverlayVisibility } = usePreviewStore(); + + return ( + <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuItem onClick={onToggleFullscreen} inset> + Full screen + </ContextMenuItem> + <ContextMenuCheckboxItem + checked={overlays.bookmarks} + onCheckedChange={(checked) => + setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) + } + > + Show bookmarks + </ContextMenuCheckboxItem> + <ContextMenuItem inset>Show grid</ContextMenuItem> + </ContextMenuContent> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 902547bd..90cb11a3 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,18 +9,13 @@ 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 { formatTimeCode, getLastFrameTime } from "@/lib/time"; +import { 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"; +import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { usePreviewStore } from "@/stores/preview-store"; +import { PreviewContextMenu } from "./context-menu"; +import { PreviewToolbar } from "./toolbar"; function usePreviewSize() { const editor = useEditor(); @@ -32,6 +27,30 @@ function usePreviewSize() { }; } +export function PreviewPanel() { + const containerRef = useRef<HTMLDivElement>(null); + const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); + + return ( + <div + ref={containerRef} + className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" + > + <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0"> + <PreviewCanvas + onToggleFullscreen={toggleFullscreen} + containerRef={containerRef} + /> + <RenderTreeController /> + </div> + <PreviewToolbar + isFullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} + /> + </div> + ); +} + function RenderTreeController() { const editor = useEditor(); const tracks = editor.timeline.getTracks(); @@ -58,98 +77,24 @@ function RenderTreeController() { return null; } -export function PreviewPanel() { - const containerRef = useRef<HTMLDivElement>(null); - const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef }); - - return ( - <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, +function PreviewCanvas({ onToggleFullscreen, + containerRef, }: { - isFullscreen: boolean; onToggleFullscreen: () => void; + containerRef: React.RefObject<HTMLElement | null>; }) { - 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 outerContainerRef = useRef<HTMLDivElement>(null); + const canvasBoundsRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const containerSize = useContainerSize({ containerRef }); + const containerSize = useContainerSize({ containerRef: outerContainerRef }); const editor = useEditor(); const activeProject = editor.project.getActive(); + const { overlays } = usePreviewStore(); const renderer = useMemo(() => { return new CanvasRenderer({ @@ -224,24 +169,42 @@ function PreviewCanvas() { return ( <div - ref={containerRef} - className="relative flex h-full w-full items-center justify-center" + ref={outerContainerRef} + className="relative flex size-full items-center justify-center" > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="block border" - style={{ - width: displaySize.width, - height: displaySize.height, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> - <PreviewInteractionOverlay canvasRef={canvasRef} /> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={canvasBoundsRef} + className="relative" + style={{ width: displaySize.width, height: displaySize.height }} + > + <canvas + ref={canvasRef} + width={nativeWidth} + height={nativeHeight} + className="block border" + style={{ + width: displaySize.width, + height: displaySize.height, + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + <PreviewInteractionOverlay + canvasRef={canvasRef} + containerRef={canvasBoundsRef} + /> + {overlays.bookmarks && <BookmarkNoteOverlay />} + </div> + </ContextMenuTrigger> + <PreviewContextMenu + onToggleFullscreen={onToggleFullscreen} + containerRef={containerRef} + /> + </ContextMenu> </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx new file mode 100644 index 00000000..44efb4c8 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { usePreviewStore } from "@/stores/preview-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( + <div className="pointer-events-none absolute inset-0"> + <Image + src="/platform-guides/tiktok-blueprint.png" + alt="TikTok layout guide" + className="absolute inset-0 size-full object-contain" + draggable={false} + fill + /> + </div> + ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = usePreviewStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return <TikTokGuide />; + + return null; +} diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index c13379f0..4c33a1a1 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,20 +1,54 @@ import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import { cn } from "@/utils/ui"; +import { TransformHandles } from "./transform-handles"; +import { SnapGuides } from "./snap-guides"; +import { TextEditOverlay } from "./text-edit-overlay"; export function PreviewInteractionOverlay({ canvasRef, + containerRef, }: { canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; }) { - const { onPointerDown, onPointerMove, onPointerUp } = - usePreviewInteraction({ canvasRef }); + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + snapLines, + editingText, + commitTextEdit, + cancelTextEdit, + } = usePreviewInteraction({ canvasRef }); return ( - <div - className={cn("absolute inset-0 pointer-events-auto")} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> + <div className="absolute inset-0"> + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */} + <div + className="absolute inset-0 pointer-events-auto" + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onDoubleClick={onDoubleClick} + /> + {editingText ? ( + <TextEditOverlay + canvasRef={canvasRef} + containerRef={containerRef} + trackId={editingText.trackId} + elementId={editingText.elementId} + element={editingText.element} + onCommit={commitTextEdit} + onCancel={cancelTextEdit} + /> + ) : ( + <TransformHandles canvasRef={canvasRef} containerRef={containerRef} /> + )} + <SnapGuides + lines={snapLines} + canvasRef={canvasRef} + containerRef={containerRef} + /> + </div> ); } diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx new file mode 100644 index 00000000..4aa714a1 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { positionToOverlay } from "@/lib/preview/preview-coords"; + +export function SnapGuides({ + lines, + canvasRef, + containerRef, +}: { + lines: SnapLine[]; + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}) { + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (!canvasRect || !containerRect || lines.length === 0) { + return null; + } + + const toOverlayX = (logicalX: number) => + positionToOverlay({ + positionX: logicalX, + positionY: 0, + canvasRect, + containerRect, + canvasSize, + }).x; + + const toOverlayY = (logicalY: number) => + positionToOverlay({ + positionX: 0, + positionY: logicalY, + canvasRect, + containerRect, + canvasSize, + }).y; + + return ( + <div className="pointer-events-none absolute inset-0" aria-hidden> + {lines.map((line) => { + if (line.type === "vertical") { + return ( + <div + key={`vertical-${line.position}`} + className="absolute top-0 bottom-0 w-px bg-white/70" + style={{ left: toOverlayX(line.position) }} + /> + ); + } + return ( + <div + key={`horizontal-${line.position}`} + className="absolute left-0 right-0 h-px bg-white/70" + style={{ top: toOverlayY(line.position) }} + /> + ); + })} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx new file mode 100644 index 00000000..265e972d --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import type { TextElement } from "@/types/timeline"; +import { + positionToOverlay, + getDisplayScale, +} from "@/lib/preview/preview-coords"; +import { + DEFAULT_LINE_HEIGHT, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; + +const TEXT_BACKGROUND_PADDING = "4px 8px"; +const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06; + +export function TextEditOverlay({ + canvasRef, + containerRef, + trackId, + elementId, + element, + onCommit, + onCancel, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; + onCancel: () => void; +}) { + const editor = useEditor(); + const divRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCancel(); + return; + } + }, + [onCancel], + ); + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasRect || !containerRect || !canvasSize) return null; + + const { x: posX, y: posY } = positionToOverlay({ + positionX: element.transform.position.x, + positionY: element.transform.position.y, + canvasRect, + containerRect, + canvasSize, + }); + + const { x: displayScaleX } = getDisplayScale({ + canvasRect, + canvasSize, + }); + + const displayFontSize = + element.fontSize * + (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * + displayScaleX; + + const verticalAlignmentOffset = + displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM; + + const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const letterSpacing = element.letterSpacing ?? 0; + const backgroundColor = + element.backgroundColor === "transparent" + ? "transparent" + : element.backgroundColor; + + return ( + <div + className="absolute" + style={{ + left: posX, + top: posY - verticalAlignmentOffset, + transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`, + transformOrigin: "center center", + }} + > + {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} + <div + ref={divRef} + contentEditable + suppressContentEditableWarning + tabIndex={0} + role="textbox" + aria-label="Edit text" + className="cursor-text select-text outline-none whitespace-pre" + style={{ + fontSize: displayFontSize, + fontFamily: element.fontFamily, + fontWeight, + fontStyle, + textAlign: element.textAlign, + letterSpacing: `${letterSpacing}px`, + lineHeight, + color: element.color, + backgroundColor, + minHeight: displayFontSize * lineHeight, + textDecoration: element.textDecoration ?? "none", + padding: + backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING, + minWidth: 1, + }} + onInput={handleInput} + onBlur={onCommit} + onKeyDown={handleKeyDown} + > + {element.content || ""} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx new file mode 100644 index 00000000..7f5cd9bc --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { formatTimeCode } from "@/lib/time"; +import { invokeAction } from "@/lib/actions"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { OcSocialIcon } from "@opencut/ui/icons"; +import { Separator } from "@/components/ui/separator"; + +export 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"> + <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" + onClick={() => invokeAction("toggle-play")} + > + <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> + </Button> + + <div className="justify-self-end flex items-center gap-2.5"> + <Button + variant="secondary" + size="sm" + className="[&_svg]:size-auto px-1 h-7" + onClick={onToggleFullscreen} + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + <OcSocialIcon size={20} /> + </Button> + <Separator orientation="vertical" className="h-4" /> + <Button + variant="text" + onClick={onToggleFullscreen} + title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"} + > + <HugeiconsIcon icon={FullScreenIcon} /> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx new file mode 100644 index 00000000..976c3019 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useTransformHandles } from "@/hooks/use-transform-handles"; +import { useEditor } from "@/hooks/use-editor"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { SnapGuides } from "./snap-guides"; +import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Rotate01Icon } from "@hugeicons/core-free-icons"; + +const HANDLE_SIZE = 10; +const ROTATION_HANDLE_OFFSET = 24; +const ROTATION_HANDLE_RADIUS = 10; +const CORNER_HIT_AREA_SIZE = 18; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +const CORNERS: Corner[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; + +function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): { + x: number; + y: number; +} { + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; + return { + x: bounds.cx - localY * sin, + y: bounds.cy + localY * cos, + }; +} + +function getOverlayContext({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}): { + canvasRect: DOMRect; + containerRect: DOMRect; +} | null { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!canvasRect || !containerRect) return null; + return { canvasRect, containerRect }; +} + +export function TransformHandles({ + canvasRef, + containerRef, +}: { + canvasRef: React.RefObject<HTMLCanvasElement | null>; + containerRef: React.RefObject<HTMLDivElement | null>; +}) { + const { + selectedWithBounds, + hasVisualSelection, + snapLines, + handleCornerPointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + } = useTransformHandles({ canvasRef }); + + const editor = useEditor(); + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!hasVisualSelection || !selectedWithBounds) return null; + + const overlayContext = getOverlayContext({ canvasRef, containerRef }); + if (!overlayContext) return null; + + const { bounds, element } = selectedWithBounds; + if (!isVisualElement(element)) return null; + + const { canvasRect, containerRect } = overlayContext; + const displayScale = getDisplayScale({ canvasRect, canvasSize }); + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + canvasToOverlay({ + canvasX, + canvasY, + canvasRect, + containerRect, + canvasSize, + }); + + const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); + const outlineWidth = bounds.width * displayScale.x; + const outlineHeight = bounds.height * displayScale.y; + + const rotationHandle = getRotationHandlePosition({ bounds }); + const rotationHandleScreen = toOverlay({ + canvasX: rotationHandle.x, + canvasY: rotationHandle.y, + }); + + return ( + <div className="pointer-events-none absolute inset-0" aria-hidden> + <SnapGuides + lines={snapLines} + canvasRef={canvasRef} + containerRef={containerRef} + /> + <BoundingBoxOutline + center={center} + outlineWidth={outlineWidth} + outlineHeight={outlineHeight} + rotation={bounds.rotation} + /> + {CORNERS.map((corner) => { + const cornerPosition = getCornerPosition({ bounds, corner }); + const screen = toOverlay({ + canvasX: cornerPosition.x, + canvasY: cornerPosition.y, + }); + return ( + <CornerHandle + key={corner} + corner={corner} + screen={screen} + onPointerDown={(event) => + handleCornerPointerDown({ event, corner }) + } + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> + ); + })} + <RotationHandle + screen={rotationHandleScreen} + onPointerDown={(event) => handleRotationPointerDown({ event })} + onPointerMove={(event) => handlePointerMove({ event })} + onPointerUp={(event) => handlePointerUp({ event })} + /> + </div> + ); +} + +function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; +}) { + return ( + <div + className="pointer-events-none absolute" + style={{ + left: center.x - outlineWidth / 2, + top: center.y - outlineHeight / 2, + width: outlineWidth, + height: outlineHeight, + transform: `rotate(${rotation}deg)`, + transformOrigin: "center center", + border: "1px solid white", + boxSizing: "border-box", + opacity: 0.75, + }} + /> + ); +} + +function CornerHandle({ + corner, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + corner: Corner; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isNesw = corner === "top-right" || corner === "bottom-left"; + + return ( + <button + type="button" + className={cn( + "absolute flex items-center justify-center outline-none", + isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize", + )} + style={{ + left: screen.x - CORNER_HIT_AREA_SIZE / 2, + top: screen.y - CORNER_HIT_AREA_SIZE / 2, + width: CORNER_HIT_AREA_SIZE, + height: CORNER_HIT_AREA_SIZE, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + <div + className="rounded-sm bg-white" + style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} + /> + </button> + ); +} + +function RotationHandle({ + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + <button + type="button" + className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none" + style={{ + left: screen.x - ROTATION_HANDLE_RADIUS, + top: screen.y - ROTATION_HANDLE_RADIUS, + width: ROTATION_HANDLE_RADIUS * 2, + height: ROTATION_HANDLE_RADIUS * 2, + pointerEvents: "auto", + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerLeave={onPointerUp} + onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} + onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} + > + <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} /> + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index 8ccf07ee..c5576dda 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -17,4 +17,4 @@ export function EmptyView() { </div> </div> ); -}
\ No newline at end of file +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts new file mode 100644 index 00000000..395666cb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -0,0 +1,72 @@ +import { useReducer, useRef } from "react"; +import { evaluateMathExpression } from "@/utils/math"; + +function looksLikeExpression({ input }: { input: string }): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + if (/[+*/]/.test(input)) return true; + const minusIndex = trimmed.indexOf("-"); + return minusIndex > 0; +} + +export function usePropertyDraft<T>({ + displayValue: sourceDisplay, + parse, + onPreview, + onCommit, + supportsExpressions = true, +}: { + displayValue: string; + parse: (input: string) => T | null; + onPreview: (value: T) => void; + onCommit: () => void; + supportsExpressions?: boolean; +}) { + const [, forceRender] = useReducer( + (renderVersion: number) => renderVersion + 1, + 0, + ); + const isEditing = useRef(false); + const draft = useRef(""); + + return { + displayValue: isEditing.current ? draft.current : sourceDisplay, + scrubTo: (value: number) => { + const parsed = parse(String(value)); + if (parsed !== null) onPreview(parsed); + }, + commitScrub: onCommit, + onFocus: () => { + isEditing.current = true; + draft.current = sourceDisplay; + forceRender(); + }, + onChange: ( + event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ) => { + draft.current = event.target.value; + forceRender(); + + const parsed = parse(event.target.value); + if (parsed !== null) { + onPreview(parsed); + } + }, + onBlur: () => { + if ( + supportsExpressions && + looksLikeExpression({ input: draft.current }) + ) { + const evaluated = evaluateMathExpression({ input: draft.current }); + if (evaluated !== null) { + const parsed = parse(String(evaluated)); + if (parsed !== null) onPreview(parsed); + } + } + onCommit(); + isEditing.current = false; + draft.current = ""; + forceRender(); + }, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx deleted file mode 100644 index a682097a..00000000 --- a/apps/web/src/components/editor/panels/properties/property-item.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from "react"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( - <div - className={cn( - "flex gap-2", - direction === "row" - ? "items-center justify-between gap-6" - : "flex-col gap-1.5", - className, - )} - > - {children} - </div> - ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <span className={cn("text-muted-foreground text-xs", className)}> - {children} - </span> - ); -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={cn("flex-1 text-sm", className)}>{children}</div>; -} - -interface PropertyGroupProps { - title: string; - children: React.ReactNode; - defaultExpanded?: boolean; - collapsible?: boolean; - className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; -} - -export function PropertyGroup({ - title, - children, - defaultExpanded = true, - collapsible = true, - className, - hasBorderTop = true, - hasBorderBottom = true, -}: PropertyGroupProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - return ( - <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/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx new file mode 100644 index 00000000..0b45065f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -0,0 +1,161 @@ +import { createContext, useContext, useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +const sectionExpandedCache = new Map<string, boolean>(); + +interface SectionContext { + isOpen: boolean; + toggle: () => void; + collapsible: boolean; +} + +const SectionCtx = createContext<SectionContext | null>(null); + +function useSectionContext() { + return useContext(SectionCtx); +} + +interface SectionProps { + children: React.ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; + sectionKey?: string; + className?: string; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; +} + +export function Section({ + children, + collapsible = false, + defaultOpen = true, + sectionKey, + className, + hasBorderTop = true, + hasBorderBottom = true, +}: SectionProps) { + const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; + const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + + const toggle = () => { + const next = !isOpen; + setIsOpen(next); + if (sectionKey) sectionExpandedCache.set(sectionKey, next); + }; + + return ( + <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}> + <div + className={cn( + "flex flex-col", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {children} + </div> + </SectionCtx.Provider> + ); +} + +interface SectionHeaderProps { + title: string; + children?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function SectionHeader({ + title, + children, + onClick, + className, +}: SectionHeaderProps) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + const isInteractive = isCollapsible || !!onClick; + + const handleClick = isCollapsible ? ctx?.toggle : onClick; + + const content = ( + <> + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + )} + > + {title} + </span> + <div className="flex items-center gap-1"> + {children} + {isCollapsible && ( + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn( + "size-3 shrink-0 transition-transform duration-200 ease-out", + isOpen + ? "rotate-0 text-foreground" + : "-rotate-90 text-muted-foreground", + )} + /> + )} + </div> + </> + ); + + const baseClassName = cn( + "flex w-full items-center justify-between h-11 px-3.5", + className, + ); + + if (isInteractive) { + return ( + <button + type="button" + className={cn(baseClassName, "cursor-pointer text-left")} + onClick={(event) => { + handleClick?.(); + event.currentTarget.blur(); + }} + > + {content} + </button> + ); + } + + return <div className={baseClassName}>{content}</div>; +} + +export function SectionContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + + if (isCollapsible) { + return ( + <div + className={cn( + "grid transition-[grid-template-rows] duration-100 ease-out", + isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", + )} + > + <div className="overflow-hidden"> + <div className={cn("p-4 pt-0", className)}>{children}</div> + </div> + </div> + ); + } + + return <div className={cn("p-4 pt-0", className)}>{children}</div>; +} diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx new file mode 100644 index 00000000..430efa25 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -0,0 +1,196 @@ +import { useEditor } from "@/hooks/use-editor"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { + DEFAULT_BLEND_MODE, + DEFAULT_OPACITY, +} from "@/constants/timeline-constants"; +import { OcCheckerboardIcon } from "@opencut/ui/icons"; +import { Fragment, useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "../section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/types/rendering"; +import type { ElementType } from "@/types/timeline"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; +}; + +const BLEND_MODE_GROUPS = [ + [{ value: "normal", label: "Normal" }], + [ + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + ], + [ + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + ], + [ + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + ], + [ + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + ], + [ + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, + ], +]; + +export function BlendingSection({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE; + const didSelectRef = useRef(false); + const committedBlendModeRef = useRef(blendMode); + if (!editor.timeline.isPreviewActive()) { + committedBlendModeRef.current = blendMode; + } + + const previewBlendMode = (value: BlendMode) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + didSelectRef.current = true; + }; + + const handleBlendModeOpenChange = (isOpen: boolean) => { + if (!isOpen) { + if (!didSelectRef.current) editor.timeline.discardPreview(); + didSelectRef.current = false; + } + }; + + const opacity = usePropertyDraft({ + displayValue: Math.round(element.opacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + onPreview: (value) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { opacity: value } }, + ], + }), + onCommit: () => editor.timeline.commitPreview(), + }); + + return ( + <Section collapsible sectionKey={`${element.type}:blending`}> + <SectionHeader title="Blending" /> + <SectionContent> + <div className="flex items-start gap-2"> + <div className="w-1/2 space-y-1.5"> + <NumberField + className="w-full" + icon={ + <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: DEFAULT_OPACITY }, + }, + ], + }) + } + isDefault={element.opacity === DEFAULT_OPACITY} + dragSensitivity="slow" + /> + </div> + <div className="w-1/2 space-y-1.5"> + <Select + value={committedBlendModeRef.current} + onOpenChange={handleBlendModeOpenChange} + onValueChange={commitBlendMode} + > + <SelectTrigger + icon={<HugeiconsIcon icon={RainDropIcon} />} + className="w-full" + > + <SelectValue placeholder="Select blend mode" /> + </SelectTrigger> + <SelectContent className="w-36"> + {BLEND_MODE_GROUPS.map((group, groupIndex) => ( + <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> + {group.map((option) => ( + <SelectItem + key={option.value} + value={option.value} + onPointerEnter={() => + previewBlendMode(option.value as BlendMode) + } + > + {option.label} + </SelectItem> + ))} + {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( + <SelectSeparator /> + ) : null} + </Fragment> + ))} + </SelectContent> + </Select> + </div> + </div> + </SectionContent> + </Section> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx new file mode 100644 index 00000000..8be58f39 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx @@ -0,0 +1,2 @@ +export * from "./transform"; +export * from "./blending"; diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx new file mode 100644 index 00000000..17a54b4c --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -0,0 +1,248 @@ +import { NumberField } from "@/components/ui/number-field"; +import { useEditor } from "@/hooks/use-editor"; +import { usePropertyDraft } from "../hooks/use-property-draft"; +import { clamp } from "@/utils/math"; +import type { ElementType, Transform } from "@/types/timeline"; +import { Section, SectionContent, SectionHeader } from "../section"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Link05Icon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { useState } from "react"; +import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants"; + +type TransformElement = { + id: string; + transform: Transform; + type: ElementType; +}; + +function parseFloat_({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : parsed; +} + +export function TransformSection({ + element, + trackId, +}: { + element: TransformElement; + trackId: string; +}) { + const editor = useEditor(); + const [isScaleLocked, setIsScaleLocked] = useState(false); + + const previewTransform = (transform: Partial<Transform>) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { transform: { ...element.transform, ...transform } }, + }, + ], + }); + }; + + const commit = () => editor.timeline.commitPreview(); + + const positionX = usePropertyDraft({ + displayValue: Math.round(element.transform.position.x).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, x: value }, + }), + onCommit: commit, + }); + + const positionY = usePropertyDraft({ + displayValue: Math.round(element.transform.position.y).toString(), + parse: (input) => parseFloat_({ input }), + onPreview: (value) => + previewTransform({ + position: { ...element.transform.position, y: value }, + }), + onCommit: commit, + }); + + const scale = usePropertyDraft({ + displayValue: Math.round(element.transform.scale * 100).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return Math.max(parsed, 1) / 100; + }, + onPreview: (value) => previewTransform({ scale: value }), + onCommit: commit, + }); + const scaleFieldProps = { + className: "flex-1", + value: scale.displayValue, + onFocus: scale.onFocus, + onChange: scale.onChange, + onBlur: scale.onBlur, + dragSensitivity: "slow" as const, + onScrub: scale.scrubTo, + onScrubEnd: scale.commitScrub, + onReset: () => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + scale: DEFAULT_TRANSFORM.scale, + }, + }, + }, + ], + }), + isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale, + }; + + const rotation = usePropertyDraft({ + displayValue: Math.round(element.transform.rotate).toString(), + parse: (input) => { + const parsed = parseFloat_({ input }); + if (parsed === null) return null; + return clamp({ value: parsed, min: -360, max: 360 }); + }, + onPreview: (value) => previewTransform({ rotate: value }), + onCommit: commit, + }); + + return ( + <Section collapsible sectionKey={`${element.type}:transform`}> + <SectionHeader title="Transform" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + {isScaleLocked ? ( + <> + <NumberField icon="W" {...scaleFieldProps} /> + <NumberField icon="H" {...scaleFieldProps} /> + </> + ) : ( + <NumberField + icon={<HugeiconsIcon icon={ArrowExpandIcon} />} + {...scaleFieldProps} + className="flex-1" + /> + )} + <Button + variant={isScaleLocked ? "secondary" : "ghost"} + size="icon" + aria-pressed={isScaleLocked} + onClick={() => setIsScaleLocked((isLocked) => !isLocked)} + > + <HugeiconsIcon icon={Link05Icon} /> + </Button> + </div> + <div className="flex items-center gap-2"> + <NumberField + icon="X" + className="flex-1" + value={positionX.displayValue} + onFocus={positionX.onFocus} + onChange={positionX.onChange} + onBlur={positionX.onBlur} + onScrub={positionX.scrubTo} + onScrubEnd={positionX.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + position: { + ...element.transform.position, + x: DEFAULT_TRANSFORM.position.x, + }, + }, + }, + }, + ], + }) + } + isDefault={ + element.transform.position.x === DEFAULT_TRANSFORM.position.x + } + /> + <NumberField + icon="Y" + className="flex-1" + value={positionY.displayValue} + onFocus={positionY.onFocus} + onChange={positionY.onChange} + onBlur={positionY.onBlur} + onScrub={positionY.scrubTo} + onScrubEnd={positionY.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + position: { + ...element.transform.position, + y: DEFAULT_TRANSFORM.position.y, + }, + }, + }, + }, + ], + }) + } + isDefault={ + element.transform.position.y === DEFAULT_TRANSFORM.position.y + } + /> + </div> + + <div className="flex items-center gap-2"> + <NumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + className="flex-1" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + transform: { + ...element.transform, + rotate: DEFAULT_TRANSFORM.rotate, + }, + }, + }, + ], + }) + } + isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} 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 22e50e4e..cfca52dc 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -1,24 +1,31 @@ import { Textarea } from "@/components/ui/textarea"; import { FontPicker } from "@/components/ui/font-picker"; -import type { FontFamily } from "@/constants/font-constants"; 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 { useReducer, useRef } from "react"; -import { PanelBaseView } from "@/components/editor/panels/panel-base-view"; -import { - PropertyGroup, - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; +import { NumberField } from "@/components/ui/number-field"; +import { useRef } from "react"; +import { Section, SectionContent, SectionHeader } from "./section"; import { ColorPicker } from "@/components/ui/color-picker"; import { uppercase } from "@/utils/string"; import { clamp } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants"; +import { + DEFAULT_TEXT_ELEMENT, + MAX_FONT_SIZE, + MIN_FONT_SIZE, +} from "@/constants/text-constants"; +import { usePropertyDraft } from "./hooks/use-property-draft"; +import { TransformSection, BlendingSection } from "./sections"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { OcFontWeightIcon } from "@opencut/ui/icons"; +import { Label } from "@/components/ui/label"; export function TextProperties({ element, @@ -27,624 +34,229 @@ export function TextProperties({ element: TextElement; trackId: string; }) { - const editor = useEditor(); - const containerRef = useRef<HTMLDivElement>(null); - 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 }) => { - fontSizeDraft.current = value; - forceRender(); + return ( + <div className="flex h-full flex-col"> + <ContentSection element={element} trackId={trackId} /> + <TransformSection element={element} trackId={trackId} /> + <BlendingSection element={element} trackId={trackId} /> + <FontSection element={element} trackId={trackId} /> + <ColorSection element={element} trackId={trackId} /> + </div> + ); +} - 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, - }); - } - }; +function ContentSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleFontSizeBlur = () => { - 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({ + const content = usePropertyDraft({ + displayValue: element.content, + parse: (input) => input, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { fontSize: initialFontSizeRef.current }, - }, + { trackId, elementId: element.id, updates: { content: value } }, ], - pushHistory: false, - }); - editor.timeline.updateElements({ - updates: [{ trackId, elementId: element.id, updates: { fontSize } }], - pushHistory: true, - }); - initialFontSizeRef.current = null; - } - isEditingFontSize.current = false; - fontSizeDraft.current = ""; - forceRender(); - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); - const handleOpacityChange = ({ value }: { value: string }) => { - 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) - : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, - }, - ], - pushHistory: false, - }); - } - }; + return ( + <Section collapsible sectionKey="text:content" hasBorderTop={false}> + <SectionHeader title="Content" /> + <SectionContent> + <Textarea + placeholder="Name" + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionContent> + </Section> + ); +} - const handleOpacityBlur = () => { - 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(); - }; +function FontSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); - const handleColorChange = ({ color }: { color: string }) => { - if (color !== "transparent") { - lastSelectedColor.current = color; - } - if (initialBgColorRef.current === null) { - initialBgColorRef.current = element.backgroundColor; - } - if (initialBgColorRef.current !== null) { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, - ], - pushHistory: false, - }); - } else { - editor.timeline.updateElements({ + const fontSize = usePropertyDraft({ + displayValue: element.fontSize.toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); + }, + onPreview: (value) => + editor.timeline.previewElements({ updates: [ - { - trackId, - elementId: element.id, - updates: { backgroundColor: color }, - }, + { trackId, elementId: element.id, updates: { fontSize: value } }, ], - }); - } - }; - - 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; - } - }; + }), + onCommit: () => editor.timeline.commitPreview(), + }); return ( - <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; - } + <Section collapsible sectionKey="text:font"> + <SectionHeader title="Font" /> + <SectionContent> + <div className="flex flex-col gap-2"> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value) => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: event.target.value }, + updates: { fontFamily: value }, }, ], - pushHistory: false, - }); - }} - onBlur={() => { - if (initialContentRef.current !== null) { - const finalContent = contentDraft.current; - editor.timeline.updateElements({ + }) + } + /> + <Select value={element.fontWeight}> + <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}> + <SelectValue placeholder="Select weight" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="100">Thin</SelectItem> + <SelectItem value="200">Extra Light</SelectItem> + <SelectItem value="300">Light</SelectItem> + <SelectItem value="400">Normal</SelectItem> + <SelectItem value="500">Medium</SelectItem> + <SelectItem value="600">Semi Bold</SelectItem> + <SelectItem value="700">Bold</SelectItem> + <SelectItem value="800">Extra Bold</SelectItem> + <SelectItem value="900">Black</SelectItem> + </SelectContent> + </Select> + + <Label>Font size</Label> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + onValueChange={([value]) => + editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, - updates: { content: initialContentRef.current }, + updates: { fontSize: value }, }, ], - pushHistory: false, - }); + }) + } + onValueCommit={() => editor.timeline.commitPreview()} + className="w-full" + /> + <NumberField + className="w-18 shrink-0" + value={fontSize.displayValue} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + onFocus={fontSize.onFocus} + onChange={fontSize.onChange} + onBlur={fontSize.onBlur} + onReset={() => editor.timeline.updateElements({ updates: [ { trackId, elementId: element.id, - updates: { content: finalContent }, + updates: { + fontSize: DEFAULT_TEXT_ELEMENT.fontSize, + }, }, ], - pushHistory: true, - }); - initialContentRef.current = null; + }) } - isEditingContent.current = false; - contentDraft.current = ""; - forceRender(); + isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize} + /> + </div> + </div> + </SectionContent> + </Section> + ); +} + +function ColorSection({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const lastSelectedColor = useRef(DEFAULT_COLOR); + + return ( + <Section collapsible sectionKey="text:color" hasBorderBottom={false}> + <SectionHeader title="Color" /> + <SectionContent> + <div className="flex flex-col gap-6"> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], + }) + } + onChangeEnd={() => editor.timeline.commitPreview()} + /> + <ColorPicker + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", "") + } + onChange={(color) => { + const hexColor = `#${color}`; + if (color !== "transparent") { + lastSelectedColor.current = hexColor; + } + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: hexColor }, + }, + ], + }); }} + onChangeEnd={() => editor.timeline.commitPreview()} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } /> - </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: { - 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 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 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; - } - 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: { - 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={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: [ - { - trackId, - elementId: element.id, - 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, - }); - }} - 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={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> + </div> + </SectionContent> + </Section> ); } diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index cfcb1e23..64824f9a 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -1,21 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; +import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; +import { + DEFAULT_BOOKMARK_COLOR, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { DEFAULT_FPS } from "@/constants/project-constants"; import { getSnappedSeekTime } from "@/lib/time"; -import { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { + ArrowTurnBackwardIcon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import type { Bookmark } from "@/types/timeline"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; + +const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND; +const MIN_BOOKMARK_WIDTH_PX = 2; +const BOOKMARK_MARKER_WIDTH_PX = 12; +const BOOKMARK_MARKER_HEIGHT_PX = 15; +const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; +const BOOKMARK_MARKER_CLIP_PATH = + "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; + +function seekToBookmarkTime({ + editor, + time, +}: { + editor: EditorCore; + time: number; +}) { + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + const fps = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps }); + editor.playback.seek({ time: snappedTime }); +} interface TimelineBookmarksRowProps { zoomLevel: number; dynamicTimelineWidth: number; - handleWheel: (e: React.WheelEvent) => void; - handleTimelineContentClick: (e: React.MouseEvent) => void; - handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; - handleRulerMouseDown: (e: React.MouseEvent) => void; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; + handleWheel: (event: React.WheelEvent) => void; + handleTimelineContentClick: (event: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; + handleRulerMouseDown: (event: React.MouseEvent) => void; } export function TimelineBookmarksRow({ zoomLevel, dynamicTimelineWidth, + dragState, + onBookmarkMouseDown, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -34,17 +88,23 @@ export function TimelineBookmarksRow({ aria-label="Timeline ruler" type="button" onWheel={handleWheel} - onClick={handleTimelineContentClick} + onClick={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleTimelineContentClick(event); + }} onMouseDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; handleRulerMouseDown(event); handleRulerTrackingMouseDown(event); }} > - {activeScene.bookmarks.map((time: number) => ( + {activeScene.bookmarks.map((bookmark) => ( <TimelineBookmark - key={`bookmark-row-${time}`} - time={time} + key={`bookmark-${bookmark.time}`} + bookmark={bookmark} zoomLevel={zoomLevel} + dragState={dragState} + onBookmarkMouseDown={onBookmarkMouseDown} /> ))} </button> @@ -52,60 +112,305 @@ export function TimelineBookmarksRow({ ); } -export function TimelineBookmark({ - time, +function TimelineBookmark({ + bookmark, zoomLevel, + dragState, + onBookmarkMouseDown, }: { - time: number; + bookmark: Bookmark; zoomLevel: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; }) { const editor = useEditor(); - const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isDragging = + dragState.isDragging && + dragState.bookmarkTime !== null && + Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON; + + const displayTime = isDragging ? dragState.currentTime : bookmark.time; + const time = bookmark.time; + const bookmarkDuration = bookmark.duration ?? 0; + const durationWidth = + bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0; + const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; + const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); + const left = displayTime * PIXELS_PER_SECOND * zoomLevel; + const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; + const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); + const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; + + const handleSeek = () => seekToBookmarkTime({ editor, time }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.detail === 2) { + setIsPopoverOpen(true); + } else { + handleSeek(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleSeek(); + }; - const handleBookmarkActivate = ({ - event, - }: { - event: - | React.MouseEvent<HTMLButtonElement> - | React.KeyboardEvent<HTMLButtonElement>; - }) => { + const handleMouseDown = (event: React.MouseEvent) => { + onBookmarkMouseDown({ event, bookmark }); + event.preventDefault(); event.stopPropagation(); - const framesPerSecond = activeProject?.settings.fps ?? 30; - const snappedTime = getSnappedSeekTime({ - rawTime: time, - duration, - fps: framesPerSecond, - }); - editor.playback.seek({ time: snappedTime }); }; return ( - <button - className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" - style={{ - left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - aria-label={`Seek to bookmark at ${time}s`} - type="button" - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => handleBookmarkActivate({ event })} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - handleBookmarkActivate({ event }); - }} - > - <div className="text-primary absolute top-[-1px] left-[-5px]"> - <HugeiconsIcon - icon={Bookmark02Icon} - aria-hidden="true" - className="fill-primary size-3" + <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> + <PopoverAnchor asChild> + <button + className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} + style={{ + left: `${bookmarkLeft}px`, + width: `${bookmarkWidth}px`, + }} + aria-label={`Bookmark at ${time.toFixed(1)}s`} + type="button" + onMouseDown={handleMouseDown} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {hasDurationRange ? ( + <div + className="absolute opacity-30" + style={{ + top: 1.5, + height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, + left: BOOKMARK_HALF_WIDTH_PX, + width: durationWidth, + backgroundColor: iconColor, + }} + /> + ) : null} + <div + className="absolute top-0 overflow-hidden" + style={{ + left: 0, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute inset-0" + style={{ + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + <div + className="absolute top-0 overflow-hidden" + style={{ + left: rightHalfLeft, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute top-0" + style={{ + left: -BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1 - BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + </button> + </PopoverAnchor> + <PopoverContent + className="w-64 flex flex-col gap-3 p-3" + align="start" + side="bottom" + sideOffset={8} + onOpenAutoFocus={(event) => event.preventDefault()} + > + <BookmarkPopoverContent + bookmark={bookmark} + time={time} + timelineDuration={duration} + onPopoverClose={() => setIsPopoverOpen(false)} /> + </PopoverContent> + </Popover> + ); +} + +function BookmarkPopoverContent({ + bookmark, + time, + timelineDuration, + onPopoverClose, +}: { + bookmark: Bookmark; + time: number; + timelineDuration: number; + onPopoverClose: () => void; +}) { + const editor = useEditor(); + const [draftColorHex, setDraftColorHex] = useState( + (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + ); + + useEffect(() => { + setDraftColorHex( + (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + ); + }, [bookmark.color]); + + const handleRemove = () => { + editor.scenes.removeBookmark({ time }); + onPopoverClose(); + }; + + const handleUpdate = ({ + note, + color, + duration, + }: Partial<{ note: string; color: string; duration: number }>) => { + const updates: Partial<{ note: string; color: string; duration: number }> = + {}; + if (note !== undefined && note !== bookmark.note) updates.note = note; + if ( + color !== undefined && + color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() + ) { + updates.color = color; + } + if (duration !== undefined && duration !== bookmark.duration) { + updates.duration = duration; + } + if (Object.keys(updates).length === 0) return; + editor.scenes.updateBookmark({ time, updates }); + }; + + return ( + <> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Note</Label> + <Input + placeholder="Add a note..." + value={bookmark.note ?? ""} + onChange={(event) => handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Color</Label> + <div className="relative"> + <ColorPicker + value={uppercase({ string: draftColorHex })} + onChange={(color) => setDraftColorHex(uppercase({ string: color }))} + onChangeEnd={(color) => + handleUpdate({ color: `#${uppercase({ string: color })}` }) + } + className="bg-background border" + /> + {bookmark.color && + bookmark.color.replace(/^#/, "").toUpperCase() !== + DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( + <Button + type="button" + variant="text" + size="text" + aria-label="Reset to default color" + className="absolute top-1/2 right-1 -translate-y-1/2 mr-1" + onClick={() => + editor.scenes.updateBookmark({ + time, + updates: { color: undefined }, + }) + } + > + <HugeiconsIcon + icon={ArrowTurnBackwardIcon} + className="!size-3.5" + /> + </Button> + )} + </div> </div> - </button> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Duration</Label> + <div className="flex items-center gap-1.5"> + <Input + type="number" + min={0} + step={0.1} + value={bookmark.duration ?? 0} + onChange={(event) => { + const parsed = parseFloat(event.target.value); + const value = Number.isNaN(parsed) + ? 0 + : clamp({ + value: parsed, + min: 0, + max: Math.max(0, timelineDuration - time), + }); + handleUpdate({ duration: value }); + }} + className="h-8 text-sm" + containerClassName="w-full" + /> + </div> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-destructive hover:bg-destructive/10" + onClick={handleRemove} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + handleRemove(); + } + }} + aria-label="delete bookmark" + > + <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" /> + Delete + </Button> + </> ); } diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 94edc2d0..7e6ff326 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; import { TimelineRuler } from "./timeline-ruler"; import { TimelineBookmarksRow } from "./bookmarks"; +import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useTimelineStore } from "@/stores/timeline-store"; import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; @@ -126,6 +128,17 @@ export function Timeline() { onSnapPointChange: handleSnapPointChange, }); + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = useTimelinePlayhead({ zoomLevel, @@ -168,10 +181,18 @@ export function Timeline() { containerWidth, ); + useEdgeAutoScroll({ + isActive: bookmarkDragState.isDragging, + getMouseClientX: () => bookmarkLastMouseXRef.current, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || isResizing); + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -351,7 +372,7 @@ export function Timeline() { > <div ref={timelineHeaderRef} - className="bg-background sticky top-0 z-30 flex flex-col" + className="bg-background sticky top-0 flex flex-col" > <TimelineRuler zoomLevel={zoomLevel} @@ -366,6 +387,8 @@ export function Timeline() { <TimelineBookmarksRow zoomLevel={zoomLevel} dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} handleWheel={handleWheel} handleTimelineContentClick={handleRulerClick} handleRulerTrackingMouseDown={handleRulerMouseDown} @@ -433,7 +456,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-40"> + <ContextMenuContent className="w-40"> <ContextMenuItem icon={<HugeiconsIcon icon={TaskAdd02Icon} />} onClick={(e) => { diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 914edccb..e70f0cd7 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -38,7 +38,7 @@ export function SnapIndicator({ return ( <div - className="pointer-events-none absolute z-90" + className="pointer-events-none absolute" style={{ left: `${leftPosition}px`, top: topPosition, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 59ce88da..628855f6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets"; import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { resolveStickerId } from "@/lib/stickers"; import Image from "next/image"; import { ScissorIcon, @@ -139,9 +140,7 @@ export function TimelineElement({ <ContextMenu> <ContextMenuTrigger asChild> <div - className={`absolute top-0 h-full select-none ${ - isBeingDragged ? "z-30" : "z-10" - }`} + className={`absolute top-0 h-full select-none`} style={{ left: `${elementLeft}px`, width: `${elementWidth}px`, @@ -155,7 +154,6 @@ export function TimelineElement({ element={element} track={track} isSelected={isSelected} - isBeingDragged={isBeingDragged} hasAudio={hasAudio} isMuted={isMuted} mediaAssets={mediaAssets} @@ -165,7 +163,7 @@ export function TimelineElement({ /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-200 w-64"> + <ContextMenuContent className="w-64"> <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />} @@ -227,7 +225,6 @@ function ElementInner({ element, track, isSelected, - isBeingDragged, hasAudio, isMuted, mediaAssets, @@ -238,7 +235,6 @@ function ElementInner({ element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - isBeingDragged: boolean; hasAudio: boolean; isMuted: boolean; mediaAssets: MediaAsset[]; @@ -248,7 +244,7 @@ function ElementInner({ element: TimelineElementType, ) => void; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -259,7 +255,7 @@ function ElementInner({ { type: track.type, }, - )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} > <button type="button" @@ -321,7 +317,7 @@ function ResizeHandle({ side: "left" | "right"; elementId: string; handleResizeStart: (params: { - e: React.MouseEvent; + event: React.MouseEvent; elementId: string; side: "left" | "right"; }) => void; @@ -330,8 +326,8 @@ function ResizeHandle({ return ( <button type="button" - className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} - onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + onMouseDown={(event) => handleResizeStart({ event, elementId, side })} aria-label={`${isLeft ? "Left" : "Right"} resize handle`} > <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> @@ -362,7 +358,10 @@ function ElementContent({ return ( <div className="flex size-full items-center gap-2 pl-2"> <Image - src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} alt={element.name} className="size-5 shrink-0" width={20} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 8af35dc9..ebb2a37e 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -72,20 +72,22 @@ export function TimelinePlayhead({ aria-valuemax={duration} aria-valuenow={playheadPosition} tabIndex={0} - className="pointer-events-auto absolute z-60" + className="pointer-events-none absolute z-5" style={{ left: `${leftPosition}px`, top: 0, height: `${totalHeight}px`, width: "2px", }} - onMouseDown={handlePlayheadMouseDown} onKeyDown={handlePlayheadKeyDown} > - <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> + <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" /> - <div - className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + <button + type="button" + aria-label="Drag playhead" + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + onMouseDown={handlePlayheadMouseDown} /> </div> ); diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 0b02c63f..03ce88eb 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -171,7 +171,7 @@ function SceneSelector() { <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> <SplitButtonSeparator /> <ScenesView> - <SplitButtonRight onClick={() => {}} type="button"> + <SplitButtonRight onClick={() => {}}> <HugeiconsIcon icon={Layers01Icon} className="size-4" /> </SplitButtonRight> </ScenesView> @@ -222,7 +222,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "out" })} > <HugeiconsIcon icon={SearchMinusIcon} /> @@ -240,7 +239,6 @@ function ToolbarRightSection({ <Button variant="text" size="icon" - type="button" onClick={() => onZoom({ direction: "in" })} > <HugeiconsIcon icon={SearchAddIcon} /> @@ -269,7 +267,6 @@ function ToolbarButton({ <Button variant={isActive ? "secondary" : "text"} size="icon" - type="button" onClick={(event) => onClick({ event })} className={cn( "rounded-sm", |
