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/settings | |
| 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/settings')
| -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 |
2 files changed, 596 insertions, 0 deletions
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" + /> + ); +} |
