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/properties/audio-properties.tsx | 4 +- .../panels/properties/clip-effects-properties.tsx | 211 ++++++++++ .../panels/properties/effect-param-field.tsx | 152 +++++++ .../editor/panels/properties/effect-properties.tsx | 103 ++--- .../components/editor/panels/properties/index.tsx | 28 +- .../editor/panels/properties/section.tsx | 154 +++++-- .../editor/panels/properties/sections/blending.tsx | 8 +- .../panels/properties/sections/transform.tsx | 25 +- .../editor/panels/properties/text-properties.tsx | 467 ++++++++++++--------- .../editor/panels/properties/video-properties.tsx | 12 +- 10 files changed, 832 insertions(+), 332 deletions(-) create mode 100644 apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx create mode 100644 apps/web/src/components/editor/panels/properties/effect-param-field.tsx (limited to 'apps/web/src/components/editor/panels/properties') diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx index 0b83a4b6..2ad46497 100644 --- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -1,5 +1,3 @@ -import type { AudioElement } from "@/types/timeline"; - -export function AudioProperties({ _element }: { _element: AudioElement }) { +export function AudioProperties() { return
Audio properties
; } diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx new file mode 100644 index 00000000..1caf73eb --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import type { Effect } from "@/types/effects"; +import type { VisualElement } from "@/types/timeline"; +import { getEffect } from "@/lib/effects/registry"; +import { useEditor } from "@/hooks/use-editor"; +import { usePropertiesStore } from "@/stores/properties-store"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, + SectionFields, +} from "./section"; +import { EffectParamField } from "./effect-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowLeft01Icon, + Delete02Icon, + ViewIcon, + ViewOffSlashIcon, +} from "@hugeicons/core-free-icons"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/utils/ui"; +export function ClipEffectsProperties({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); + const editor = useEditor(); + const effects = element.effects ?? []; + + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + + const handleDragStart = ({ index }: { index: number }) => { + setDragIndex(index); + }; + + const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => { + event.preventDefault(); + if (index !== dropIndex) setDropIndex(index); + }; + + const handleDrop = ({ toIndex }: { toIndex: number }) => { + if (dragIndex !== null && dragIndex !== toIndex) { + editor.timeline.reorderClipEffects({ + trackId, + elementId: element.id, + fromIndex: dragIndex, + toIndex, + }); + } + setDragIndex(null); + setDropIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDropIndex(null); + }; + + return ( +
+
+ + Effects +
+ + {effects.map((effect, index) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder +
handleDragStart({ index })} + onDragOver={(event) => handleDragOver({ event, index })} + onDrop={() => handleDrop({ toIndex: index })} + onDragEnd={handleDragEnd} + className={cn( + "group", + dragIndex === index && "opacity-40", + dropIndex === index && + dragIndex !== null && + dragIndex !== index && + (index < dragIndex + ? "border-t-2 border-primary" + : "border-b-2 border-primary"), + )} + > + +
+ ))} +
+
+ ); +} + +function ClipEffectSection({ + effect, + element, + trackId, +}: { + effect: Effect; + element: VisualElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = getEffect({ effectType: effect.type }); + + const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => { + const updatedEffects = (element.effects ?? []).map((existing) => + existing.id !== effect.id + ? existing + : { ...existing, params: { ...existing.params, [key]: value } }, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { effects: updatedEffects }, + }, + ], + }); + }; + + const commitParam = () => editor.timeline.commitPreview(); + + const toggleEffect = () => + editor.timeline.toggleClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }); + + const removeEffect = () => + editor.timeline.removeClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }); + + return ( +
+ + + + + } + > + + {definition.name} + + + {effect.enabled && ( + + + {definition.params.map((param) => ( + + ))} + + + )} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx new file mode 100644 index 00000000..408d430a --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx @@ -0,0 +1,152 @@ +"use client"; + +import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects"; +import { clamp } from "@/utils/math"; +import { SectionField } from "./section"; +import { Slider } from "@/components/ui/slider"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { usePropertyDraft } from "./hooks/use-property-draft"; + +export function EffectParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: EffectParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + return ( + + + + ); +} + +function EffectParamInput({ + param, + value, + onPreview, + onCommit, +}: { + param: EffectParamDefinition; + value: number | string | boolean; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; +}) { + if (param.type === "number") { + return ( + + ); + } + + if (param.type === "boolean") { + return ( + { + onPreview(checked); + onCommit(); + }} + /> + ); + } + + if (param.type === "select") { + return ( + + ); + } + + if (param.type === "color") { + return ( + onPreview(event.target.value)} + onBlur={onCommit} + /> + ); + } + + return null; +} + +function NumberParamField({ + param, + value, + onPreview, + onCommit, +}: { + param: NumberEffectParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; +}) { + const { min, max, step } = param; + + const draft = usePropertyDraft({ + displayValue: String(value), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min, max }); + }, + onPreview, + onCommit, + }); + + return ( +
+ onPreview(newValue)} + onValueCommit={onCommit} + /> + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx index bf137291..75fc30a1 100644 --- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx @@ -1,78 +1,16 @@ "use client"; import type { EffectElement } from "@/types/timeline"; -import type { EffectParamDefinition } from "@/types/effects"; import { getEffect } from "@/lib/effects/registry"; import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section"; -import { Slider } from "@/components/ui/slider"; -import { NumberField } from "@/components/ui/number-field"; -import { usePropertyDraft } from "./hooks/use-property-draft"; - -function EffectParamField({ - param, - element, - trackId, -}: { - param: EffectParamDefinition; - element: EffectElement; - trackId: string; -}) { - const editor = useEditor(); - - const currentValue = Number(element.params[param.key] ?? param.default); - const min = param.min ?? 0; - const max = param.max ?? 100; - const step = param.step ?? 1; - - const updateParam = (value: number) => - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, [param.key]: value } }, - }, - ], - }); - - const commitParam = () => editor.timeline.commitPreview(); - - const draft = usePropertyDraft({ - displayValue: String(currentValue), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min, max }); - }, - onPreview: updateParam, - onCommit: commitParam, - }); - - return ( - -
- updateParam(value)} - onValueCommit={commitParam} - /> - -
-
- ); -} +import { + Section, + SectionContent, + SectionHeader, + SectionFields, + SectionTitle, +} from "./section"; +import { EffectParamField } from "./effect-param-field"; export function EffectProperties({ element, @@ -81,19 +19,36 @@ export function EffectProperties({ element: EffectElement; trackId: string; }) { + const editor = useEditor(); const definition = getEffect({ effectType: element.effectType }); + const previewParam = + ({ key }: { key: string }) => + (value: number | string | boolean) => + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, [key]: value } }, + }, + ], + }); + return ( -
- +
+ + {definition.name} + {definition.params.map((param) => ( editor.timeline.commitPreview()} /> ))} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 75dd81a9..cb45996d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -5,9 +5,12 @@ import { AudioProperties } from "./audio-properties"; import { VideoProperties } from "./video-properties"; import { TextProperties } from "./text-properties"; import { EffectProperties } from "./effect-properties"; +import { ClipEffectsProperties } from "./clip-effects-properties"; import { EmptyView } from "./empty-view"; import { useEditor } from "@/hooks/use-editor"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { usePropertiesStore } from "@/stores/properties-store"; +import { isVisualElement } from "@/lib/timeline"; import type { TimelineElement, TimelineTrack } from "@/types/timeline"; function ElementProperties({ @@ -21,7 +24,7 @@ function ElementProperties({ return ; } if (element.type === "audio") { - return ; + return ; } if ( element.type === "video" || @@ -39,16 +42,33 @@ function ElementProperties({ export function PropertiesPanel() { const editor = useEditor(); const { selectedElements } = useElementSelection(); + const clipEffectsTarget = usePropertiesStore( + (state) => state.clipEffectsTarget, + ); + + const clipEffectsTrack = clipEffectsTarget + ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId }) + : null; + const clipEffectsElement = clipEffectsTrack?.elements.find( + (element) => element.id === clipEffectsTarget?.elementId, + ); + const isShowingClipEffects = + clipEffectsTrack && + clipEffectsElement && + isVisualElement(clipEffectsElement); const elementsWithTracks = editor.timeline.getElementsWithTracks({ elements: selectedElements, }); - const hasSelection = selectedElements.length > 0; - return (
- {hasSelection ? ( + {isShowingClipEffects ? ( + + ) : selectedElements.length > 0 ? ( {elementsWithTracks.map(({ track, element }) => ( (); +const mountedSectionKeys = new Set(); interface SectionContext { isOpen: boolean; @@ -24,8 +26,8 @@ interface SectionProps { defaultOpen?: boolean; sectionKey?: string; className?: string; - hasBorderTop?: boolean; - hasBorderBottom?: boolean; + showTopBorder?: boolean; + showBottomBorder?: boolean; } export function Section({ @@ -34,12 +36,21 @@ export function Section({ defaultOpen = true, sectionKey, className, - hasBorderTop = true, - hasBorderBottom = true, + showTopBorder = true, + showBottomBorder = true, }: SectionProps) { const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + useEffect(() => { + if (!sectionKey) return; + if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) { + console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`); + } + mountedSectionKeys.add(sectionKey); + return () => { mountedSectionKeys.delete(sectionKey); }; + }, [sectionKey]); + const toggle = () => { const next = !isOpen; setIsOpen(next); @@ -51,8 +62,8 @@ export function Section({
@@ -63,15 +74,19 @@ export function Section({ } interface SectionHeaderProps { - title: string; children?: React.ReactNode; + trailing?: React.ReactNode; + leading?: React.ReactNode; + actions?: React.ReactNode; onClick?: () => void; className?: string; } export function SectionHeader({ - title, children, + trailing, + leading, + actions, onClick, className, }: SectionHeaderProps) { @@ -79,57 +94,108 @@ export function SectionHeader({ const isCollapsible = ctx?.collapsible ?? false; const isOpen = ctx?.isOpen ?? true; const isInteractive = isCollapsible || !!onClick; - const handleClick = isCollapsible ? ctx?.toggle : onClick; - const content = ( + const chevronIcon = isCollapsible ? ( + + ) : null; + + const headerContent = ( <> - - {title} - -
- {children} - {isCollapsible && ( - - )} -
+ {leading} +
{children}
+ {(trailing || chevronIcon) && ( +
+ {trailing} + {chevronIcon && ( + + )} +
+ )} + {actions} ); - const baseClassName = cn( - "flex w-full items-center justify-between h-11 px-3.5", - className, + if (!isInteractive) { + return ( +
+ {headerContent} +
+ ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested ); } - return
{content}
; + return ( + + {children} + + ); } export function SectionFields({ diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx index 4343c692..66fe820f 100644 --- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx @@ -8,7 +8,7 @@ import { } from "@/constants/timeline-constants"; import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { Fragment, useRef } from "react"; -import { Section, SectionContent, SectionField, SectionHeader } from "../section"; +import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section"; import { Select, SelectContent, @@ -74,7 +74,7 @@ export function BlendingSection({ committedBlendModeRef.current = blendMode; } - const previewBlendMode = (value: BlendMode) => + const previewBlendMode = ({ value }: { value: BlendMode }) => editor.timeline.previewElements({ updates: [ { trackId, elementId: element.id, updates: { blendMode: value } }, @@ -123,7 +123,7 @@ export function BlendingSection({ return (
- + Blending
@@ -175,7 +175,7 @@ export function BlendingSection({ key={option.value} value={option.value} onPointerEnter={() => - previewBlendMode(option.value as BlendMode) + previewBlendMode({ value: option.value as BlendMode }) } > {option.label} diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx index 3fdb47dc..6aaaa1fe 100644 --- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx +++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx @@ -9,6 +9,7 @@ import { SectionField, SectionFields, SectionHeader, + SectionTitle, } from "../section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -24,12 +25,12 @@ import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation"; import { KeyframeToggle } from "../keyframe-toggle"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -function parseNumericInput({ input }: { input: string }): number | null { +export function parseNumericInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed; } -function isPropertyAtDefault({ +export function isPropertyAtDefault({ hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue, @@ -55,9 +56,11 @@ function isPropertyAtDefault({ export function TransformSection({ element, trackId, + showTopBorder = true, }: { element: VisualElement; trackId: string; + showTopBorder?: boolean; }) { const editor = useEditor(); const [isScaleLocked, setIsScaleLocked] = useState(false); @@ -239,8 +242,12 @@ export function TransformSection({ }; return ( -
- +
+ Transform ) : ( - } - {...scaleFieldProps} - className="flex-1" - /> + } + {...scaleFieldProps} + className="flex-1" + /> )}