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/media.tsx | 675 --------------------- 1 file changed, 675 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/assets/views/media.tsx (limited to 'apps/web/src/components/editor/panels/assets/views/media.tsx') diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx deleted file mode 100644 index 59395fd8..00000000 --- a/apps/web/src/components/editor/panels/assets/views/media.tsx +++ /dev/null @@ -1,675 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; -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 { - buildImageElement, - buildUploadAudioElement, - buildVideoElement, -} from "@/lib/timeline/element-utils"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import type { MediaAsset } from "@/types/assets"; -import type { CreateTimelineElement } from "@/types/timeline"; -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 element = createElementFromMedia({ asset, 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}`); - - 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}`); - } -} -- cgit v1.3.1