mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
This commit is contained in:
@@ -1,629 +1,84 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type Edge = "right" | "left" | "bottom";
|
||||
type HandleType = Corner | Edge | "rotation";
|
||||
|
||||
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||
return edge === "right"
|
||||
? { right: true }
|
||||
: edge === "left"
|
||||
? { left: true }
|
||||
: { bottom: true };
|
||||
}
|
||||
|
||||
interface ScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialDistance: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialAngle: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
}
|
||||
|
||||
interface EdgeScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
edge: Edge;
|
||||
rotationRad: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
function clampScaleNonZero(scale: number): number {
|
||||
if (Math.abs(scale) < MIN_SCALE) {
|
||||
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
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" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
import {
|
||||
TransformHandleController,
|
||||
type TransformHandleDeps,
|
||||
} from "@/preview/controllers/transform-handle-controller";
|
||||
|
||||
export function useTransformHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: OnSnapLinesChange;
|
||||
}) {
|
||||
const viewport = usePreviewViewport();
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
|
||||
const scaleStateRef = useRef<ScaleState | null>(null);
|
||||
const rotationStateRef = useRef<RotationState | null>(null);
|
||||
const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
const tracks = useEditor(
|
||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||
);
|
||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const canvasSize = useEditor(
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const deps: TransformHandleDeps = {
|
||||
viewport,
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getSelectedElements: () => selectedElements,
|
||||
getTracks: () => tracks,
|
||||
getCurrentTime: () => currentTime,
|
||||
getMediaAssets: () => mediaAssets,
|
||||
getCanvasSize: () => canvasSize,
|
||||
},
|
||||
timeline: {
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
preview: {
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
const depsRef = useRef<TransformHandleDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const selectedWithBounds: ElementWithBounds | null =
|
||||
selectedElements.length === 1
|
||||
? (elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null)
|
||||
: null;
|
||||
const controllerRef = useRef<TransformHandleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new TransformHandleController({ depsRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const hasVisualSelection =
|
||||
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
|
||||
|
||||
const clearActiveHandleState = useCallback(() => {
|
||||
scaleStateRef.current = null;
|
||||
rotationStateRef.current = null;
|
||||
edgeScaleStateRef.current = null;
|
||||
setActiveHandle(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
|
||||
const releaseCapturedPointer = useCallback(() => {
|
||||
const capture = captureRef.current;
|
||||
if (!capture) return;
|
||||
|
||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||
capture.element.releasePointerCapture(capture.pointerId);
|
||||
}
|
||||
|
||||
captureRef.current = null;
|
||||
}, []);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHandle) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller, controller.isActive]);
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
editor.timeline.discardPreview();
|
||||
clearActiveHandleState();
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeHandle,
|
||||
clearActiveHandleState,
|
||||
editor.timeline,
|
||||
releaseCapturedPointer,
|
||||
]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
const handleCornerPointerDown = useCallback(
|
||||
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
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 / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const shouldClearScaleAnimation =
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}) ||
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
channel: undefined,
|
||||
}),
|
||||
propertyPath: "transform.scaleY",
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handleRotationPointerDown = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
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 = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
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: resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
};
|
||||
setActiveHandle("rotation");
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds, viewport],
|
||||
);
|
||||
|
||||
const handleEdgePointerDown = useCallback(
|
||||
({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
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 baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const rotationRad = (bounds.rotation * Math.PI) / 180;
|
||||
|
||||
const propertyPath =
|
||||
edge === "right" || edge === "left"
|
||||
? "transform.scaleX"
|
||||
: "transform.scaleY";
|
||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
edgeScaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(edge);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (
|
||||
!scaleStateRef.current &&
|
||||
!rotationStateRef.current &&
|
||||
!edgeScaleStateRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const position = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
if (
|
||||
scaleStateRef.current &&
|
||||
activeHandle &&
|
||||
activeHandle !== "rotation"
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentDistance =
|
||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
|
||||
// Use actual element dimensions (base * current scale) so snap
|
||||
// computes the correct edges when scaleX ≠ scaleY
|
||||
const effectiveWidth = baseWidth * initialTransform.scaleX;
|
||||
const effectiveHeight = baseHeight * initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedScale: snappedFactor, activeLines } =
|
||||
isShiftHeldRef.current
|
||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||
: snapScale({
|
||||
proposedScale: scaleFactor,
|
||||
position: initialTransform.position,
|
||||
baseWidth: effectiveWidth,
|
||||
baseHeight: effectiveHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
initialTransform.scaleX * snappedFactor,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
initialTransform.scaleY * snappedFactor,
|
||||
),
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
edgeScaleStateRef.current &&
|
||||
(activeHandle === "right" ||
|
||||
activeHandle === "left" ||
|
||||
activeHandle === "bottom")
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = edgeScaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const xProjection =
|
||||
deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
|
||||
const yProjection =
|
||||
-deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
|
||||
const projection =
|
||||
edge === "right"
|
||||
? xProjection
|
||||
: edge === "left"
|
||||
? -xProjection
|
||||
: yProjection;
|
||||
|
||||
const baseAxisHalf =
|
||||
edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
|
||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||
|
||||
const proposedScaleX =
|
||||
edge === "right" || edge === "left"
|
||||
? proposedScale
|
||||
: initialTransform.scaleX;
|
||||
const proposedScaleY =
|
||||
edge === "bottom" ? proposedScale : initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { x: xSnap, y: ySnap } = isShiftHeldRef.current
|
||||
? {
|
||||
x: {
|
||||
snappedScale: proposedScaleX,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
y: {
|
||||
snappedScale: proposedScaleY,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
}
|
||||
: snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position: initialTransform.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges: getPreferredEdge({ edge }),
|
||||
});
|
||||
|
||||
const relevantSnap =
|
||||
edge === "right" || edge === "left" ? xSnap : ySnap;
|
||||
onSnapLinesChange?.(relevantSnap.activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX:
|
||||
edge === "right" || edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: initialTransform.scaleX,
|
||||
scaleY:
|
||||
edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: initialTransform.scaleY,
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotationStateRef.current && activeHandle === "rotation") {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
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;
|
||||
const newRotate = initialTransform.rotate + deltaAngle;
|
||||
const { snappedRotation } = isShiftHeldRef.current
|
||||
? { snappedRotation: newRotate }
|
||||
: snapRotation({ proposedRotation: newRotate });
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, rotate: snappedRotation },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
canvasSize,
|
||||
editor,
|
||||
isShiftHeldRef,
|
||||
onSnapLinesChange,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (
|
||||
scaleStateRef.current ||
|
||||
rotationStateRef.current ||
|
||||
edgeScaleStateRef.current
|
||||
) {
|
||||
editor.timeline.commitPreview();
|
||||
clearActiveHandleState();
|
||||
}
|
||||
releaseCapturedPointer();
|
||||
}, [clearActiveHandleState, editor, releaseCapturedPointer]);
|
||||
const selectedWithBounds = controller.selectedWithBounds;
|
||||
const hasVisualSelection = selectedWithBounds !== null;
|
||||
|
||||
return {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
activeHandle,
|
||||
handleCornerPointerDown,
|
||||
handleEdgePointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
activeHandle: controller.activeHandle,
|
||||
handleCornerPointerDown: controller.onCornerPointerDown,
|
||||
handleEdgePointerDown: controller.onEdgePointerDown,
|
||||
handleRotationPointerDown: controller.onRotationPointerDown,
|
||||
handlePointerMove: controller.onPointerMove,
|
||||
handlePointerUp: controller.onPointerUp,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user