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/timeline/drag-line.tsx | 2 +- .../components/editor/panels/timeline/index.tsx | 115 +++--- .../editor/panels/timeline/timeline-element.tsx | 424 ++++++++++++--------- .../editor/panels/timeline/timeline-toolbar.tsx | 20 +- .../editor/panels/timeline/timeline-track.tsx | 3 +- 5 files changed, 323 insertions(+), 241 deletions(-) (limited to 'apps/web/src/components/editor/panels/timeline') diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index f31c6e28..291a4633 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -21,7 +21,7 @@ export function DragLine({ return (
); diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index d89d270d..593976a8 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; + export function Timeline() { - const tracksContainerHeight = { min: 0, max: 800 }; - const { snappingEnabled } = useTimelineStore(); + const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); const { clearElementSelection, setElementSelection } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; const tracks = timeline.getTracks(); const seek = (time: number) => editor.playback.seek({ time }); - // refs const timelineRef = useRef(null); const timelineHeaderRef = useRef(null); const rulerRef = useRef(null); @@ -74,7 +76,6 @@ export function Timeline() { const playheadRef = useRef(null); const trackLabelsScrollRef = useRef(null); - // state const [isResizing, setIsResizing] = useState(false); const [currentSnapPoint, setCurrentSnapPoint] = useState( null, @@ -151,6 +152,7 @@ export function Timeline() { const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, headerRef: timelineHeaderRef, + tracksScrollRef, zoomLevel, }); @@ -169,7 +171,7 @@ export function Timeline() { zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -422,7 +424,20 @@ export function Timeline() { {tracks.length === 0 ? (
) : ( - tracks.map((track, index) => ( + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (element) => element.id === dragState.elementId, + ); + const bHasDragged = b.track.elements.some( + (element) => element.id === dragState.elementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }) + .map(({ track, index }) => (
} - onClick={(e) => { - e.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - - { - e.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - - - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - - - { - e.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - - - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - - - { - e.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} + onClick={(event) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + { + event.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + + + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + + { + event.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + + + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + + { + event.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} variant="destructive" > diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 38cf953b..f96979d4 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -4,6 +4,10 @@ import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/hooks/timeline/element/use-keyframe-drag"; import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { getElementKeyframes } from "@/lib/animation"; @@ -26,6 +30,7 @@ import { import type { TimelineElement as TimelineElementType, TimelineTrack, + VisualElement, ElementDragState, } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; @@ -53,8 +58,11 @@ import { uppercase } from "@/utils/string"; import type { ComponentProps, ReactNode } from "react"; import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation"; import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; +import { usePropertiesStore } from "@/stores/properties-store"; const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; interface KeyframeIndicator { time: number; @@ -62,7 +70,7 @@ interface KeyframeIndicator { keyframes: SelectedKeyframeRef[]; } -function buildKeyframeIndicator({ +export function buildKeyframeIndicator({ keyframe, trackId, elementId, @@ -98,7 +106,7 @@ function buildKeyframeIndicator({ }; } -function getKeyframeIndicators({ +export function getKeyframeIndicators({ keyframes, trackId, elementId, @@ -145,8 +153,8 @@ function getKeyframeIndicators({ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); } -function getDisplayShortcut(action: TAction) { - const { defaultShortcuts } = getActionDefinition(action); +export function getDisplayShortcut({ action }: { action: TAction }) { + const { defaultShortcuts } = getActionDefinition({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -164,10 +172,13 @@ interface TimelineElementProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onResizeStateChange?: (params: { isResizing: boolean }) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, element: TimelineElementType, ) => void; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; dragState: ElementDragState; isDropTarget?: boolean; } @@ -188,12 +199,12 @@ export function TimelineElement({ const { selectedElements } = useElementSelection(); const { requestRevealMedia } = useAssetsPanelStore(); - const mediaAssets = editor.media.getAssets(); let mediaAsset: MediaAsset | null = null; if (hasMediaId(element)) { mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + editor.media.getAssets().find((asset) => asset.id === element.mediaId) ?? + null; } const hasAudio = mediaSupportsAudio({ media: mediaAsset }); @@ -242,6 +253,13 @@ export function TimelineElement({ elementWidth, }) : []; + + const { + keyframeDragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + } = useKeyframeDrag({ zoomLevel, element }); const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -255,7 +273,7 @@ export function TimelineElement({
- {isSelected && } + {isSelected && ( +
+ +
+ )}
@@ -344,9 +371,6 @@ function ElementInner({ element, track, isSelected, - hasAudio, - isMuted, - mediaAssets, onElementClick, onElementMouseDown, handleResizeStart, @@ -355,12 +379,12 @@ function ElementInner({ element: TimelineElementType; track: TimelineTrack; isSelected: boolean; - hasAudio: boolean; - isMuted: boolean; - mediaAssets: MediaAsset[]; - onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; onElementMouseDown: ( - e: React.MouseEvent, + event: React.MouseEvent, element: TimelineElementType, ) => void; handleResizeStart: (params: { @@ -374,47 +398,56 @@ function ElementInner({ (canElementBeHidden(element) && element.hidden) || isDropTarget ? "opacity-50" : ""; + const closeClipEffects = usePropertiesStore( + (state) => state.closeClipEffects, + ); return (
- +
+ + {element.type !== "audio" && element.type !== "effect" && ( +
+
- {(hasAudio - ? isMuted - : canElementBeHidden(element) && element.hidden) && ( -
- {hasAudio ? ( - - ) : ( - - )} -
- )} - + )} {isSelected && ( <> @@ -451,68 +484,82 @@ function ResizeHandle({ return ( + > ); } function KeyframeIndicators({ indicators, + dragState, + displayedStartTime, + elementLeft, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, }: { indicators: KeyframeIndicator[]; + dragState: KeyframeDragState; + displayedStartTime: number; + elementLeft: number; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; }) { - const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } = - useKeyframeSelection(); + const { isKeyframeSelected } = useKeyframeSelection(); const orderedKeyframes = indicators.flatMap( (indicator) => indicator.keyframes, ); - const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const handleKeyframeClick = ({ - event, - keyframes, - }: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.stopPropagation(); - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }; - return indicators.map((indicator) => { const isIndicatorSelected = indicator.keyframes.some((keyframe) => isKeyframeSelected({ keyframe }), ); + const isBeingDragged = indicator.keyframes.some((kf) => + dragState.draggingKeyframeIds.has(kf.keyframeId), + ); + const visualOffsetPx = getVisualOffsetPx({ + indicatorTime: indicator.time, + indicatorOffsetPx: indicator.offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); return ( + ); +} const ELEMENT_CONTENT_RENDERERS: Record< TimelineElementType["type"], @@ -548,7 +667,7 @@ const ELEMENT_CONTENT_RENDERERS: Record< { type: "text" } >; return ( -
+
{textElement.content} @@ -557,8 +676,11 @@ const ELEMENT_CONTENT_RENDERERS: Record< }, effect: ({ element }) => (
- - {element.name} + + {element.name}
), sticker: ({ element }) => { @@ -618,7 +740,7 @@ const ELEMENT_CONTENT_RENDERERS: Record< ); }, - video: ({ element, track, isSelected, mediaAssets }) => { + video: ({ element, track, mediaAssets }) => { const videoElement = element as Extract< TimelineElementType, { type: "video" } @@ -626,48 +748,13 @@ const ELEMENT_CONTENT_RENDERERS: Record< const mediaAsset = mediaAssets.find( (asset) => asset.id === videoElement.mediaId, ); - - if (!mediaAsset) { - return ( - - {videoElement.name} - - ); - } - - if (mediaAsset.thumbnailUrl) { - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - - return ( -
-
-
-
-
- ); - } - - return ( - - {videoElement.name} - - ); + return renderTiledMedia({ + element: videoElement, + imageUrl: mediaAsset?.thumbnailUrl, + track, + }); }, - image: ({ element, track, isSelected, mediaAssets }) => { + image: ({ element, track, mediaAssets }) => { const imageElement = element as Extract< TimelineElementType, { type: "image" } @@ -675,44 +762,27 @@ const ELEMENT_CONTENT_RENDERERS: Record< const mediaAsset = mediaAssets.find( (asset) => asset.id === imageElement.mediaId, ); - - if (!mediaAsset?.url) { - return ( - - {imageElement.name} - - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * (16 / 9); - - return ( -
-
-
-
-
- ); + return renderTiledMedia({ + element: imageElement, + imageUrl: mediaAsset?.url, + track, + }); }, }; -function ElementContent(props: ElementContentProps) { - const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type]; - return <>{renderer(props)}; +function ElementContent({ element, track, isSelected }: ElementContentProps) { + const editor = useEditor(); + const renderer = ELEMENT_CONTENT_RENDERERS[element.type]; + return ( + <> + {renderer({ + element, + track, + isSelected, + mediaAssets: editor.media.getAssets(), + })} + + ); } function CopyMenuItem() { @@ -740,9 +810,9 @@ function MuteMenuItem({ return ; } return isMuted ? ( - - ) : ( + ) : ( + ); }; @@ -823,7 +893,7 @@ function ActionMenuItem({ event.stopPropagation(); invokeAction(action); }} - textRight={getDisplayShortcut(action)} + textRight={getDisplayShortcut({ action })} {...props} > {children} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index 03ce88eb..533ea63d 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -78,7 +78,7 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); const currentTime = editor.playback.getCurrentTime(); - const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime }); const handleAction = ({ action, @@ -116,7 +116,7 @@ function ToolbarLeftSection() { } - tooltip="Coming soon" /* separate audio */ + tooltip="Separate audio (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -131,7 +131,7 @@ function ToolbarLeftSection() { } - tooltip="Coming soon" /* freeze frame */ + tooltip="Freeze frame (coming soon)" disabled={true} onClick={({ event: _event }) => {}} /> @@ -149,8 +149,8 @@ function ToolbarLeftSection() { } - isActive={currentBookmarked} - tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} onClick={({ event }) => handleAction({ action: "toggle-bookmark", event }) } @@ -191,12 +191,10 @@ function ToolbarRightSection({ onZoomChange: (zoom: number) => void; onZoom: (options: { direction: "in" | "out" }) => void; }) { - const { - snappingEnabled, - rippleEditingEnabled, - toggleSnapping, - toggleRippleEditing, - } = useTimelineStore(); + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); return (
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index fbc7cb3f..81cda7a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -52,7 +52,7 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const editor = useEditor(); - const { isElementSelected, clearElementSelection } = useElementSelection(); + const { isElementSelected } = useElementSelection(); const duration = editor.timeline.getTotalDuration(); @@ -69,7 +69,6 @@ export function TimelineTrackContent({ className="size-full" onClick={(event) => { if (shouldIgnoreClick?.()) return; - clearElementSelection(); onTrackClick?.(event); }} onMouseDown={(event) => { -- cgit v1.3.1