From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../editor/panels/assets/drag-overlay.tsx | 2 +- .../editor/panels/assets/draggable-item.tsx | 17 +- .../src/components/editor/panels/assets/tabbar.tsx | 15 +- .../editor/panels/assets/views/assets.tsx | 153 +++--- .../editor/panels/assets/views/base-panel.tsx | 57 +++ .../editor/panels/assets/views/base-view.tsx | 52 -- .../editor/panels/assets/views/captions.tsx | 141 +++--- .../editor/panels/assets/views/effects.tsx | 15 +- .../editor/panels/assets/views/settings-legacy.tsx | 332 ------------- .../editor/panels/assets/views/settings.tsx | 168 ------- .../panels/assets/views/settings/background.tsx | 227 +++++++++ .../editor/panels/assets/views/settings/index.tsx | 369 ++++++++++++++ .../editor/panels/assets/views/sounds.tsx | 2 +- .../editor/panels/assets/views/stickers.tsx | 538 +++++++++++++-------- .../components/editor/panels/assets/views/text.tsx | 12 +- 15 files changed, 1192 insertions(+), 908 deletions(-) create mode 100644 apps/web/src/components/editor/panels/assets/views/base-panel.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/base-view.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/settings.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings/background.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings/index.tsx (limited to 'apps/web/src/components/editor/panels/assets') diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx index c6b0861c..9d9ae903 100644 --- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -29,7 +29,7 @@ export function MediaDragOverlay({ return ( - + {error && ( +
+

{error}

+
+ )} + + +
+ + + +
); } 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 ( @@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) { }; render(); - return onPreviewImageReady({ callback: render }); + return effectPreviewService.onPreviewImageReady({ callback: render }); }, [effectType]); return ; 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 ( - -
- - -
-
- ); -} - -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 ( -
- - Name - {activeProject.metadata.name} - - - - Aspect ratio - - - - - - - Frame rate - - - - -
- ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - - ), -); - -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) => ( - + ); + }, +); + +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) => ( + + ); +} + +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 ( +
+ ); +} 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 ( - - { + setSearchQuery({ query: e.target.value }); + void searchStickers({ query: e.target.value }); + }} + showClearIcon + onClear={() => { + setSearchQuery({ query: "" }); + void searchStickers({ query: "" }); + }} + className="w-full" + containerClassName="w-full" + /> +
+ +
+
+
- - - - - {Object.entries(STICKER_CATEGORIES).map(([category, label]) => ( - + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { + const isActive = key === selectedCategory; + return ( + - - + + ); + })} +
- } - > - - +
+ +
+
+ ); } @@ -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 ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} + function EmptyView({ message }: { message: string }) { return (
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) { ); } +function RegionBanner({ region }: { region: string }) { + return ( +
+ + {region} +
+ ); +} + 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 ( +
+ +
+ ); } - 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 ( +
+ {isRegionSearch && } +
+ + {searchResults.total} results + +
+ +
+ ); } - return items; - }, [recentStickers]); + + // "all" tab search — sections are in browseContent, fall through to section rendering below + if (selectedCategory !== "all" && searchQuery) { + return ; + } + } + + if (isBrowsing && !browseContent) { + return ( +
+ +
+ ); + } + + if (!browseContent?.sections.length) { + const categoryLabel = STICKER_CATEGORIES[selectedCategory]; + return ( + + ); + } return ( -
- {recentStickerItems.length > 0 && viewMode === "browse" && ( -
-
- - Recent - - - - - - -

Clear recent stickers

-
-
-
+
+ {browseContent.sections.map((section) => ( + { + setSelectedCategory({ category }); + }} + /> + ))} +
+ ); +} + +function StickerSection({ + section, + onClearRecent, + onSeeAll, +}: { + section: StickerBrowseSection; + onClearRecent: () => void; + onSeeAll: (category: StickerCategory) => void; +}) { + const hasHeader = + Boolean(section.title) || section.id === "recent" || section.action; + + return ( +
+ {hasHeader && ( +
+ {section.title ? ( +

{section.title}

+ ) : ( +
+ )} + +
+ {section.id === "recent" && ( + + )} + + {section.action?.type === "see-all" && section.action.category && ( + + )}
-
)} - {viewMode === "search" && ( -
- {isSearching ? ( -
- -
- ) : searchResults?.items.length ? ( -
-
- - {searchResults.total} results - -
- -
- ) : searchQuery ? ( - - ) : null} -
+ {section.layout === "row" ? ( + + ) : ( + )}
); @@ -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 + | ReturnType; + 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 ? ( -
- - {displayName} - -
- ) : ( -
- {displayName} { - setHasImageError(true); - }} - loading="lazy" - unoptimized - /> + const preview = ( +
+ {hasImageError ? ( + + {displayName} + + ) : ( + {displayName} setHasImageError(true)} + loading="lazy" + unoptimized + /> + )}
); + 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 (
{isAdding && (
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
); } - -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} -- cgit v1.3.1