mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -4,19 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import { getElementsAtTime } from "@/lib/timeline";
|
||||
|
||||
export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { selectedElements, setElementSelection } = useElementSelection();
|
||||
const {
|
||||
clipboard,
|
||||
setClipboard,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
|
||||
const clipboard = useTimelineStore((s) => s.clipboard);
|
||||
const setClipboard = useTimelineStore((s) => s.setClipboard);
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
@@ -209,6 +209,11 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"delete-selected",
|
||||
() => {
|
||||
if (selectedKeyframes.length > 0) {
|
||||
editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
|
||||
clearKeyframeSelection();
|
||||
return;
|
||||
}
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +240,19 @@ export function useEditorActions() {
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"deselect-all",
|
||||
() => {
|
||||
setElementSelection({ elements: [] });
|
||||
clearKeyframeSelection();
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLButtonElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"duplicate-selected",
|
||||
() => {
|
||||
@@ -278,7 +296,7 @@ export function useEditorActions() {
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { ...elementWithoutId } = element;
|
||||
const { id: _, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -26,12 +26,14 @@ import type {
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
tracksScrollRef?: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
@@ -104,11 +106,16 @@ export function useTimelineDragDrop({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
@@ -131,8 +138,8 @@ export function useTimelineDragDrop({
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
|
||||
const targetElementTypes =
|
||||
dragData?.type === "effect"
|
||||
@@ -162,6 +169,7 @@ export function useTimelineDragDrop({
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
@@ -200,19 +208,6 @@ export function useTimelineDragDrop({
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
@@ -221,12 +216,26 @@ export function useTimelineDragDrop({
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeStickerDrop = useCallback(
|
||||
@@ -237,31 +246,32 @@ export function useTimelineDragDrop({
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "sticker",
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildStickerElement({
|
||||
stickerId: dragData.stickerId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
@@ -276,18 +286,6 @@ export function useTimelineDragDrop({
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
let trackId: string;
|
||||
|
||||
if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: trackType,
|
||||
index: target.trackIndex,
|
||||
});
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
@@ -299,31 +297,63 @@ export function useTimelineDragDrop({
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, mediaAssets, tracks],
|
||||
[editor.command, editor.timeline, mediaAssets, tracks],
|
||||
);
|
||||
|
||||
const executeEffectDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: EffectDragData;
|
||||
}) => {
|
||||
if (target.targetElement) {
|
||||
editor.timeline.addClipEffect({
|
||||
trackId: target.targetElement.trackId,
|
||||
elementId: target.targetElement.elementId,
|
||||
effectType: dragData.effectType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||
let trackId: string;
|
||||
|
||||
if (effectTrack && !target.targetElement) {
|
||||
if (effectTrack) {
|
||||
trackId = effectTrack.id;
|
||||
} else if (target.targetElement) {
|
||||
trackId = effectTrack?.id ?? editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: 0,
|
||||
});
|
||||
} else if (target.isNewTrack) {
|
||||
trackId = editor.timeline.addTrack({
|
||||
type: "effect",
|
||||
index: target.trackIndex,
|
||||
const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element: buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track || track.type !== "effect") return;
|
||||
@@ -340,7 +370,7 @@ export function useTimelineDragDrop({
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor.timeline, tracks],
|
||||
[editor.command, editor.timeline, tracks],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
@@ -452,12 +482,18 @@ export function useTimelineDragDrop({
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
|
||||
const mouseY =
|
||||
e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
@@ -478,6 +514,7 @@ export function useTimelineDragDrop({
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
@@ -18,7 +17,13 @@ import {
|
||||
type SnapLine,
|
||||
} from "@/lib/preview/preview-snap";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/lib/animation";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type HandleType = Corner | "rotation";
|
||||
@@ -32,6 +37,8 @@ interface ScaleState {
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
@@ -78,16 +85,16 @@ function getCornerDistance({
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
@@ -114,6 +121,8 @@ export function useTransformHandles({
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
@@ -144,19 +153,41 @@ export function useTransformHandles({
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const initialDistance = getCornerDistance({ bounds, corner });
|
||||
const baseWidth = bounds.width / element.transform.scale;
|
||||
const baseHeight = bounds.height / element.transform.scale;
|
||||
const baseWidth = bounds.width / resolvedTransform.scale;
|
||||
const baseHeight = bounds.height / resolvedTransform.scale;
|
||||
const shouldClearScaleAnimation =
|
||||
!!element.animations?.channels["transform.scale"];
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scale",
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialTransform: resolvedTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
@@ -172,19 +203,30 @@ export function useTransformHandles({
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const position = screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
canvas: canvasRef.current,
|
||||
});
|
||||
const dx = position.x - bounds.cx;
|
||||
const dy = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const deltaX = position.x - bounds.cx;
|
||||
const deltaY = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
rotationStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: element.transform,
|
||||
initialTransform: resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
@@ -220,11 +262,13 @@ export function useTransformHandles({
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
const proposedScale = Math.max(
|
||||
MIN_SCALE,
|
||||
@@ -258,14 +302,22 @@ export function useTransformHandles({
|
||||
setSnapLines(activeLines);
|
||||
}
|
||||
|
||||
const updates: {
|
||||
transform: Transform;
|
||||
animations?: ElementAnimations;
|
||||
} = {
|
||||
transform: { ...initialTransform, scale: snappedScale },
|
||||
};
|
||||
if (shouldClearScaleAnimation) {
|
||||
updates.animations = animationsWithoutScale;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, scale: snappedScale },
|
||||
},
|
||||
updates,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -282,9 +334,9 @@ export function useTransformHandles({
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
const dx = position.x - initialBoundsCx;
|
||||
const dy = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
Reference in New Issue
Block a user