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).
This commit is contained in:
Maze Winther
2026-03-02 13:13:07 +01:00
parent 93bea01c9e
commit e7dcb586c0
66 changed files with 3688 additions and 1333 deletions
@@ -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,
});
@@ -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,
]);
@@ -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<string>;
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<KeyframeDragState>(initialDragState);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
const mouseDownXRef = useRef<number | null>(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,
};
}