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). --- .../timeline/element/use-element-interaction.ts | 65 ++--- .../hooks/timeline/element/use-element-resize.ts | 98 +++++-- .../hooks/timeline/element/use-keyframe-drag.ts | 281 +++++++++++++++++++++ 3 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-drag.ts (limited to 'apps/web/src/hooks/timeline/element') diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 3831a86f..ea01c718 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -8,6 +8,7 @@ import { } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/stores/timeline-store"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { DRAG_THRESHOLD_PX, @@ -38,6 +39,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } +const MOUSE_BUTTON_RIGHT = 2; + const initialDragState: ElementDragState = { isDragging: false, elementId: null, @@ -162,6 +165,7 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { @@ -441,27 +445,33 @@ export function useElementInteraction({ return; } - if (dropTarget.isNewTrack) { - const newTrackId = generateUUID(); + if (dropTarget.isNewTrack) { + const newTrackId = generateUUID(); + editor.timeline.moveElement({ + sourceTrackId: dragState.trackId, + targetTrackId: newTrackId, + elementId: dragState.elementId, + newStartTime: snappedTime, + createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, + }); + selectElement({ trackId: newTrackId, elementId: dragState.elementId }); + } else { + const targetTrack = tracks[dropTarget.trackIndex]; + if (targetTrack) { editor.timeline.moveElement({ sourceTrackId: dragState.trackId, - targetTrackId: newTrackId, + targetTrackId: targetTrack.id, elementId: dragState.elementId, newStartTime: snappedTime, - createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, + rippleEnabled: rippleEditingEnabled, }); - } else { - const targetTrack = tracks[dropTarget.trackIndex]; - if (targetTrack) { - editor.timeline.moveElement({ - sourceTrackId: dragState.trackId, - targetTrackId: targetTrack.id, - elementId: dragState.elementId, - newStartTime: snappedTime, - }); + if (targetTrack.id !== dragState.trackId) { + selectElement({ trackId: targetTrack.id, elementId: dragState.elementId }); } } + } endDrag(); onSnapPointChange?.(null); @@ -483,6 +493,8 @@ export function useElementInteraction({ tracksContainerRef, tracksScrollRef, headerRef, + rippleEditingEnabled, + selectElement, ]); useEffect(() => { @@ -508,10 +520,10 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - const isRightClick = event.button === 2; + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, @@ -526,14 +538,12 @@ export function useElementInteraction({ return; } - // left-click: stop propagation for drag operations - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - // multi-select: toggle selection - if (isMultiSelect) { + if (isMultiSelect) { handleSelectionClick({ trackId: track.id, elementId: element.id, @@ -541,8 +551,7 @@ export function useElementInteraction({ }); } - // start drag - const clickOffsetTime = getClickOffsetTime({ + const clickOffsetTime = getClickOffsetTime({ clientX: event.clientX, elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, @@ -570,10 +579,9 @@ export function useElementInteraction({ element: TimelineElement; track: TimelineTrack; }) => { - event.stopPropagation(); + event.stopPropagation(); - // was it a drag or a click? - if (mouseDownLocationRef.current) { + if (mouseDownLocationRef.current) { const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) { @@ -585,8 +593,7 @@ export function useElementInteraction({ // modifier keys already handled in mousedown if (event.metaKey || event.ctrlKey || event.shiftKey) return; - // single click: select if not selected - const alreadySelected = isElementSelected({ + const alreadySelected = isElementSelected({ trackId: track.id, elementId: element.id, }); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 2340c9b2..0ae17cb6 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -137,18 +137,52 @@ export function useTimelineElementResize({ } onSnapPointChange?.(resizeSnapPoint); + const otherElements = track.elements.filter(({ id }) => id !== element.id); + const initialEndTime = resizing.initialStartTime + resizing.initialDuration; + + const rightNeighborBound = + resizing.side === "right" + ? otherElements + .filter(({ startTime }) => startTime >= initialEndTime) + .reduce((min, { startTime }) => Math.min(min, startTime), Infinity) + : Infinity; + + const leftNeighborBound = + resizing.side === "left" + ? otherElements + .filter( + ({ startTime, duration }) => + startTime + duration <= resizing.initialStartTime, + ) + .reduce( + (max, { startTime, duration }) => + Math.max(max, startTime + duration), + -Infinity, + ) + : -Infinity; + if (resizing.side === "left") { const sourceDuration = resizing.initialTrimStart + resizing.initialDuration + resizing.initialTrimEnd; + const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound) + ? Math.max( + 0, + resizing.initialTrimStart + + (leftNeighborBound - resizing.initialStartTime), + ) + : 0; const maxAllowed = sourceDuration - resizing.initialTrimEnd - minDurationSeconds; const calculated = resizing.initialTrimStart + deltaTime; if (calculated >= 0 && calculated <= maxAllowed) { const newTrimStart = snapTimeToFrame({ - time: Math.min(maxAllowed, calculated), + time: Math.min( + maxAllowed, + Math.max(minTrimStartForNeighbor, calculated), + ), fps: projectFps, }); const trimDelta = newTrimStart - resizing.initialTrimStart; @@ -171,7 +205,16 @@ export function useTimelineElementResize({ if (canExtendElementDuration()) { const extensionAmount = Math.abs(calculated); const maxExtension = resizing.initialStartTime; - const actualExtension = Math.min(extensionAmount, maxExtension); + const actualExtension = Math.max( + 0, + Number.isFinite(leftNeighborBound) + ? Math.min( + extensionAmount, + maxExtension, + resizing.initialStartTime - leftNeighborBound, + ) + : Math.min(extensionAmount, maxExtension), + ); const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps, @@ -188,7 +231,8 @@ export function useTimelineElementResize({ currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } else { - const trimDelta = 0 - resizing.initialTrimStart; + const trimDelta = + minTrimStartForNeighbor - resizing.initialTrimStart; const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps, @@ -198,10 +242,10 @@ export function useTimelineElementResize({ fps: projectFps, }); - setCurrentTrimStart(0); + setCurrentTrimStart(minTrimStartForNeighbor); setCurrentStartTime(newStartTime); setCurrentDuration(newDuration); - currentTrimStartRef.current = 0; + currentTrimStartRef.current = minTrimStartForNeighbor; currentStartTimeRef.current = newStartTime; currentDurationRef.current = newDuration; } @@ -212,6 +256,9 @@ export function useTimelineElementResize({ resizing.initialDuration + resizing.initialTrimEnd; const newTrimEnd = resizing.initialTrimEnd - deltaTime; + const maxAllowedDuration = Number.isFinite(rightNeighborBound) + ? rightNeighborBound - resizing.initialStartTime + : Infinity; if (newTrimEnd < 0) { if (canExtendElementDuration()) { @@ -219,7 +266,7 @@ export function useTimelineElementResize({ const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: baseDuration + extensionNeeded, + time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps, }); @@ -230,7 +277,10 @@ export function useTimelineElementResize({ } else { const extensionToLimit = resizing.initialTrimEnd; const newDuration = snapTimeToFrame({ - time: resizing.initialDuration + extensionToLimit, + time: Math.min( + resizing.initialDuration + extensionToLimit, + maxAllowedDuration, + ), fps: projectFps, }); @@ -240,9 +290,20 @@ export function useTimelineElementResize({ currentTrimEndRef.current = 0; } } else { + const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration) + ? Math.max( + 0, + resizing.initialDuration + + resizing.initialTrimEnd - + maxAllowedDuration, + ) + : 0; const maxTrimEnd = sourceDuration - resizing.initialTrimStart - minDurationSeconds; - const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd)); + const clampedTrimEnd = Math.min( + maxTrimEnd, + Math.max(minTrimEndForNeighbor, newTrimEnd), + ); const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps, @@ -267,6 +328,7 @@ export function useTimelineElementResize({ snappingEnabled, editor, element.id, + track.elements, onSnapPointChange, canExtendElementDuration, isShiftHeldRef, @@ -285,26 +347,13 @@ export function useTimelineElementResize({ const startTimeChanged = finalStartTime !== resizing.initialStartTime; const durationChanged = finalDuration !== resizing.initialDuration; - if (trimStartChanged || trimEndChanged) { + if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) { editor.timeline.updateElementTrim({ elementId: element.id, trimStart: finalTrimStart, trimEnd: finalTrimEnd, - }); - } - - if (startTimeChanged) { - editor.timeline.updateElementStartTime({ - elements: [{ trackId: track.id, elementId: element.id }], - startTime: finalStartTime, - }); - } - - if (durationChanged) { - editor.timeline.updateElementDuration({ - trackId: track.id, - elementId: element.id, - duration: finalDuration, + startTime: startTimeChanged ? finalStartTime : undefined, + duration: durationChanged ? finalDuration : undefined, }); } @@ -315,7 +364,6 @@ export function useTimelineElementResize({ resizing, editor.timeline, element.id, - track.id, onResizeStateChange, onSnapPointChange, ]); diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts new file mode 100644 index 00000000..ad4bfe4f --- /dev/null +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -0,0 +1,281 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { snapTimeToFrame } from "@/lib/time"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { DRAG_THRESHOLD_PX, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/lib/commands"; +import type { SelectedKeyframeRef } from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; +import type { Command } from "@/lib/commands/base-command"; + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, +}: { + zoomLevel: number; + element: TimelineElement; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef(null); + const mouseDownXRef = useRef(null); + + const activeProject = editor.project.getActive(); + const fps = activeProject.settings.fps; + + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + const endDrag = useCallback(() => { + setDragState(initialDragState); + }, []); + + const commitDrag = useCallback( + ({ + keyframeRefs, + deltaTime, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTime: number; + }) => { + const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { + const channel = element.animations?.channels[keyframeRef.propertyPath]; + const keyframe = channel?.keyframes.find( + (keyframe) => keyframe.id === keyframeRef.keyframeId, + ); + if (!keyframe) return []; + const nextTime = Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTime), + ); + return [ + new RetimeKeyframeCommand({ + trackId: keyframeRef.trackId, + elementId: keyframeRef.elementId, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + nextTime, + }), + ]; + }); + + if (commands.length === 1) { + editor.command.execute({ command: commands[0] }); + } else if (commands.length > 1) { + editor.command.execute({ command: new BatchCommand(commands) }); + } + }, + [editor.command, element], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX }: MouseEvent) => { + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + if (deltaX <= DRAG_THRESHOLD_PX) return; + + const pending = pendingDragRef.current; + pendingDragRef.current = null; + setIsPendingDrag(false); + setDragState({ + isDragging: true, + draggingKeyframeIds: new Set( + pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), + ), + deltaTime: 0, + }); + return; + } + + if (!dragState.isDragging) return; + + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = (clientX - startX) / pixelsPerSecond; + const snappedDelta = snapTimeToFrame({ time: rawDelta, fps }); + + setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + const draggingRefs = selectedKeyframes.filter( + (keyframe) => + keyframe.elementId === element.id && + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + + if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { + commitDrag({ + keyframeRefs: draggingRefs, + deltaTime: dragState.deltaTime, + }); + } + + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.draggingKeyframeIds, + dragState.deltaTime, + selectedKeyframes, + element.id, + commitDrag, + endDrag, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag]); + + const handleKeyframeMouseDown = useCallback( + ({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.preventDefault(); + event.stopPropagation(); + + mouseDownXRef.current = event.clientX; + + const anySelected = keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes], + ); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [toggleKeyframeSelection, selectKeyframeRange], + ); + + const getVisualOffsetPx = useCallback( + ({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number => { + if (!isBeingDragged) return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min(element.duration, indicatorTime + dragState.deltaTime), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel, + }) - elementLeft + ); + }, + [dragState.deltaTime, element.duration, zoomLevel], + ); + + return { + keyframeDragState: dragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + }; +} -- cgit v1.3.1