diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/assets/views | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views')
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/assets.tsx | 153 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/base-panel.tsx (renamed from apps/web/src/components/editor/panels/assets/views/base-view.tsx) | 11 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/captions.tsx | 141 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/effects.tsx | 15 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx | 332 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings.tsx | 168 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings/background.tsx | 227 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/settings/index.tsx | 369 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/sounds.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/stickers.tsx | 538 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/text.tsx | 12 |
11 files changed, 1129 insertions, 839 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 3b35c022..e17e4d4a 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -2,11 +2,11 @@ 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 { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import { ContextMenu, ContextMenuContent, @@ -28,8 +28,15 @@ import { import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; -import { useRevealItem } from "@/hooks/use-reveal-item"; +import { invokeAction } from "@/lib/actions"; import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { + SelectableItem, + SelectableSurface, + useSelection, + useSelectionScope, +} from "@/lib/selection"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { type MediaSortKey, @@ -37,7 +44,8 @@ import { type MediaViewMode, useAssetsPanelStore, } from "@/stores/assets-panel-store"; -import type { MediaAsset } from "@/types/assets"; +import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; export function MediaView() { const editor = useEditor(); - const mediaFiles = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const mediaFiles = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { mediaViewMode, @@ -64,15 +72,11 @@ export function MediaView() { mediaSortOrder, setMediaSort, } = useAssetsPanelStore(); - const { highlightedId, registerElement } = useRevealItem( - highlightMediaId, - clearHighlight, - ); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const processFiles = async ({ files }: { files: FileList }) => { + const processFiles = async ({ files }: { files: File[] }) => { if (!files || files.length === 0) return; if (!activeProject) { toast.error("No active project"); @@ -82,20 +86,28 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - const processedAssets = await processMediaAssets({ - files, - onProgress: (progress: { progress: number }) => - setProgress(progress.progress), + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ + files, + onProgress: (progress: { progress: number }) => + setProgress(progress.progress), + }); + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + } + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, }); - for (const asset of processedAssets) { - await editor.media.addMediaAsset({ - projectId: activeProject.metadata.id, - asset, - }); - } } catch (error) { console.error("Error processing files:", error); - toast.error("Failed to process files"); } finally { setIsProcessing(false); setProgress(0); @@ -109,23 +121,18 @@ export function MediaView() { onFilesSelected: (files) => processFiles({ files }), }); - const handleRemove = async ({ + const handleRemove = ({ event, - id, + ids, }: { event: React.MouseEvent; - id: string; + ids: string[]; }) => { event.stopPropagation(); - if (!activeProject) { - toast.error("No active project"); - return; - } - - await editor.media.removeMediaAsset({ + invokeAction("remove-media-assets", { projectId: activeProject.metadata.id, - id, + assetIds: ids, }); }; @@ -172,6 +179,9 @@ export function MediaView() { return filtered; }, [mediaFiles, mediaSortBy, mediaSortOrder]); + const orderedMediaIds = useMemo(() => { + return filteredMediaItems.map((item) => item.id); + }, [filteredMediaItems]); return ( <> @@ -191,6 +201,7 @@ export function MediaView() { /> } className={cn(isDragOver && "bg-accent/30")} + contentClassName="h-full" {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -201,29 +212,38 @@ export function MediaView() { onClick={openFilePicker} /> ) : ( - <MediaItemList - items={filteredMediaItems} - mode={mediaViewMode} - onRemove={handleRemove} - highlightedId={highlightedId} - registerElement={registerElement} - /> + <SelectableSurface + ariaLabel="Assets" + orderedIds={orderedMediaIds} + revealId={highlightMediaId} + onRevealComplete={clearHighlight} + > + <MediaScopeRegistrar /> + <MediaItemList + items={filteredMediaItems} + mode={mediaViewMode} + onRemove={handleRemove} + /> + </SelectableSurface> )} </PanelView> </> ); } +function MediaScopeRegistrar() { + useSelectionScope(); + return null; +} + function MediaAssetDraggable({ item, preview, - isHighlighted, variant, isRounded, }: { item: MediaAsset; preview: React.ReactNode; - isHighlighted: boolean; variant: "card" | "compact"; isRounded?: boolean; }) { @@ -261,7 +281,7 @@ function MediaAssetDraggable({ mediaType: item.type, name: item.name, ...(item.type !== "audio" && { - targetElementTypes: ["video", "image"] as const, + targetElementTypes: [...MASKABLE_ELEMENT_TYPES], }), }} shouldShowPlusOnDrag={false} @@ -270,7 +290,6 @@ function MediaAssetDraggable({ } variant={variant} isRounded={isRounded} - isHighlighted={isHighlighted} /> ); } @@ -282,18 +301,31 @@ function MediaItemWithContextMenu({ }: { item: MediaAsset; children: React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { + const { isSelected, selectedIds } = useSelection(); + const idsToDelete = isSelected(item.id) ? selectedIds : [item.id]; + const deleteLabel = + idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete"; + return ( <ContextMenu> - <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem>Export clips</ContextMenuItem> <ContextMenuItem variant="destructive" - onClick={(event) => onRemove({ event, id: item.id })} + onClick={(event: React.MouseEvent<HTMLDivElement>) => + onRemove({ event, ids: idsToDelete }) + } > - Delete + {deleteLabel} </ContextMenuItem> </ContextMenuContent> </ContextMenu> @@ -304,27 +336,29 @@ function MediaItemList({ items, mode, onRemove, - highlightedId, - registerElement, }: { items: MediaAsset[]; mode: MediaViewMode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { const isGrid = mode === "grid"; return ( <div - className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")} + className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")} style={ isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined } > {items.map((item) => ( - <div key={item.id} ref={(element) => registerElement(item.id, element)}> - <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}> + <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}> <MediaAssetDraggable item={item} preview={ @@ -335,16 +369,15 @@ function MediaItemList({ } variant={isGrid ? "card" : "compact"} isRounded={isGrid ? false : undefined} - isHighlighted={highlightedId === item.id} /> - </MediaItemWithContextMenu> - </div> + </SelectableItem> + </MediaItemWithContextMenu> ))} </div> ); } -export function formatDuration({ duration }: { duration: number }) { +function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; @@ -406,7 +439,7 @@ function MediaPreview({ if (item.type === "image") { return ( - <div className="relative flex size-full items-center justify-center"> + <div className="relative flex size-full items-center justify-center bg-muted"> <Image src={item.url ?? ""} alt={item.name} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx index c613bc2a..0ece9b19 100644 --- a/apps/web/src/components/editor/panels/assets/views/base-view.tsx +++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx @@ -5,6 +5,7 @@ interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { actions?: React.ReactNode; children: React.ReactNode; contentClassName?: string; + scrollClassName?: string; hideHeader?: boolean; ref?: React.Ref<HTMLDivElement>; onScroll?: React.UIEventHandler<HTMLDivElement>; @@ -17,6 +18,7 @@ export function PanelView({ children, className, contentClassName, + scrollClassName, hideHeader = false, ref, onScroll, @@ -30,15 +32,18 @@ export function PanelView({ {...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> + <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b"> + {title && ( + <span className="text-muted-foreground text-sm">{title}</span> + )} {actions} </div> )} <div className={cn( - "scrollbar-thin size-full overflow-y-auto", + "scrollbar-hidden size-full overflow-y-auto", hideHeader ? "pt-4" : "pt-2", + scrollClassName, )} ref={scrollRef} onScroll={onScroll} 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 a73fd693..023e1578 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 { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, SelectContent, @@ -10,7 +10,6 @@ import { import { useState, useRef } from "react"; import { extractTimelineAudio } from "@/lib/media/mediabunny"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE, TRANSCRIPTION_LANGUAGES, @@ -18,12 +17,23 @@ import { import type { TranscriptionLanguage, TranscriptionProgress, -} from "@/types/transcription"; +} from "@/lib/transcription/types"; import { transcriptionService } from "@/services/transcription/service"; import { decodeAudioToFloat32 } from "@/lib/media/audio"; import { buildCaptionChunks } from "@/lib/transcription/caption"; import { Spinner } from "@/components/ui/spinner"; -import { Label } from "@/components/ui/label"; +import { + Section, + SectionContent, + SectionField, + SectionFields, +} from "@/components/section"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + AddTrackCommand, + BatchCommand, + InsertElementCommand, +} from "@/lib/commands"; export function Captions() { const [selectedLanguage, setSelectedLanguage] = @@ -69,26 +79,29 @@ export function Captions() { setProcessingStep("Generating captions..."); const captionChunks = buildCaptionChunks({ segments: result.segments }); - const captionTrackId = editor.timeline.addTrack({ - type: "text", - index: 0, - }); + const addTrackCommand = new AddTrackCommand("text", 0); + const insertCommands = captionChunks.map( + (caption, i) => + new InsertElementCommand({ + placement: { + mode: "explicit", + trackId: addTrackCommand.getTrackId(), + }, + element: { + ...DEFAULTS.text.element, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }), + ); - for (let i = 0; i < captionChunks.length; i++) { - const caption = captionChunks[i]; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: captionTrackId }, - element: { - ...DEFAULT_TEXT_ELEMENT, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, - }); - } + editor.command.execute({ + command: new BatchCommand([addTrackCommand, ...insertCommands]), + }); } catch (error) { console.error("Transcription failed:", error); setError( @@ -114,43 +127,53 @@ export function Captions() { }; return ( - <PanelView title="Captions" ref={containerRef}> - <div className="flex flex-col gap-3"> - <Label>Language</Label> - <Select - value={selectedLanguage} - onValueChange={(value) => handleLanguageChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select a language" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="auto">Auto detect</SelectItem> - {TRANSCRIPTION_LANGUAGES.map((language) => ( - <SelectItem key={language.code} value={language.code}> - {language.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="flex flex-col gap-4"> - {error && ( - <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> - <p className="text-destructive text-sm">{error}</p> - </div> - )} + <PanelView + title="Captions" + contentClassName="px-0 flex flex-col h-full" + ref={containerRef} + > + <Section showTopBorder={false} showBottomBorder={false} className="flex-1"> + <SectionContent className="flex flex-col gap-4 h-full pt-1"> + <SectionFields> + <SectionField label="Language"> + <Select + value={selectedLanguage} + onValueChange={(value) => handleLanguageChange({ value })} + > + <SelectTrigger> + <SelectValue placeholder="Select a language" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="auto">Auto detect</SelectItem> + {TRANSCRIPTION_LANGUAGES.map((language) => ( + <SelectItem key={language.code} value={language.code}> + {language.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </SectionField> + </SectionFields> - <Button - className="w-full" - onClick={handleGenerateTranscript} - disabled={isProcessing} - > - {isProcessing && <Spinner className="mr-1" />} - {isProcessing ? processingStep : "Generate transcript"} - </Button> - </div> + {error && ( + <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> + <p className="text-destructive text-sm">{error}</p> + </div> + )} + </SectionContent> + </Section> + <Section showBottomBorder={false} showTopBorder={false}> + <SectionContent> + <Button + className="w-full" + onClick={handleGenerateTranscript} + disabled={isProcessing} + > + {isProcessing && <Spinner className="mr-1" />} + {isProcessing ? processingStep : "Generate transcript"} + </Button> + </SectionContent> + </Section> </PanelView> ); } diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx index c9b8b2dd..74128df6 100644 --- a/apps/web/src/components/editor/panels/assets/views/effects.tsx +++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx @@ -1,19 +1,16 @@ "use client"; import { useEffect, useRef, useCallback } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; -import { - effectPreviewService, - onPreviewImageReady, -} from "@/services/renderer/effect-preview"; +import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects"; +import { effectPreviewService } from "@/services/renderer/effect-preview"; import { useEditor } from "@/hooks/use-editor"; import { buildEffectElement } from "@/lib/timeline/element-utils"; -import type { EffectDefinition } from "@/types/effects"; +import type { EffectDefinition } from "@/lib/effects/types"; export function EffectsView() { - const effects = getAllEffects(); + const effects = effectsRegistry.getAll(); return ( <PanelView title="Effects"> @@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { }; render(); - return onPreviewImageReady({ callback: render }); + return effectPreviewService.onPreviewImageReady({ callback: render }); }, [effectType]); return <canvas ref={canvasRef} className="size-full" />; 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 deleted file mode 100644 index 457c08a9..00000000 --- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx +++ /dev/null @@ -1,332 +0,0 @@ -// @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 deleted file mode 100644 index d46e9d1f..00000000 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -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 { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/editor/panels/properties/section"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; - -const ORIGINAL_PRESET_VALUE = "original"; - -export function 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; -} - -export function SettingsView() { - return ( - <PanelView contentClassName="px-0" hideHeader> - <div className="flex flex-col"> - <Section showTopBorder={false}> - <SectionContent> - <ProjectInfoContent /> - </SectionContent> - </Section> - <Popover> - <Section className="cursor-pointer"> - <PopoverTrigger asChild> - <div> - <SectionHeader - trailing={<div className="size-4 rounded-sm bg-red-500" />} - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - </div> - </PopoverTrigger> - </Section> - <PopoverContent> - <div className="size-4 rounded-sm bg-red-500" /> - </PopoverContent> - </Popover> - </div> - </PanelView> - ); -} - -function ProjectInfoContent() { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - const { canvasPresets } = useEditorStore(); - - const currentCanvasSize = activeProject.settings.canvasSize; - const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); - const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; - const presetIndex = findPresetIndexByAspectRatio({ - presets: canvasPresets, - targetAspectRatio: currentAspectRatio, - }); - const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; - - const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === ORIGINAL_PRESET_VALUE) { - 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 }: { value: string }) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }; - - return ( - <div className="flex flex-col gap-4"> - <div className="flex flex-col gap-2"> - <Label>Name</Label> - <span className="leading-none text-sm"> - {activeProject.metadata.name} - </span> - </div> - <div className="flex flex-col gap-2"> - <Label>Aspect ratio</Label> - <Select - value={selectedPresetValue} - onValueChange={(value) => handleAspectRatioChange({ value })} - > - <SelectTrigger className="w-fit"> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - <SelectItem value={ORIGINAL_PRESET_VALUE}>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={(value) => handleFpsChange({ value })} - > - <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/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx new file mode 100644 index 00000000..b3ef1eb1 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, +} 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 { effectPreviewService } from "@/services/renderer/effect-preview"; +import type { TCanvasSize } from "@/lib/project/types"; +import { cn } from "@/utils/ui"; + +const BlurPreview = memo( + ({ + blur, + canvasSize, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + canvasSize: TCanvasSize; + isSelected: boolean; + onSelect: () => void; + }) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const { width, height } = canvasSize; + + useEffect(() => { + const renderPreview = () => { + if (!canvasRef.current) return; + + effectPreviewService.renderPreview({ + effectType: "blur", + params: { intensity: blur.value }, + targetCanvas: canvasRef.current, + uniformDimensions: { width, height }, + }); + }; + + renderPreview(); + return effectPreviewService.onPreviewImageReady({ callback: renderPreview }); + }, [blur.value, width, height]); + + return ( + <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`} + > + <canvas + ref={canvasRef} + className="absolute inset-0 h-full w-full object-cover" + /> + <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, + onSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + onSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + <button + key={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={() => onSelect(bg)} + type="button" + aria-label={`Select background ${bg}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + onSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +const COLOR_SECTIONS = [ + { title: "Colors", backgrounds: colors, useBackgroundColor: true }, + { title: "Pattern craft", backgrounds: patternCraftGradients }, + { title: "Syntax UI", backgrounds: syntaxUIGradients }, +] as const; + +export function BackgroundContent() { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + + const handleBlurSelect = useCallback( + async (blurIntensity: number) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async (color: string) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const currentBlurIntensity = isBlurBackground + ? (activeProject.settings.background as { blurIntensity: number }) + .blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = isColorBackground + ? (activeProject.settings.background as { color: string }).color + : DEFAULT_COLOR; + const canvasSize = activeProject.settings.canvasSize; + + const blurPreviews = useMemo( + () => + BLUR_INTENSITY_PRESETS.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + canvasSize={canvasSize} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect(blur.value)} + /> + )), + [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + return ( + <div className="flex flex-col"> + <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}> + <SectionHeader> + <SectionTitle>Blur</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex flex-wrap gap-2">{blurPreviews}</div> + </SectionContent> + </Section> + {COLOR_SECTIONS.map((section) => ( + <Section + key={section.title} + collapsible + defaultOpen={false} + sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`} + > + <SectionHeader> + <SectionTitle>{section.title}</SectionTitle> + </SectionHeader> + <SectionContent> + <div className="flex flex-wrap gap-2"> + <BackgroundPreviews + backgrounds={section.backgrounds as string[]} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + onSelect={handleColorSelect} + useBackgroundColor={ + "useBackgroundColor" in section + ? section.useBackgroundColor + : false + } + /> + </div> + </SectionContent> + </Section> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx new file mode 100644 index 00000000..e0229ea9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { useRef, useState } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FPS_PRESETS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { BackgroundContent } from "./background"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { useEditorStore } from "@/stores/editor-store"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Tick02Icon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { formatNumberForDisplay } from "@/utils/math"; +import { OcSquarePlusIcon } from "@opencut/ui/icons"; +import type { TCanvasSize } from "@/lib/project/types"; + +type SettingsView = "project-info" | "background"; + +const PRESET_LABELS: Record<string, string> = { + "1:1": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "4:3": "4:3", +}; + +function areCanvasSizesEqual({ + left, + right, +}: { + left: TCanvasSize; + right: TCanvasSize; +}) { + return left.width === right.width && left.height === right.height; +} + +function formatCanvasDimension({ value }: { value: number }) { + return formatNumberForDisplay({ value, maxFractionDigits: 0 }); +} + +function parseCanvasDimension({ input }: { input: string }): number | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + + const rounded = Math.round(parsed); + return rounded > 0 ? rounded : null; +} + +function useCanvasDimensionDraft({ + value, + onCommit, +}: { + value: number; + onCommit: (value: number) => void; +}) { + const pendingValueRef = useRef(value); + const syncedValueRef = useRef(value); + + if (syncedValueRef.current !== value) { + syncedValueRef.current = value; + pendingValueRef.current = value; + } + + return usePropertyDraft({ + displayValue: formatCanvasDimension({ value }), + parse: (input) => parseCanvasDimension({ input }), + onPreview: (nextValue) => { + pendingValueRef.current = nextValue; + }, + onCommit: () => { + if (pendingValueRef.current !== value) { + onCommit(pendingValueRef.current); + } + }, + }); +} + +export function SettingsView() { + const [view, setView] = useState<SettingsView>("project-info"); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const { canvasPresets } = useEditorStore(); + const currentCanvasSize = activeProject.settings.canvasSize; + const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; + const lastCustomCanvasSize = + activeProject.settings.lastCustomCanvasSize ?? null; + + const presetItems = canvasPresets.map((preset, index) => { + const ratio = dimensionToAspectRatio(preset); + return { + id: index.toString(), + label: PRESET_LABELS[ratio] ?? ratio, + ratio, + canvasSize: preset, + }; + }); + + const selectedPresetId = canvasSizeMode === "preset" + ? (presetItems.find((preset) => + areCanvasSizesEqual({ + left: preset.canvasSize, + right: currentCanvasSize, + }), + )?.id ?? null) + : null; + + const updateCustomCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateLastCustomCanvasSize = + lastCustomCanvasSize === null || + !areCanvasSizesEqual({ + left: canvasSize, + right: lastCustomCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; + + if ( + !shouldUpdateCanvasSize && + !shouldUpdateLastCustomCanvasSize && + !shouldUpdateCanvasSizeMode + ) { + return; + } + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "custom" as const } + : {}), + lastCustomCanvasSize: canvasSize, + }, + }); + }; + + const selectPresetCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; + + if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "preset" as const } + : {}), + }, + }); + }; + + const selectCustomCanvasSize = () => { + updateCustomCanvasSize({ + canvasSize: lastCustomCanvasSize ?? currentCanvasSize, + }); + }; + + const widthDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.width, + onCommit: (width) => + updateCustomCanvasSize({ + canvasSize: { width, height: currentCanvasSize.height }, + }), + }); + + const heightDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.height, + onCommit: (height) => + updateCustomCanvasSize({ + canvasSize: { width: currentCanvasSize.width, height }, + }), + }); + + const isCustomSelected = canvasSizeMode === "custom"; + + return ( + <PanelView + contentClassName="px-0" + scrollClassName="pt-0" + actions={ + <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <TabsList> + <TabsTrigger value="project-info">Project info</TabsTrigger> + <TabsTrigger value="background">Background</TabsTrigger> + </TabsList> + </Tabs> + } + > + {view === "project-info" && ( + <div className="flex flex-col"> + <Section showTopBorder={false}> + <SectionHeader> + <SectionTitle className="flex-1">Name</SectionTitle> + <span className="text-sm truncate"> + {activeProject.metadata.name} + </span> + </SectionHeader> + </Section> + <Section showTopBorder={false}> + <SectionHeader className="justify-between"> + <SectionTitle className="flex-1">Frame rate</SectionTitle> + <Select + value={activeProject.settings.fps.toString()} + onValueChange={(value) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }} + > + <SelectTrigger className="bg-transparent border-none p-1 h-auto"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </SectionHeader> + </Section> + <Section + showTopBorder={false} + collapsible + sectionKey="settings:aspect-ratio" + > + <SectionHeader> + <SectionTitle className="flex-1">Aspect ratio</SectionTitle> + </SectionHeader> + <SectionContent className="px-2 flex flex-col gap-1 pb-2"> + {presetItems.map((preset) => ( + <AspectRatioItem + key={preset.id} + label={preset.label} + previewIcon={<AspectRatioPreview ratio={preset.ratio} />} + isSelected={selectedPresetId === preset.id} + onClick={() => { + selectPresetCanvasSize({ + canvasSize: preset.canvasSize, + }); + }} + /> + ))} + <div className="pb-2"> + <AspectRatioItem + key="custom" + label="Custom" + previewIcon={<OcSquarePlusIcon />} + isSelected={isCustomSelected} + onClick={selectCustomCanvasSize} + uiOptions={ + <div className=" flex items-center gap-2 text-foreground"> + <NumberField + value={widthDraft.displayValue} + className="w-full" + aria-label="Canvas width" + onFocus={widthDraft.onFocus} + onChange={widthDraft.onChange} + onBlur={widthDraft.onBlur} + /> + <NumberField + value={heightDraft.displayValue} + className="w-full" + aria-label="Canvas height" + onFocus={heightDraft.onFocus} + onChange={heightDraft.onChange} + onBlur={heightDraft.onBlur} + /> + </div> + } + /> + </div> + </SectionContent> + </Section> + </div> + )} + {view === "background" && <BackgroundContent />} + </PanelView> + ); +} + +function AspectRatioItem({ + label, + previewIcon, + isSelected, + onClick, + uiOptions, +}: { + label: string; + previewIcon: React.ReactNode; + isSelected: boolean; + onClick: () => void; + uiOptions?: React.ReactNode; +}) { + return ( + <Button + variant={isSelected ? "secondary" : "ghost"} + className={cn( + "px-2 py-0 flex flex-col h-fit w-full", + !isSelected && "border border-transparent opacity-75!", + )} + onClick={onClick} + > + <div className="w-full flex justify-between items-center h-8"> + <div className="flex-1 flex items-center gap-2"> + <div className="flex items-center justify-center size-5"> + {previewIcon} + </div> + <span className="text-sm truncate">{label}</span> + </div> + <div> + {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} + </div> + </div> + {uiOptions && isSelected && ( + <div className="w-full pb-2">{uiOptions}</div> + )} + </Button> + ); +} + +function AspectRatioPreview({ ratio }: { ratio?: string }) { + if (!ratio) return null; + + const [w, h] = ratio.split(":").map(Number); + const maxSize = 16; + const width = w >= h ? maxSize : (w / h) * maxSize; + const height = h >= w ? maxSize : (h / w) * maxSize; + + return ( + <div + style={{ width, height, borderWidth: 1.5 }} + className="rounded-xs border-current opacity-60" + /> + ); +} 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 b1c57c3d..4fb43ac2 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { useSoundSearch } from "@/hooks/use-sound-search"; import { useSoundsStore } from "@/stores/sounds-store"; -import type { SavedSound, SoundEffect } from "@/types/sounds"; +import type { SavedSound, SoundEffect } from "@/lib/sounds/types"; import { cn } from "@/utils/ui"; import { FavouriteIcon, 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 671effec..af489630 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -2,81 +2,110 @@ import Image from "next/image"; import type { CSSProperties } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, 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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useEditor } from "@/hooks/use-editor"; +import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - resolveStickerId, - type StickerItem as StickerData, + buildGraphicElement, + buildStickerElement, +} from "@/lib/timeline/element-utils"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; +import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; +import type { TimelineDragData } from "@/lib/timeline/drag"; +import type { + StickerBrowseSection, + StickerCategory, + StickerItem as StickerData, } from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; import { cn } from "@/utils/ui"; import { HappyIcon, - ClockIcon, - MultiplicationSignIcon, - Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Spinner } from "@/components/ui/spinner"; -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(); + const { + browseContent, + browseStickers, + searchQuery, + searchStickers, + selectedCategory, + setSearchQuery, + setSelectedCategory, + viewMode, + } = useStickersStore(); + + useEffect(() => { + if (viewMode === "browse" && !browseContent) { + void browseStickers(); + } + }, [browseContent, browseStickers, viewMode]); return ( - <PanelView - title="Stickers" - actions={ - <div className="flex items-center"> - <Select - value={selectedCategory} - onValueChange={(value: StickerCategory) => - setSelectedCategory({ category: value }) - } + <div className="flex h-full flex-col py-2"> + <div className="px-2"> + <Input + size="sm" + variant="default" + placeholder="Search..." + value={searchQuery} + onChange={(e) => { + setSearchQuery({ query: e.target.value }); + void searchStickers({ query: e.target.value }); + }} + showClearIcon + onClear={() => { + setSearchQuery({ query: "" }); + void searchStickers({ query: "" }); + }} + className="w-full" + containerClassName="w-full" + /> + </div> + + <div className="mt-2 flex min-h-0 flex-1 flex-col"> + <div className="border-b border-border px-2"> + <div + role="tablist" + aria-label="Sticker categories" + className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0" > - <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}> + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { + const isActive = key === selectedCategory; + return ( + <button + key={key} + type="button" + role="tab" + aria-selected={isActive} + onClick={() => + setSelectedCategory({ + category: key as StickerCategory, + }) + } + className={cn( + "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px", + isActive && "text-primary border-primary", + )} + > {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> + </button> + ); + })} + </div> </div> - } - > - <StickersContentView /> - </PanelView> + <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> + <StickersContentView /> + </div> + </div> + </div> ); } @@ -92,10 +121,10 @@ function StickerGrid({ "--sticker-max"?: string; } = { 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", - ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), + ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" + : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", + "--sticker-min": "80px", + ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), }; return ( @@ -107,6 +136,18 @@ function StickerGrid({ ); } +function StickerRow({ items }: { items: StickerData[] }) { + return ( + <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> + {items.map((item) => ( + <div key={item.id} className="w-20 shrink-0"> + <StickerItem item={item} shouldCapSize containerClassName="w-full" /> + </div> + ))} + </div> + ); +} + function EmptyView({ message }: { message: string }) { return ( <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> @@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) { ); } +function RegionBanner({ region }: { region: string }) { + return ( + <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className="shrink-0 text-sky-600" + aria-hidden="true" + > + <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> + <circle cx="12" cy="10" r="3" /> + </svg> + <span className="text-xs font-semibold text-sky-600">{region}</span> + </div> + ); +} + function StickersContentView() { const { + browseContent, + clearRecentStickers, + isBrowsing, + isSearching, searchQuery, - viewMode, searchResults, - recentStickers, - isSearching, - clearRecentStickers, + selectedCategory, + setSelectedCategory, + viewMode, } = useStickersStore(); - const itemsToDisplay = useMemo(() => { - if (viewMode === "search" && searchResults) { - return searchResults.items; + if (viewMode === "search") { + if (isSearching) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ); } - return []; - }, [viewMode, searchResults]); + if (searchResults?.items.length) { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const isRegionSearch = + selectedCategory === "flags" && + resolveQueryToRegions({ query: normalizedQuery }) !== null; + const regionLabel = getRegionLabel({ query: normalizedQuery }); - const recentStickerItems = useMemo(() => { - const items: StickerData[] = []; - for (const stickerId of recentStickers) { - const recentStickerItem = toRecentStickerItem({ stickerId }); - if (recentStickerItem) { - items.push(recentStickerItem); - } + return ( + <div className="flex flex-col gap-3 pb-4"> + {isRegionSearch && <RegionBanner region={regionLabel} />} + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> + </div> + <StickerGrid items={searchResults.items} /> + </div> + ); } - return items; - }, [recentStickers]); + + // "all" tab search — sections are in browseContent, fall through to section rendering below + if (selectedCategory !== "all" && searchQuery) { + return <EmptyView message={`No stickers found for "${searchQuery}"`} />; + } + } + + if (isBrowsing && !browseContent) { + return ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ); + } + + if (!browseContent?.sections.length) { + const categoryLabel = STICKER_CATEGORIES[selectedCategory]; + return ( + <EmptyView + message={ + viewMode === "search" + ? `No stickers found for "${searchQuery}"` + : selectedCategory === "all" + ? "No stickers available yet." + : `No stickers available in ${categoryLabel.toLowerCase()} yet.` + } + /> + ); + } return ( - <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" - /> - </button> - </TooltipTrigger> - <TooltipContent> - <p>Clear recent stickers</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + <div className="flex flex-col gap-4 pb-4"> + {browseContent.sections.map((section) => ( + <StickerSection + key={section.id} + section={section} + onClearRecent={clearRecentStickers} + onSeeAll={(category) => { + setSelectedCategory({ category }); + }} + /> + ))} + </div> + ); +} + +function StickerSection({ + section, + onClearRecent, + onSeeAll, +}: { + section: StickerBrowseSection; + onClearRecent: () => void; + onSeeAll: (category: StickerCategory) => void; +}) { + const hasHeader = + Boolean(section.title) || section.id === "recent" || section.action; + + return ( + <div className="flex flex-col gap-2"> + {hasHeader && ( + <div className="flex items-center justify-between gap-2"> + {section.title ? ( + <p className="text-xs text-muted-foreground">{section.title}</p> + ) : ( + <div /> + )} + + <div className="ml-auto flex items-center gap-2"> + {section.id === "recent" && ( + <Button + onClick={onClearRecent} + variant="text" + size="sm" + className="h-auto gap-1 p-0 text-xs text-muted-foreground" + > + Clear + </Button> + )} + + {section.action?.type === "see-all" && section.action.category && ( + <Button + variant="text" + size="sm" + className="h-auto gap-1 p-0 text-xs text-primary" + onClick={() => { + onSeeAll(section.action?.category as StickerCategory); + }} + > + See all + </Button> + )} </div> - <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize /> </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> - <StickerGrid items={itemsToDisplay} shouldCapSize /> - </div> - ) : searchQuery ? ( - <EmptyView message={`No stickers found for "${searchQuery}"`} /> - ) : null} - </div> + {section.layout === "row" ? ( + <StickerRow items={section.items} /> + ) : ( + <StickerGrid items={section.items} /> )} </div> ); @@ -212,65 +335,116 @@ function StickersContentView() { interface StickerItemProps { item: StickerData; shouldCapSize?: boolean; + containerClassName?: string; } -function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { - const { addingSticker, addStickerToTimeline } = useStickersStore(); - const isAdding = addingSticker === item.id; +function StickerItem({ + item, + shouldCapSize = false, + containerClassName, +}: StickerItemProps) { + const editor = useEditor(); + const { addToRecentStickers } = useStickersStore(); + const [isAdding, setIsAdding] = useState(false); const [hasImageError, setHasImageError] = useState(false); useEffect(() => { if (!item.id) { return; } + setHasImageError(false); }, [item.id]); const displayName = item.name; + const shapePreset = + item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; const handleAdd = async () => { + setIsAdding(true); try { - await addStickerToTimeline({ - stickerId: item.id, - name: item.name, + const currentTime = editor.playback.getCurrentTime(); + + let element: + | ReturnType<typeof buildGraphicElement> + | ReturnType<typeof buildStickerElement>; + if (shapePreset) { + element = buildGraphicElement({ + definitionId: shapePreset.definitionId, + name: shapePreset.name, + startTime: currentTime, + params: shapePreset.params, + }); + } else { + const { width: intrinsicWidth, height: intrinsicHeight } = + await resolveStickerIntrinsicSize({ stickerId: item.id }); + element = buildStickerElement({ + stickerId: item.id, + name: item.name, + startTime: currentTime, + intrinsicWidth, + intrinsicHeight, + }); + } + + editor.timeline.insertElement({ + placement: { mode: "auto" }, + element, }); + + addToRecentStickers({ stickerId: item.id }); } catch (error) { console.error("Failed to add sticker:", error); toast.error("Failed to add sticker to timeline"); + } finally { + setIsAdding(false); } }; - 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} - </span> - </div> - ) : ( - <div className="flex size-full items-center justify-center p-4"> - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => { - setHasImageError(true); - }} - loading="lazy" - unoptimized - /> + const preview = ( + <div className="flex size-full items-center justify-center p-3"> + {hasImageError ? ( + <span className="text-muted-foreground text-center text-xs break-all"> + {displayName} + </span> + ) : ( + <Image + src={item.previewUrl} + alt={displayName} + width={64} + height={64} + className="size-full object-contain" + style={ + shouldCapSize + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } + : undefined + } + onError={() => setHasImageError(true)} + loading="lazy" + unoptimized + /> + )} </div> ); + const dragData: TimelineDragData = shapePreset + ? { + id: item.id, + type: "graphic", + name: displayName, + definitionId: shapePreset.definitionId, + params: shapePreset.params ?? {}, + } + : { + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }; + return ( <div className={cn("relative", isAdding && "pointer-events-none opacity-50")} @@ -278,18 +452,13 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { <DraggableItem name={displayName} preview={preview} - dragData={{ - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }} + dragData={dragData} onAddToTimeline={handleAdd} aspectRatio={1} shouldShowLabel={false} isRounded variant="card" - containerClassName="w-full" + containerClassName={containerClassName ?? "w-full"} /> {isAdding && ( <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> @@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { </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 3673acc3..075cc25b 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,7 +1,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; import { buildTextElement } from "@/lib/timeline/element-utils"; export function TextView() { @@ -12,7 +12,7 @@ export function TextView() { if (!activeScene) return; const element = buildTextElement({ - raw: DEFAULT_TEXT_ELEMENT, + raw: DEFAULTS.text.element, startTime: currentTime, }); @@ -33,9 +33,9 @@ export function TextView() { } dragData={{ id: "temp-text-id", - type: DEFAULT_TEXT_ELEMENT.type, - name: DEFAULT_TEXT_ELEMENT.name, - content: DEFAULT_TEXT_ELEMENT.content, + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: DEFAULTS.text.element.content, }} aspectRatio={1} onAddToTimeline={handleAddToTimeline} |
