feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
@@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import {
snapElementEdge,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
} from "@/lib/timeline";
interface UseElementInteractionProps {
zoomLevel: number;
@@ -213,6 +211,20 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
const cancelCurrentDrag = useCallback(() => {
pendingDragRef.current = null;
mouseDownLocationRef.current = null;
setIsPendingDrag(false);
endDrag();
onSnapPointChange?.(null);
}, [endDrag, onSnapPointChange]);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
return registerCanceller({ fn: cancelCurrentDrag });
}, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
const getDragSnapResult = useCallback(
({
frameSnappedTime,
@@ -445,33 +457,36 @@ 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: targetTrack.id,
targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
const targetTrack = tracks[dropTarget.trackIndex];
if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
rippleEnabled: rippleEditingEnabled,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({
trackId: targetTrack.id,
elementId: dragState.elementId,
});
}
}
}
}
endDrag();
onSnapPointChange?.(null);
@@ -520,10 +535,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
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,
@@ -538,12 +553,12 @@ export function useElementInteraction({
return;
}
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;
if (isMultiSelect) {
if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -551,7 +566,7 @@ export function useElementInteraction({
});
}
const clickOffsetTime = getClickOffsetTime({
const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -579,9 +594,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
event.stopPropagation();
event.stopPropagation();
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) {
@@ -593,7 +608,7 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
const alreadySelected = isElementSelected({
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -9,7 +9,13 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import { isRetimableElement } from "@/lib/timeline";
import {
getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan,
} from "@/lib/retime";
import { useTimelineStore } from "@/stores/timeline-store";
import { registerCanceller } from "@/lib/cancel-interaction";
export interface ResizeState {
elementId: string;
@@ -37,7 +43,6 @@ export function useTimelineElementResize({
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const rippleEditingEnabled = useTimelineStore(
@@ -91,6 +96,94 @@ export function useTimelineElementResize({
return element.sourceDuration == null;
}, [element.sourceDuration]);
const getSourceDeltaForClipDelta = useCallback(
(clipDelta: number) => {
if (!isRetimableElement(element)) {
return clipDelta;
}
return clipDelta >= 0
? getSourceSpanAtClipTime({
clipTime: clipDelta,
retime: element.retime,
})
: -getSourceSpanAtClipTime({
clipTime: Math.abs(clipDelta),
retime: element.retime,
});
},
[element],
);
const getVisibleSourceSpanForDuration = useCallback(
(duration: number) => {
if (!isRetimableElement(element)) {
return duration;
}
return getSourceSpanAtClipTime({
clipTime: duration,
retime: element.retime,
});
},
[element],
);
const getDurationForVisibleSourceSpan = useCallback(
(sourceSpan: number) => {
if (!isRetimableElement(element)) {
return sourceSpan;
}
return getTimelineDurationForSourceSpan({
sourceSpan,
retime: element.retime,
});
},
[element],
);
const getSourceDuration = useCallback(
({
trimStart,
duration,
trimEnd,
}: {
trimStart: number;
duration: number;
trimEnd: number;
}) => {
if (typeof element.sourceDuration === "number") {
return element.sourceDuration;
}
return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
},
[element.sourceDuration, getVisibleSourceSpanForDuration],
);
const cancelResize = useCallback(() => {
if (!resizing) return;
setCurrentTrimStart(resizing.initialTrimStart);
setCurrentTrimEnd(resizing.initialTrimEnd);
setCurrentStartTime(resizing.initialStartTime);
setCurrentDuration(resizing.initialDuration);
currentTrimStartRef.current = resizing.initialTrimStart;
currentTrimEndRef.current = resizing.initialTrimEnd;
currentStartTimeRef.current = resizing.initialStartTime;
currentDurationRef.current = resizing.initialDuration;
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [resizing, onResizeStateChange, onSnapPointChange]);
useEffect(() => {
if (!resizing) return;
return registerCanceller({ fn: cancelResize });
}, [resizing, cancelResize]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
if (!resizing) return;
@@ -100,7 +193,7 @@ export function useTimelineElementResize({
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = activeProject.settings.fps;
const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
@@ -139,14 +232,20 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
const otherElements = track.elements.filter(({ id }) => id !== element.id);
const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
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)
.reduce(
(min, { startTime }) => Math.min(min, startTime),
Infinity,
)
: Infinity;
const leftNeighborBound =
@@ -164,20 +263,26 @@ export function useTimelineElementResize({
: -Infinity;
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const sourceDuration = getSourceDuration({
trimStart: resizing.initialTrimStart,
duration: resizing.initialDuration,
trimEnd: resizing.initialTrimEnd,
});
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
(leftNeighborBound - resizing.initialStartTime),
getSourceDeltaForClipDelta(
leftNeighborBound - resizing.initialStartTime,
),
)
: 0;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
sourceDuration -
resizing.initialTrimEnd -
getVisibleSourceSpanForDuration(minDurationSeconds);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
@@ -187,13 +292,17 @@ export function useTimelineElementResize({
),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
@@ -233,31 +342,50 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta =
const leftBound = Number.isFinite(leftNeighborBound)
? leftNeighborBound
: 0;
const trimDeltaFromTrimStart =
minTrimStartForNeighbor - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
leftBound - resizing.initialStartTime,
);
const trimDelta = Math.max(
trimDeltaFromTrimStart,
trimDeltaFromStartTime,
);
const newTrimStart = resizing.initialTrimStart + trimDelta;
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
setCurrentTrimStart(minTrimStartForNeighbor);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = minTrimStartForNeighbor;
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
const sourceDuration = getSourceDuration({
trimStart: resizing.initialTrimStart,
duration: resizing.initialDuration,
trimEnd: resizing.initialTrimEnd,
});
const newTrimEnd =
resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
? rightNeighborBound - resizing.initialStartTime
: Infinity;
@@ -268,7 +396,10 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
@@ -277,12 +408,11 @@ export function useTimelineElementResize({
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({
time: Math.min(
resizing.initialDuration + extensionToLimit,
maxAllowedDuration,
),
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
@@ -295,13 +425,15 @@ export function useTimelineElementResize({
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
resizing.initialDuration +
resizing.initialTrimEnd -
maxAllowedDuration,
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(maxAllowedDuration),
)
: 0;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(minDurationSeconds);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
@@ -310,9 +442,12 @@ export function useTimelineElementResize({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
@@ -326,13 +461,16 @@ export function useTimelineElementResize({
[
resizing,
zoomLevel,
activeProject.settings.fps,
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
getDurationForVisibleSourceSpan,
getSourceDeltaForClipDelta,
getSourceDuration,
getVisibleSourceSpanForDuration,
isShiftHeldRef,
],
);
@@ -349,7 +487,12 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
if (
trimStartChanged ||
trimEndChanged ||
startTimeChanged ||
durationChanged
) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
@@ -1,14 +1,10 @@
import { useCallback, useSyncExternalStore } from "react";
import { useCallback } from "react";
import { useEditor } from "@/hooks/use-editor";
type ElementRef = { trackId: string; elementId: string };
import type { ElementRef } from "@/lib/timeline/types";
export function useElementSelection() {
const editor = useEditor();
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
@@ -82,6 +78,30 @@ export function useElementSelection() {
[editor],
);
/**
* Merges elements into the current selection, deduplicating by identity.
* Used for additive box-select where the pre-drag selection is preserved.
*/
const mergeElementsIntoSelection = useCallback(
({ elements }: { elements: ElementRef[] }) => {
const merged = [
...selectedElements.filter(
(selectedElement) =>
!elements.some(
(element) =>
element.trackId === selectedElement.trackId &&
element.elementId === selectedElement.elementId,
),
),
...elements,
];
editor.selection.setSelectedElements({ elements: merged });
},
[selectedElements, editor],
);
/**
* Handles click interaction on an element.
* - Regular click: select only this element
@@ -107,6 +127,7 @@ export function useElementSelection() {
isElementSelected,
selectElement,
setElementSelection,
mergeElementsIntoSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
@@ -15,9 +15,10 @@ import {
} 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 { SelectedKeyframeRef } from "@/lib/animation/types";
import type { TimelineElement } from "@/lib/timeline";
import type { Command } from "@/lib/commands/base-command";
import { registerCanceller } from "@/lib/cancel-interaction";
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set<string>;
@@ -69,6 +70,13 @@ export function useKeyframeDrag({
setDragState(initialDragState);
}, []);
const cancelDrag = useCallback(() => {
pendingDragRef.current = null;
mouseDownXRef.current = null;
setIsPendingDrag(false);
endDrag();
}, [endDrag]);
const commitDrag = useCallback(
({
keyframeRefs,
@@ -107,6 +115,12 @@ export function useKeyframeDrag({
[editor.command, element],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
return registerCanceller({ fn: cancelDrag });
}, [dragState.isDragging, isPendingDrag, cancelDrag]);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@@ -1,6 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import type { SelectedKeyframeRef } from "@/types/animation";
import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({
keyframe,