From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- .../editor/panels/assets/draggable-item.tsx | 4 +- .../src/components/editor/panels/assets/index.tsx | 2 +- .../src/components/editor/panels/assets/tabbar.tsx | 3 +- .../editor/panels/assets/views/assets.tsx | 637 +++++++++++++++++++ .../editor/panels/assets/views/base-view.tsx | 52 ++ .../editor/panels/assets/views/captions.tsx | 9 +- .../editor/panels/assets/views/media.tsx | 675 -------------------- .../editor/panels/assets/views/settings-legacy.tsx | 332 ++++++++++ .../editor/panels/assets/views/settings.tsx | 354 +++-------- .../editor/panels/assets/views/sounds.tsx | 2 +- .../editor/panels/assets/views/stickers.tsx | 691 ++++++--------------- .../components/editor/panels/assets/views/text.tsx | 6 +- 12 files changed, 1322 insertions(+), 1445 deletions(-) create mode 100644 apps/web/src/components/editor/panels/assets/views/assets.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/base-view.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/media.tsx create mode 100644 apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx (limited to 'apps/web/src/components/editor/panels/assets') diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 517fdc92..be4176ce 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -163,7 +163,9 @@ export function DraggableItem({
{preview}
- {name} + + {name} + )} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index e7bd3d4f..3600d00b 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; import { TabBar } from "./tabbar"; import { Captions } from "./views/captions"; -import { MediaView } from "./views/media"; +import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; import { SoundsView } from "./views/sounds"; import { StickersView } from "./views/stickers"; diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index 31842306..61777a7c 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -61,7 +61,8 @@ export function TabBar() { aria-label={tab.label} className={cn( "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5", - activeTab !== tabKey && "border border-transparent text-muted-foreground", + activeTab !== tabKey && + "border border-transparent text-muted-foreground", )} onClick={() => setActiveTab(tabKey)} > diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx new file mode 100644 index 00000000..808961f1 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -0,0 +1,637 @@ +"use client"; + +import Image from "next/image"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +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 { processMediaAssets } from "@/lib/media/processing"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import type { MediaAsset } from "@/types/assets"; +import { cn } from "@/utils/ui"; +import { + CloudUploadIcon, + GridViewIcon, + LeftToRightListDashIcon, + SortingOneNineIcon, + Image02Icon, + MusicNote03Icon, + Video01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export function MediaView() { + const editor = useEditor(); + const mediaFiles = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = + useAssetsPanelStore(); + const { highlightedId, registerElement } = useRevealItem( + highlightMediaId, + clearHighlight, + ); + + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( + "name", + ); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + + const processFiles = async ({ files }: { files: FileList }) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + 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, + }); + } + } catch (error) { + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps, openFilePicker, fileInputProps } = + useFileUpload({ + accept: "image/*,video/*,audio/*", + multiple: true, + onFilesSelected: (files) => processFiles({ files }), + }); + + const handleRemove = async ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => { + event.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + await editor.media.removeMediaAsset({ + projectId: activeProject.metadata.id, + id, + }); + }; + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }): boolean => { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: asset.id, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + return true; + }; + + const filteredMediaItems = useMemo(() => { + const filtered = mediaFiles.filter((item) => !item.ephemeral); + + filtered.sort((a, b) => { + let valueA: string | number; + let valueB: string | number; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + + return filtered; + }, [mediaFiles, sortBy, sortOrder]); + + const previewComponents = useMemo(() => { + const previews = new Map(); + + filteredMediaItems.forEach((item) => { + previews.set(item.id, ); + previews.set( + `compact-${item.id}`, + , + ); + }); + + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); + const renderCompactPreview = (item: MediaAsset) => + previewComponents.get(`compact-${item.id}`); + + const mediaActions = ( +
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+ + + + + + + + + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+
+ +
+ ); + + return ( + <> + + + + {isDragOver || filteredMediaItems.length === 0 ? ( + + ) : mediaViewMode === "grid" ? ( + + ) : ( + + )} + + + ); +} + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaAsset; + children: React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; +}) { + return ( + + {children} + + Export clips + onRemove({ event, id: item.id })} + > + Delete + + + + ); +} + +function GridView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( +
+ {items.map((item) => ( +
registerElement(item.id, el)}> + + + onAddToTimeline({ asset: item, startTime: currentTime }) + } + isRounded={false} + variant="card" + isHighlighted={highlightedId === item.id} + /> + +
+ ))} +
+ ); +} + +function ListView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( +
+ {items.map((item) => ( +
registerElement(item.id, el)}> + + + onAddToTimeline({ asset: item, startTime: currentTime }) + } + variant="compact" + isHighlighted={highlightedId === item.id} + /> + +
+ ))} +
+ ); +} + +const formatDuration = ({ duration }: { duration: number }) => { + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; +}; + +function MediaDurationBadge({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( +
+ {formatDuration({ duration })} +
+ ); +} + +function MediaDurationLabel({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + {formatDuration({ duration })} + ); +} + +function MediaTypePlaceholder({ + icon, + label, + duration, + variant, +}: { + icon: IconSvgElement; + label: string; + duration?: number; + variant: "muted" | "bordered"; +}) { + const iconClassName = cn("size-6", variant === "bordered" && "mb-1"); + + return ( +
+ + {label} + +
+ ); +} + +function MediaPreview({ + item, + variant = "grid", +}: { + item: MediaAsset; + variant?: "grid" | "compact"; +}) { + const shouldShowDurationBadge = variant === "grid"; + + if (item.type === "image") { + return ( +
+ {item.name} +
+ ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( +
+ {item.name} + {shouldShowDurationBadge ? ( + + ) : null} +
+ ); + } + + return ( + + ); + } + + if (item.type === "audio") { + return ( + + ); + } + + return ( + + ); +} + +function SortMenuItem({ + label, + sortKey, + currentSortBy, + currentSortOrder, + onSort, +}: { + label: string; + sortKey: "name" | "type" | "duration" | "size"; + currentSortBy: string; + currentSortOrder: "asc" | "desc"; + onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; +}) { + const isActive = currentSortBy === sortKey; + const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; + + return ( + onSort({ key: sortKey })}> + {label} {arrow} + + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx new file mode 100644 index 00000000..c613bc2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx @@ -0,0 +1,52 @@ +import { cn } from "@/utils/ui"; + +interface PanelViewProps extends React.HTMLAttributes { + title?: string; + actions?: React.ReactNode; + children: React.ReactNode; + contentClassName?: string; + hideHeader?: boolean; + ref?: React.Ref; + onScroll?: React.UIEventHandler; + scrollRef?: React.Ref; +} + +export function PanelView({ + title, + actions, + children, + className, + contentClassName, + hideHeader = false, + ref, + onScroll, + scrollRef, + ...rest +}: PanelViewProps) { + return ( +
+ {!hideHeader && ( +
+ {title} + {actions} +
+ )} +
+
+ {children} +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index daebf2c6..7a3b16bf 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { Select, SelectContent, @@ -108,10 +108,7 @@ export function Captions() { }; return ( - +
- -
-
- Assets -
- - - - - - -

- {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} -

-
- - - - - - - - - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - - - -

- Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) -

-
-
-
-
- -
-
- -
-
- {isDragOver || filteredMediaItems.length === 0 ? ( - - ) : mediaViewMode === "grid" ? ( - - ) : ( - - )} -
-
-
- - ); -} - -function MediaItemWithContextMenu({ - item, - children, - onRemove, -}: { - item: MediaAsset; - children: React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; -}) { - return ( - - {children} - - Export clips - onRemove({ event, id: item.id })} - > - Delete - - - - ); -} - -function GridView({ - items, - renderPreview, - onRemove, - onAddToTimeline, - highlightedId, - registerElement, -}: { - items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - return ( -
- {items.map((item) => ( -
registerElement(item.id, el)}> - - - onAddToTimeline({ asset: item, startTime: currentTime }) - } - isRounded={false} - variant="card" - isHighlighted={highlightedId === item.id} - /> - -
- ))} -
- ); -} - -function ListView({ - items, - renderPreview, - onRemove, - onAddToTimeline, - highlightedId, - registerElement, -}: { - items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - onAddToTimeline: ({ - asset, - startTime, - }: { - asset: MediaAsset; - startTime: number; - }) => boolean; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; -}) { - return ( -
- {items.map((item) => ( -
registerElement(item.id, el)}> - - - onAddToTimeline({ asset: item, startTime: currentTime }) - } - variant="compact" - isHighlighted={highlightedId === item.id} - /> - -
- ))} -
- ); -} - -const formatDuration = ({ duration }: { duration: number }) => { - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; -}; - -function MediaDurationBadge({ duration }: { duration?: number }) { - if (!duration) return null; - - return ( -
- {formatDuration({ duration })} -
- ); -} - -function MediaDurationLabel({ duration }: { duration?: number }) { - if (!duration) return null; - - return ( - {formatDuration({ duration })} - ); -} - -function MediaTypePlaceholder({ - icon, - label, - duration, - variant, -}: { - icon: IconSvgElement; - label: string; - duration?: number; - variant: "muted" | "bordered"; -}) { - const iconClassName = cn("size-6", variant === "bordered" && "mb-1"); - - return ( -
- - {label} - -
- ); -} - -function MediaPreview({ - item, - variant = "grid", -}: { - item: MediaAsset; - variant?: "grid" | "compact"; -}) { - const shouldShowDurationBadge = variant === "grid"; - - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } - - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
- {item.name} - {shouldShowDurationBadge ? ( - - ) : null} -
- ); - } - - return ( - - ); - } - - if (item.type === "audio") { - return ( - - ); - } - - return ( - - ); -} - -function SortMenuItem({ - label, - sortKey, - currentSortBy, - currentSortOrder, - onSort, -}: { - label: string; - sortKey: "name" | "type" | "duration" | "size"; - currentSortBy: string; - currentSortOrder: "asc" | "desc"; - onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; -}) { - const isActive = currentSortBy === sortKey; - const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; - - return ( - onSort({ key: sortKey })}> - {label} {arrow} - - ); -} - -function createElementFromMedia({ - asset, - startTime, -}: { - asset: MediaAsset; - startTime: number; -}): CreateTimelineElement { - const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; - - switch (asset.type) { - case "video": - return buildVideoElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "image": - return buildImageElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - case "audio": - return buildUploadAudioElement({ - mediaId: asset.id, - name: asset.name, - duration, - startTime, - }); - default: - throw new Error(`Unsupported media type: ${asset.type}`); - } -} diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx new file mode 100644 index 00000000..457c08a9 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx @@ -0,0 +1,332 @@ +// @ts-nocheck + +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { useEditorStore } from "@/stores/editor-store"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/section"; + +export function SettingsView() { + return ( + +
+ + +
+
+ ); +} + +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) => ( +
- ), - }, - { - value: "background", - label: "Background", - content: ( -
-
- + +
+
+ + + +
+ +
+ +
+ +
+
-
- ), - }, - ]} - className="flex h-full flex-col justify-between p-0" - /> +
+
+ +
+ + +
+ ); } -function ProjectInfoView() { +function ProjectInfoContent() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { canvasPresets } = useEditorStore(); @@ -124,232 +113,55 @@ function ProjectInfoView() { 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) => ( -
); } diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 30c70a83..b1c57c3d 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -235,7 +235,7 @@ function SoundEffectsView() {
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 68ba91d2..671effec 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -4,144 +4,104 @@ import Image from "next/image"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; import { Button } from "@/components/ui/button"; -import { InputWithBack } from "@/components/ui/input-with-back"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import { - buildIconSvgUrl, - getIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; + resolveStickerId, + type StickerItem as StickerData, +} from "@/lib/stickers"; import { useStickersStore } from "@/stores/stickers-store"; -import type { StickerCategory } from "@/types/stickers"; import { cn } from "@/utils/ui"; import { - ArrowRightIcon, HappyIcon, ClockIcon, - LayoutGridIcon, MultiplicationSignIcon, - SparklesIcon, - HashtagIcon, + Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Spinner } from "@/components/ui/spinner"; - -function isStickerCategory(value: string): value is StickerCategory { - return STICKER_CATEGORIES.includes(value as StickerCategory); -} +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { OcSlidersVerticalIcon } from "@opencut/ui/icons"; +import type { StickerCategory } from "@/types/stickers"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { parseStickerId } from "@/lib/stickers/sticker-id"; export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( - { - if (isStickerCategory(v)) { - setSelectedCategory({ category: v }); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: , - content: , - }, - { - value: "general", - label: "Icons", - icon: , - content: , - }, - { - value: "brands", - label: "Brands", - icon: , - content: , - }, - { - value: "emoji", - label: "Emoji", - icon: , - content: , - }, - ]} - className="flex h-full flex-col overflow-hidden p-0" - /> + + + + + + +
+ } + > + +
); } function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, + items, + shouldCapSize = false, }: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; + items: StickerData[]; + shouldCapSize?: boolean; }) { const gridStyle: CSSProperties & { "--sticker-min": string; "--sticker-max"?: string; } = { - gridTemplateColumns: capSize + gridTemplateColumns: shouldCapSize ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", "--sticker-min": "96px", - ...(capSize ? { "--sticker-max": "160px" } : {}), + ...(shouldCapSize ? { "--sticker-max": "160px" } : {}), }; return (
- {icons.map((iconName) => ( - - ))} -
- ); -} - -function CollectionGrid({ - collections, - onSelectCollection, -}: { - collections: Array<{ - prefix: string; - name: string; - total: number; - category?: string; - }>; - onSelectCollection: ({ prefix }: { prefix: string }) => void; -}) { - return ( -
- {collections.map((collection) => ( - onSelectCollection({ prefix: collection.prefix })} - /> + {items.map((item) => ( + ))}
); @@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) { ); } -function StickersContentView({ category }: { category: StickerCategory }) { +function StickersContentView() { const { searchQuery, - selectedCollection, viewMode, - collections, - currentCollection, searchResults, recentStickers, - isLoadingCollections, - isLoadingCollection, isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, clearRecentStickers, - setSelectedCategory, - addingSticker, } = useStickersStore(); - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - const filteredCollections = useMemo(() => { - if (category === "all") { - return Object.entries(collections).map(([prefix, collection]) => ({ - prefix, - name: collection.name, - total: collection.total, - category: collection.category, - })); - } - - const collectionList = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - name: c.name, - total: collection.total, - } - : null; - }) - .filter(Boolean) as Array<{ - prefix: string; - name: string; - total: number; - }>; - }, [collections, category]); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: () => setCollectionsToShow((prev) => prev + 20), - hasMore: filteredCollections.length > collectionsToShow, - isLoading: isLoadingCollections, - enabled: viewMode === "browse" && !selectedCollection && category === "all", - }); - - useEffect(() => { - if (Object.keys(collections).length === 0) { - loadCollections(); - } - }, [collections, loadCollections]); - - useEffect(() => { - const timer = setTimeout(() => { - if (localSearchQuery !== searchQuery) { - setSearchQuery({ query: localSearchQuery }); - if (localSearchQuery.trim()) { - searchStickers({ query: localSearchQuery }); - } - } - }, 500); - - return () => clearTimeout(timer); - }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline({ iconName }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { + const itemsToDisplay = useMemo(() => { if (viewMode === "search" && searchResults) { - return searchResults.icons; - } - - if (viewMode === "collection" && currentCollection) { - const icons: string[] = []; - - if (currentCollection.uncategorized) { - icons.push( - ...currentCollection.uncategorized.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - } - - if (currentCollection.categories) { - Object.values(currentCollection.categories).forEach((categoryIcons) => { - icons.push( - ...categoryIcons.map( - (name) => `${currentCollection.prefix}:${name}`, - ), - ); - }); - } - - return icons.slice(0, 100); + return searchResults.items; } return []; - }, [viewMode, searchResults, currentCollection]); - - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); + }, [viewMode, searchResults]); + + const recentStickerItems = useMemo(() => { + const items: StickerData[] = []; + for (const stickerId of recentStickers) { + const recentStickerItem = toRecentStickerItem({ stickerId }); + if (recentStickerItem) { + items.push(recentStickerItem); + } } - }, [isInCollection]); + return items; + }, [recentStickers]); return ( -
-
- { - if (!expanded && isInCollection) { - setSelectedCollection({ collection: null }); - } - }} - placeholder={ - category === "all" - ? "Search all stickers" - : category === "general" - ? "Search icons" - : category === "brands" - ? "Search brands" - : "Search Emojis" - } - value={localSearchQuery} - onChange={setLocalSearchQuery} - disableAnimation={true} - /> -
- -
- -
- {recentStickers.length > 0 && viewMode === "browse" && ( -
-
- - Recent - - - - - - -

Clear recent stickers

-
-
-
-
- -
- )} - - {viewMode === "collection" && selectedCollection && ( -
- {isLoadingCollection ? ( -
- -
- ) : showCollectionItems ? ( - - ) : ( -
- -
- )} -
- )} - - {viewMode === "search" && ( -
- {isSearching ? ( -
- -
- ) : searchResults?.icons.length ? ( - <> -
- - {searchResults.total} results - -
- + {recentStickerItems.length > 0 && viewMode === "browse" && ( +
+
+ + Recent + + + + - )} -
- ) : null} -
- )} - - {viewMode === "browse" && !selectedCollection && ( -
- {isLoadingCollections ? ( -
- -
- ) : ( - <> - {category !== "all" && ( -
- - setSelectedCollection({ collection: prefix }) - } - /> -
- )} + + + +

Clear recent stickers

+
+ + +
+ +
+ )} - {category === "all" && filteredCollections.length > 0 && ( -
- - setSelectedCollection({ collection: prefix }) - } - /> -
- )} - - )} + {viewMode === "search" && ( +
+ {isSearching ? ( +
+ +
+ ) : searchResults?.items.length ? ( +
+
+ + {searchResults.total} results +
- )} -
- -
+ +
+ ) : searchQuery ? ( + + ) : null} +
+ )}
); } -interface CollectionItemProps { - title: string; - subtitle: string; - onClick: () => void; -} - -function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { - return ( - - ); -} - interface StickerItemProps { - iconName: string; - onAdd: (iconName: string) => void; - isAdding?: boolean; - capSize?: boolean; + item: StickerData; + shouldCapSize?: boolean; } -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); +function StickerItem({ item, shouldCapSize = false }: StickerItemProps) { + const { addingSticker, addStickerToTimeline } = useStickersStore(); + const isAdding = addingSticker === item.id; + const [hasImageError, setHasImageError] = useState(false); useEffect(() => { - if (!iconName) { + if (!item.id) { return; } - setImageError(false); - setHostIndex(0); - }, [iconName]); + setHasImageError(false); + }, [item.id]); - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; + const displayName = item.name; + + const handleAdd = async () => { + try { + await addStickerToTimeline({ + stickerId: item.id, + name: item.name, + }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; - const preview = imageError ? ( + const preview = hasImageError ? (
{displayName} @@ -536,21 +249,13 @@ function StickerItem({ ) : (
{displayName} { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } + setHasImageError(true); }} loading="lazy" unoptimized @@ -572,44 +272,63 @@ function StickerItem({ ); return ( - - -
- onAdd(iconName)} - aspectRatio={1} - shouldShowLabel={false} - isRounded={true} - variant="card" - className="" - containerClassName="w-full" - /> - {isAdding && ( -
- -
- )} -
-
- -
-

{displayName}

-

{collectionPrefix}

+
+ + {isAdding && ( +
+
- - + )} +
+ ); +} + +function getStickerNameFromId({ stickerId }: { stickerId: string }): string { + const stickerIdParts = stickerId.split(":"); + if (stickerIdParts.length <= 1) { + return stickerId; + } + return ( + stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ?? + stickerId ); } + +function toRecentStickerItem({ + stickerId, +}: { + stickerId: string; +}): StickerData | null { + try { + const { providerId } = parseStickerId({ stickerId }); + return { + id: stickerId, + provider: providerId, + name: getStickerNameFromId({ stickerId }), + previewUrl: resolveStickerId({ + stickerId, + options: { width: 64, height: 64 }, + }), + metadata: {}, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 6360c363..3673acc3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,5 +1,5 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-view"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { buildTextElement } from "@/lib/timeline/element-utils"; @@ -23,7 +23,7 @@ export function TextView() { }; return ( - + - + ); } -- cgit v1.3.1