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/views/assets.tsx | 637 +++++++++++++++++++++ 1 file changed, 637 insertions(+) create mode 100644 apps/web/src/components/editor/panels/assets/views/assets.tsx (limited to 'apps/web/src/components/editor/panels/assets/views/assets.tsx') 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} + + ); +} -- cgit v1.3.1