754 lines
19 KiB
TypeScript
754 lines
19 KiB
TypeScript
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
import type { MediaAsset } from "@/media/types";
|
|
import {
|
|
getVisibleElementsWithBounds,
|
|
type Corner,
|
|
type Edge,
|
|
type ElementBounds,
|
|
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 { ElementAnimations } from "@/animation/types";
|
|
import type { Transform } from "@/rendering";
|
|
import type {
|
|
ElementRef,
|
|
SceneTracks,
|
|
TimelineElement,
|
|
VisualElement,
|
|
} from "@/timeline";
|
|
|
|
type Point = { readonly x: number; readonly y: number };
|
|
type CanvasSize = { readonly width: number; readonly height: number };
|
|
type HandleType = Corner | Edge | "rotation";
|
|
|
|
interface CapturedPointerState {
|
|
readonly pointerId: number;
|
|
readonly captureTarget: HTMLElement;
|
|
}
|
|
|
|
interface CornerScaleSession extends CapturedPointerState {
|
|
readonly kind: "corner-scale";
|
|
readonly corner: Corner;
|
|
readonly trackId: string;
|
|
readonly elementId: string;
|
|
readonly initialTransform: Transform;
|
|
readonly initialDistance: number;
|
|
readonly initialBoundsCx: number;
|
|
readonly initialBoundsCy: number;
|
|
readonly baseWidth: number;
|
|
readonly baseHeight: number;
|
|
readonly shouldClearScaleAnimation: boolean;
|
|
readonly animationsWithoutScale: ElementAnimations | undefined;
|
|
}
|
|
|
|
interface EdgeScaleSession extends CapturedPointerState {
|
|
readonly kind: "edge-scale";
|
|
readonly edge: Edge;
|
|
readonly trackId: string;
|
|
readonly elementId: string;
|
|
readonly initialTransform: Transform;
|
|
readonly initialBoundsCx: number;
|
|
readonly initialBoundsCy: number;
|
|
readonly baseWidth: number;
|
|
readonly baseHeight: number;
|
|
readonly rotationRad: number;
|
|
readonly shouldClearScaleAnimation: boolean;
|
|
readonly animationsWithoutScale: ElementAnimations | undefined;
|
|
}
|
|
|
|
interface RotationSession extends CapturedPointerState {
|
|
readonly kind: "rotation";
|
|
readonly trackId: string;
|
|
readonly elementId: string;
|
|
readonly initialTransform: Transform;
|
|
readonly initialAngle: number;
|
|
readonly initialBoundsCx: number;
|
|
readonly initialBoundsCy: number;
|
|
}
|
|
|
|
type TransformSession =
|
|
| { readonly kind: "idle" }
|
|
| CornerScaleSession
|
|
| EdgeScaleSession
|
|
| RotationSession;
|
|
|
|
const IDLE_SESSION: TransformSession = { kind: "idle" };
|
|
|
|
interface VisualSelectionContext {
|
|
readonly trackId: string;
|
|
readonly elementId: string;
|
|
readonly element: VisualElement;
|
|
readonly bounds: ElementBounds;
|
|
readonly resolvedTransform: Transform;
|
|
}
|
|
|
|
export interface PreviewViewportAdapter {
|
|
screenToCanvas: ({
|
|
clientX,
|
|
clientY,
|
|
}: {
|
|
clientX: number;
|
|
clientY: number;
|
|
}) => Point | null;
|
|
screenPixelsToLogicalThreshold: ({
|
|
screenPixels,
|
|
}: {
|
|
screenPixels: number;
|
|
}) => Point;
|
|
}
|
|
|
|
export interface InputAdapter {
|
|
isShiftHeld: () => boolean;
|
|
}
|
|
|
|
export interface SceneReader {
|
|
getSelectedElements: () => readonly ElementRef[];
|
|
getTracks: () => SceneTracks;
|
|
getCurrentTime: () => number;
|
|
getMediaAssets: () => MediaAsset[];
|
|
getCanvasSize: () => CanvasSize;
|
|
}
|
|
|
|
export interface TimelinePreviewUpdate {
|
|
readonly trackId: string;
|
|
readonly elementId: string;
|
|
readonly updates: Partial<TimelineElement>;
|
|
}
|
|
|
|
export interface TimelineOps {
|
|
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
|
commitPreview: () => void;
|
|
discardPreview: () => void;
|
|
}
|
|
|
|
export interface PreviewOptions {
|
|
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
|
}
|
|
|
|
export interface TransformHandleDeps {
|
|
viewport: PreviewViewportAdapter;
|
|
input: InputAdapter;
|
|
scene: SceneReader;
|
|
timeline: TimelineOps;
|
|
preview: PreviewOptions;
|
|
}
|
|
|
|
export interface TransformHandleDepsRef {
|
|
readonly current: TransformHandleDeps;
|
|
}
|
|
|
|
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
|
return edge === "right"
|
|
? { right: true }
|
|
: edge === "left"
|
|
? { left: true }
|
|
: { bottom: true };
|
|
}
|
|
|
|
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: ElementBounds;
|
|
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;
|
|
}
|
|
|
|
function buildSelectedWithBounds({
|
|
selectedElements,
|
|
elementsWithBounds,
|
|
}: {
|
|
selectedElements: readonly ElementRef[];
|
|
elementsWithBounds: readonly ElementWithBounds[];
|
|
}): ElementWithBounds | null {
|
|
if (selectedElements.length !== 1) return null;
|
|
|
|
return (
|
|
elementsWithBounds.find(
|
|
(entry) =>
|
|
entry.trackId === selectedElements[0].trackId &&
|
|
entry.elementId === selectedElements[0].elementId,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function buildCornerScaleAnimationReset({
|
|
animations,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
}): {
|
|
shouldClearScaleAnimation: boolean;
|
|
animationsWithoutScale: ElementAnimations | undefined;
|
|
} {
|
|
const shouldClearScaleAnimation =
|
|
hasKeyframesForPath({
|
|
animations,
|
|
propertyPath: "transform.scaleX",
|
|
}) ||
|
|
hasKeyframesForPath({
|
|
animations,
|
|
propertyPath: "transform.scaleY",
|
|
});
|
|
|
|
return {
|
|
shouldClearScaleAnimation,
|
|
animationsWithoutScale: shouldClearScaleAnimation
|
|
? setChannel({
|
|
animations: setChannel({
|
|
animations,
|
|
propertyPath: "transform.scaleX",
|
|
channel: undefined,
|
|
}),
|
|
propertyPath: "transform.scaleY",
|
|
channel: undefined,
|
|
})
|
|
: animations,
|
|
};
|
|
}
|
|
|
|
function buildEdgeScaleAnimationReset({
|
|
animations,
|
|
edge,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
edge: Edge;
|
|
}): {
|
|
shouldClearScaleAnimation: boolean;
|
|
animationsWithoutScale: ElementAnimations | undefined;
|
|
} {
|
|
const propertyPath =
|
|
edge === "right" || edge === "left"
|
|
? "transform.scaleX"
|
|
: "transform.scaleY";
|
|
|
|
const shouldClearScaleAnimation = hasKeyframesForPath({
|
|
animations,
|
|
propertyPath,
|
|
});
|
|
|
|
return {
|
|
shouldClearScaleAnimation,
|
|
animationsWithoutScale: shouldClearScaleAnimation
|
|
? setChannel({
|
|
animations,
|
|
propertyPath,
|
|
channel: undefined,
|
|
})
|
|
: animations,
|
|
};
|
|
}
|
|
|
|
export class TransformHandleController {
|
|
private readonly depsRef: TransformHandleDepsRef;
|
|
private readonly subscribers = new Set<() => void>();
|
|
|
|
private session: TransformSession = IDLE_SESSION;
|
|
|
|
constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) {
|
|
this.depsRef = depsRef;
|
|
|
|
this.onCornerPointerDown = this.onCornerPointerDown.bind(this);
|
|
this.onEdgePointerDown = this.onEdgePointerDown.bind(this);
|
|
this.onRotationPointerDown = this.onRotationPointerDown.bind(this);
|
|
this.onPointerMove = this.onPointerMove.bind(this);
|
|
this.onPointerUp = this.onPointerUp.bind(this);
|
|
}
|
|
|
|
private get deps(): TransformHandleDeps {
|
|
return this.depsRef.current;
|
|
}
|
|
|
|
get selectedWithBounds(): ElementWithBounds | null {
|
|
return buildSelectedWithBounds({
|
|
selectedElements: this.deps.scene.getSelectedElements(),
|
|
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
|
});
|
|
}
|
|
|
|
get activeHandle(): HandleType | null {
|
|
switch (this.session.kind) {
|
|
case "corner-scale":
|
|
return this.session.corner;
|
|
case "edge-scale":
|
|
return this.session.edge;
|
|
case "rotation":
|
|
return "rotation";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
get isActive(): boolean {
|
|
return this.session.kind !== "idle";
|
|
}
|
|
|
|
subscribe(fn: () => void): () => void {
|
|
this.subscribers.add(fn);
|
|
return () => this.subscribers.delete(fn);
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.session.kind !== "idle") {
|
|
const session = this.session;
|
|
this.session = IDLE_SESSION;
|
|
this.deps.timeline.discardPreview();
|
|
this.clearSnapLines();
|
|
this.releaseCapturedPointer(session);
|
|
}
|
|
|
|
this.subscribers.clear();
|
|
}
|
|
|
|
cancel(): void {
|
|
if (this.session.kind === "idle") return;
|
|
|
|
const session = this.session;
|
|
this.session = IDLE_SESSION;
|
|
this.deps.timeline.discardPreview();
|
|
this.clearSnapLines();
|
|
this.releaseCapturedPointer(session);
|
|
this.notify();
|
|
}
|
|
|
|
onCornerPointerDown({
|
|
event,
|
|
corner,
|
|
}: {
|
|
event: ReactPointerEvent;
|
|
corner: Corner;
|
|
}): void {
|
|
const context = this.getSelectedVisualContext();
|
|
if (!context) return;
|
|
|
|
event.stopPropagation();
|
|
|
|
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
|
buildCornerScaleAnimationReset({
|
|
animations: context.element.animations,
|
|
});
|
|
|
|
this.session = {
|
|
kind: "corner-scale",
|
|
corner,
|
|
trackId: context.trackId,
|
|
elementId: context.elementId,
|
|
initialTransform: context.resolvedTransform,
|
|
initialDistance: getCornerDistance({
|
|
bounds: context.bounds,
|
|
corner,
|
|
}),
|
|
initialBoundsCx: context.bounds.cx,
|
|
initialBoundsCy: context.bounds.cy,
|
|
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
|
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
|
shouldClearScaleAnimation,
|
|
animationsWithoutScale,
|
|
pointerId: event.pointerId,
|
|
captureTarget: this.capturePointer({
|
|
target: event.currentTarget as HTMLElement,
|
|
pointerId: event.pointerId,
|
|
}),
|
|
};
|
|
|
|
this.notify();
|
|
}
|
|
|
|
onRotationPointerDown({ event }: { event: ReactPointerEvent }): void {
|
|
const context = this.getSelectedVisualContext();
|
|
if (!context) return;
|
|
|
|
event.stopPropagation();
|
|
|
|
const position = this.deps.viewport.screenToCanvas({
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
if (!position) return;
|
|
|
|
const deltaX = position.x - context.bounds.cx;
|
|
const deltaY = position.y - context.bounds.cy;
|
|
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
|
|
|
this.session = {
|
|
kind: "rotation",
|
|
trackId: context.trackId,
|
|
elementId: context.elementId,
|
|
initialTransform: context.resolvedTransform,
|
|
initialAngle,
|
|
initialBoundsCx: context.bounds.cx,
|
|
initialBoundsCy: context.bounds.cy,
|
|
pointerId: event.pointerId,
|
|
captureTarget: this.capturePointer({
|
|
target: event.currentTarget as HTMLElement,
|
|
pointerId: event.pointerId,
|
|
}),
|
|
};
|
|
|
|
this.notify();
|
|
}
|
|
|
|
onEdgePointerDown({
|
|
event,
|
|
edge,
|
|
}: {
|
|
event: ReactPointerEvent;
|
|
edge: Edge;
|
|
}): void {
|
|
const context = this.getSelectedVisualContext();
|
|
if (!context) return;
|
|
|
|
event.stopPropagation();
|
|
|
|
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
|
buildEdgeScaleAnimationReset({
|
|
animations: context.element.animations,
|
|
edge,
|
|
});
|
|
|
|
this.session = {
|
|
kind: "edge-scale",
|
|
edge,
|
|
trackId: context.trackId,
|
|
elementId: context.elementId,
|
|
initialTransform: context.resolvedTransform,
|
|
initialBoundsCx: context.bounds.cx,
|
|
initialBoundsCy: context.bounds.cy,
|
|
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
|
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
|
rotationRad: (context.bounds.rotation * Math.PI) / 180,
|
|
shouldClearScaleAnimation,
|
|
animationsWithoutScale,
|
|
pointerId: event.pointerId,
|
|
captureTarget: this.capturePointer({
|
|
target: event.currentTarget as HTMLElement,
|
|
pointerId: event.pointerId,
|
|
}),
|
|
};
|
|
|
|
this.notify();
|
|
}
|
|
|
|
onPointerMove({ event }: { event: ReactPointerEvent }): void {
|
|
if (this.session.kind === "idle") return;
|
|
|
|
const position = this.deps.viewport.screenToCanvas({
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
});
|
|
if (!position) return;
|
|
|
|
switch (this.session.kind) {
|
|
case "corner-scale":
|
|
this.previewCornerScale({
|
|
session: this.session,
|
|
position,
|
|
});
|
|
return;
|
|
case "edge-scale":
|
|
this.previewEdgeScale({
|
|
session: this.session,
|
|
position,
|
|
});
|
|
return;
|
|
case "rotation":
|
|
this.previewRotation({
|
|
session: this.session,
|
|
position,
|
|
});
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
onPointerUp(): void {
|
|
if (this.session.kind === "idle") return;
|
|
|
|
const session = this.session;
|
|
this.session = IDLE_SESSION;
|
|
this.deps.timeline.commitPreview();
|
|
this.clearSnapLines();
|
|
this.releaseCapturedPointer(session);
|
|
this.notify();
|
|
}
|
|
|
|
private notify(): void {
|
|
for (const fn of this.subscribers) fn();
|
|
}
|
|
|
|
private clearSnapLines(): void {
|
|
this.deps.preview.onSnapLinesChange?.([]);
|
|
}
|
|
|
|
private capturePointer({
|
|
target,
|
|
pointerId,
|
|
}: {
|
|
target: HTMLElement;
|
|
pointerId: number;
|
|
}): HTMLElement {
|
|
target.setPointerCapture(pointerId);
|
|
return target;
|
|
}
|
|
|
|
private releaseCapturedPointer(pointerState: CapturedPointerState): void {
|
|
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
|
return;
|
|
}
|
|
|
|
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
|
}
|
|
|
|
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
|
return getVisibleElementsWithBounds({
|
|
tracks: this.deps.scene.getTracks(),
|
|
currentTime: this.deps.scene.getCurrentTime(),
|
|
canvasSize: this.deps.scene.getCanvasSize(),
|
|
mediaAssets: this.deps.scene.getMediaAssets(),
|
|
});
|
|
}
|
|
|
|
private getSelectedVisualContext(): VisualSelectionContext | null {
|
|
const selectedWithBounds = this.selectedWithBounds;
|
|
if (!selectedWithBounds) return null;
|
|
if (!isVisualElement(selectedWithBounds.element)) return null;
|
|
|
|
const localTime = getElementLocalTime({
|
|
timelineTime: this.deps.scene.getCurrentTime(),
|
|
elementStartTime: selectedWithBounds.element.startTime,
|
|
elementDuration: selectedWithBounds.element.duration,
|
|
});
|
|
|
|
return {
|
|
trackId: selectedWithBounds.trackId,
|
|
elementId: selectedWithBounds.elementId,
|
|
element: selectedWithBounds.element,
|
|
bounds: selectedWithBounds.bounds,
|
|
resolvedTransform: resolveTransformAtTime({
|
|
baseTransform: selectedWithBounds.element.transform,
|
|
animations: selectedWithBounds.element.animations,
|
|
localTime,
|
|
}),
|
|
};
|
|
}
|
|
|
|
private previewCornerScale({
|
|
session,
|
|
position,
|
|
}: {
|
|
session: CornerScaleSession;
|
|
position: Point;
|
|
}): void {
|
|
const deltaX = position.x - session.initialBoundsCx;
|
|
const deltaY = position.y - session.initialBoundsCy;
|
|
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
|
const scaleFactor = currentDistance / session.initialDistance;
|
|
|
|
// Use actual element dimensions (base * current scale) so snapping is
|
|
// computed from the rendered geometry when scaleX != scaleY.
|
|
const effectiveWidth = session.baseWidth * session.initialTransform.scaleX;
|
|
const effectiveHeight =
|
|
session.baseHeight * session.initialTransform.scaleY;
|
|
|
|
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
|
});
|
|
const { snappedScale, activeLines } = this.deps.input.isShiftHeld()
|
|
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
|
: snapScale({
|
|
proposedScale: scaleFactor,
|
|
position: session.initialTransform.position,
|
|
baseWidth: effectiveWidth,
|
|
baseHeight: effectiveHeight,
|
|
rotation: session.initialTransform.rotate,
|
|
canvasSize: this.deps.scene.getCanvasSize(),
|
|
snapThreshold,
|
|
});
|
|
|
|
this.deps.preview.onSnapLinesChange?.(activeLines);
|
|
|
|
this.deps.timeline.previewElements([
|
|
{
|
|
trackId: session.trackId,
|
|
elementId: session.elementId,
|
|
updates: {
|
|
transform: {
|
|
...session.initialTransform,
|
|
scaleX: clampScaleNonZero(
|
|
session.initialTransform.scaleX * snappedScale,
|
|
),
|
|
scaleY: clampScaleNonZero(
|
|
session.initialTransform.scaleY * snappedScale,
|
|
),
|
|
},
|
|
...(session.shouldClearScaleAnimation && {
|
|
animations: session.animationsWithoutScale,
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
private previewEdgeScale({
|
|
session,
|
|
position,
|
|
}: {
|
|
session: EdgeScaleSession;
|
|
position: Point;
|
|
}): void {
|
|
const deltaX = position.x - session.initialBoundsCx;
|
|
const deltaY = position.y - session.initialBoundsCy;
|
|
const xProjection =
|
|
deltaX * Math.cos(session.rotationRad) +
|
|
deltaY * Math.sin(session.rotationRad);
|
|
const yProjection =
|
|
-deltaX * Math.sin(session.rotationRad) +
|
|
deltaY * Math.cos(session.rotationRad);
|
|
const projection =
|
|
session.edge === "right"
|
|
? xProjection
|
|
: session.edge === "left"
|
|
? -xProjection
|
|
: yProjection;
|
|
|
|
const baseAxisHalf =
|
|
session.edge === "right" || session.edge === "left"
|
|
? session.baseWidth / 2
|
|
: session.baseHeight / 2;
|
|
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
|
|
|
const proposedScaleX =
|
|
session.edge === "right" || session.edge === "left"
|
|
? proposedScale
|
|
: session.initialTransform.scaleX;
|
|
const proposedScaleY =
|
|
session.edge === "bottom"
|
|
? proposedScale
|
|
: session.initialTransform.scaleY;
|
|
|
|
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
|
});
|
|
const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld()
|
|
? {
|
|
x: {
|
|
snappedScale: proposedScaleX,
|
|
snapDistance: Infinity,
|
|
activeLines: [] as SnapLine[],
|
|
},
|
|
y: {
|
|
snappedScale: proposedScaleY,
|
|
snapDistance: Infinity,
|
|
activeLines: [] as SnapLine[],
|
|
},
|
|
}
|
|
: snapScaleAxes({
|
|
proposedScaleX,
|
|
proposedScaleY,
|
|
position: session.initialTransform.position,
|
|
baseWidth: session.baseWidth,
|
|
baseHeight: session.baseHeight,
|
|
rotation: session.initialTransform.rotate,
|
|
canvasSize: this.deps.scene.getCanvasSize(),
|
|
snapThreshold,
|
|
preferredEdges: getPreferredEdge({ edge: session.edge }),
|
|
});
|
|
|
|
const relevantSnap =
|
|
session.edge === "right" || session.edge === "left" ? xSnap : ySnap;
|
|
this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines);
|
|
|
|
this.deps.timeline.previewElements([
|
|
{
|
|
trackId: session.trackId,
|
|
elementId: session.elementId,
|
|
updates: {
|
|
transform: {
|
|
...session.initialTransform,
|
|
scaleX:
|
|
session.edge === "right" || session.edge === "left"
|
|
? xSnap.snappedScale
|
|
: session.initialTransform.scaleX,
|
|
scaleY:
|
|
session.edge === "bottom"
|
|
? ySnap.snappedScale
|
|
: session.initialTransform.scaleY,
|
|
},
|
|
...(session.shouldClearScaleAnimation && {
|
|
animations: session.animationsWithoutScale,
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
private previewRotation({
|
|
session,
|
|
position,
|
|
}: {
|
|
session: RotationSession;
|
|
position: Point;
|
|
}): void {
|
|
const deltaX = position.x - session.initialBoundsCx;
|
|
const deltaY = position.y - session.initialBoundsCy;
|
|
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
|
let deltaAngle = currentAngle - session.initialAngle;
|
|
if (deltaAngle > 180) deltaAngle -= 360;
|
|
if (deltaAngle < -180) deltaAngle += 360;
|
|
|
|
const newRotate = session.initialTransform.rotate + deltaAngle;
|
|
const { snappedRotation } = this.deps.input.isShiftHeld()
|
|
? { snappedRotation: newRotate }
|
|
: snapRotation({ proposedRotation: newRotate });
|
|
|
|
this.deps.timeline.previewElements([
|
|
{
|
|
trackId: session.trackId,
|
|
elementId: session.elementId,
|
|
updates: {
|
|
transform: {
|
|
...session.initialTransform,
|
|
rotate: snappedRotation,
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
}
|