mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user