From e7dcb586c0408255a0bf788e355fdb6d50d9b923 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Mar 2026 13:13:07 +0100 Subject: feat: Clip effects, asset sorting, and timeline improvements Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders). --- .../editor/panels/assets/views/assets.tsx | 516 ++++++++++----------- .../editor/panels/assets/views/settings.tsx | 67 +-- 2 files changed, 272 insertions(+), 311 deletions(-) (limited to 'apps/web/src/components/editor/panels/assets/views') diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 4c6922ff..8814dd4d 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -31,7 +31,12 @@ 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 MediaSortKey, + type MediaSortOrder, + type MediaViewMode, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; import type { MediaAsset } from "@/types/assets"; import { cn } from "@/utils/ui"; import { @@ -50,8 +55,15 @@ export function MediaView() { const mediaFiles = editor.media.getAssets(); const activeProject = editor.project.getActive(); - const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = - useAssetsPanelStore(); + const { + mediaViewMode, + setMediaViewMode, + highlightMediaId, + clearHighlight, + mediaSortBy, + mediaSortOrder, + setMediaSort, + } = useAssetsPanelStore(); const { highlightedId, registerElement } = useRevealItem( highlightMediaId, clearHighlight, @@ -59,10 +71,6 @@ export function MediaView() { 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; @@ -121,27 +129,12 @@ export function MediaView() { }); }; - 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 handleSort = ({ key }: { key: SortKey }) => { + if (mediaSortBy === key) { + setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + } else { + setMediaSort(key, "asc"); + } }; const filteredMediaItems = useMemo(() => { @@ -151,7 +144,7 @@ export function MediaView() { let valueA: string | number; let valueB: string | number; - switch (sortBy) { + switch (mediaSortBy) { case "name": valueA = a.name.toLowerCase(); valueB = b.name.toLowerCase(); @@ -172,154 +165,13 @@ export function MediaView() { return 0; } - if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; - if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return mediaSortOrder === "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"}) -

-
-
-
-
- -
- ); + }, [mediaFiles, mediaSortBy, mediaSortOrder]); return ( <> @@ -327,8 +179,18 @@ export function MediaView() { + } + className={cn(isDragOver && "bg-accent/30")} {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -338,21 +200,11 @@ export function MediaView() { progress={progress} onClick={openFilePicker} /> - ) : mediaViewMode === "grid" ? ( - ) : ( - @@ -362,6 +214,67 @@ export function MediaView() { ); } +function MediaAssetDraggable({ + item, + preview, + isHighlighted, + variant, + isRounded, +}: { + item: MediaAsset; + preview: React.ReactNode; + isHighlighted: boolean; + variant: "card" | "compact"; + isRounded?: boolean; +}) { + const editor = useEditor(); + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => { + 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 ( + + addElementAtTime({ asset: item, startTime: currentTime }) + } + variant={variant} + isRounded={isRounded} + isHighlighted={isHighlighted} + /> + ); +} + function MediaItemWithContextMenu({ item, children, @@ -387,107 +300,41 @@ function MediaItemWithContextMenu({ ); } -function GridView({ +function MediaItemList({ items, - renderPreview, + mode, onRemove, - onAddToTimeline, highlightedId, registerElement, }: { items: MediaAsset[]; - renderPreview: (item: MediaAsset) => React.ReactNode; + mode: MediaViewMode; 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; }) { + const isGrid = mode === "grid"; + return (
{items.map((item) => ( -
registerElement(item.id, el)}> +
registerElement(item.id, element)}> - - 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" + variant={isGrid ? "card" : "compact"} + isRounded={isGrid ? false : undefined} isHighlighted={highlightedId === item.id} /> @@ -497,11 +344,11 @@ function ListView({ ); } -const formatDuration = ({ duration }: { duration: number }) => { +export function 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; @@ -619,6 +466,119 @@ function MediaPreview({ ); } +function MediaActions({ + mediaViewMode, + setMediaViewMode, + isProcessing, + sortBy, + sortOrder, + onSort, + onImport, +}: { + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + isProcessing: boolean; + sortBy: MediaSortKey; + sortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; + onImport: () => void; +}) { + return ( +
+ + + + + + +

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

+
+
+ + + + + + + + + + + + + + + +

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

+
+
+
+ +
+ ); +} + function SortMenuItem({ label, sortKey, @@ -627,10 +587,10 @@ function SortMenuItem({ onSort, }: { label: string; - sortKey: "name" | "type" | "duration" | "size"; - currentSortBy: string; - currentSortOrder: "asc" | "desc"; - onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; + sortKey: MediaSortKey; + currentSortBy: MediaSortKey; + currentSortOrder: MediaSortOrder; + onSort: ({ key }: { key: MediaSortKey }) => void; }) { const isActive = currentSortBy === sortKey; const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx index 7e10ac9d..d46e9d1f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -16,6 +16,7 @@ import { Section, SectionContent, SectionHeader, + SectionTitle, } from "@/components/editor/panels/properties/section"; import { Label } from "@/components/ui/label"; import { @@ -23,25 +24,46 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useState } from "react"; -export function SettingsView() { - const [open, setOpen] = useState(false); +const ORIGINAL_PRESET_VALUE = "original"; +export function 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; +} + +export function SettingsView() { return (
-
+
- +
- -
+ } + > + Background
@@ -60,26 +82,6 @@ function ProjectInfoContent() { 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; @@ -87,12 +89,11 @@ function ProjectInfoContent() { presets: canvasPresets, targetAspectRatio: currentAspectRatio, }); - const originalPresetValue = "original"; const selectedPresetValue = - presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE; const handleAspectRatioChange = ({ value }: { value: string }) => { - if (value === originalPresetValue) { + if (value === ORIGINAL_PRESET_VALUE) { const canvasSize = originalCanvasSize ?? currentCanvasSize; editor.project.updateSettings({ settings: { canvasSize }, @@ -106,7 +107,7 @@ function ProjectInfoContent() { } }; - const handleFpsChange = (value: string) => { + const handleFpsChange = ({ value }: { value: string }) => { const fps = parseFloat(value); editor.project.updateSettings({ settings: { fps } }); }; @@ -129,7 +130,7 @@ function ProjectInfoContent() { - Original + Original {canvasPresets.map((preset, index) => { const label = dimensionToAspectRatio({ width: preset.width, @@ -148,7 +149,7 @@ function ProjectInfoContent() {