mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: thinner hooks
This commit is contained in:
@@ -73,15 +73,13 @@ function TimecodeDisplay() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) =>
|
||||
setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
||||
window.addEventListener("playback-update", handler);
|
||||
window.addEventListener("playback-seek", handler);
|
||||
const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime);
|
||||
const unsubscribeSeek = editor.playback.onSeek(setCurrentTime);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handler);
|
||||
window.removeEventListener("playback-seek", handler);
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSeek();
|
||||
};
|
||||
}, []);
|
||||
}, [editor.playback]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
VisualElement,
|
||||
} from "@/timeline";
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
const PRIMARY_POINTER_BUTTON = 0;
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
interface CapturedPointerState {
|
||||
readonly pointerId: number;
|
||||
readonly captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGesture extends CapturedPointerState {
|
||||
readonly kind: "pending";
|
||||
readonly origin: Point;
|
||||
readonly topmostHit: ElementWithBounds | null;
|
||||
readonly selectedHit: ElementWithBounds | null;
|
||||
readonly selectedElements: readonly ElementRef[];
|
||||
}
|
||||
|
||||
interface DragElementSnapshot {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
}
|
||||
|
||||
interface DraggingGesture extends CapturedPointerState {
|
||||
readonly kind: "dragging";
|
||||
readonly origin: Point;
|
||||
readonly bounds: {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rotation: number;
|
||||
};
|
||||
readonly elements: readonly DragElementSnapshot[];
|
||||
}
|
||||
|
||||
type GestureSession =
|
||||
| { readonly kind: "idle" }
|
||||
| PendingGesture
|
||||
| DraggingGesture;
|
||||
|
||||
const IDLE_GESTURE: GestureSession = { kind: "idle" };
|
||||
|
||||
export interface EditingTextState {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly element: TextElement;
|
||||
}
|
||||
|
||||
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 {
|
||||
getTracks: () => SceneTracks;
|
||||
getCurrentTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
getCanvasSize: () => TCanvasSize;
|
||||
}
|
||||
|
||||
export interface SelectionApi {
|
||||
getSelected: () => readonly ElementRef[];
|
||||
setSelected: (elements: readonly ElementRef[]) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export interface TimelinePreviewUpdate {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
getElementsWithTracks: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: readonly ElementRef[];
|
||||
}) => Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||
commitPreview: () => void;
|
||||
discardPreview: () => void;
|
||||
}
|
||||
|
||||
export interface PlaybackApi {
|
||||
getIsPlaying: () => boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
isMaskMode: () => boolean;
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDeps {
|
||||
viewport: PreviewViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
selection: SelectionApi;
|
||||
timeline: TimelineOps;
|
||||
playback: PlaybackApi;
|
||||
preview: PreviewOptions;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDepsRef {
|
||||
readonly current: PreviewInteractionDeps;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: readonly ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function movedPastDragThreshold({
|
||||
current,
|
||||
origin,
|
||||
}: {
|
||||
current: Point;
|
||||
origin: Point;
|
||||
}): boolean {
|
||||
return (
|
||||
Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function toDragElementSnapshots({
|
||||
elementsWithTracks,
|
||||
}: {
|
||||
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
}): DragElementSnapshot[] {
|
||||
const isVisualTrackedElement = (value: {
|
||||
track: TimelineTrack;
|
||||
element: TimelineElement;
|
||||
}): value is { track: TimelineTrack; element: VisualElement } =>
|
||||
isVisualElement(value.element);
|
||||
|
||||
return elementsWithTracks
|
||||
.filter(isVisualTrackedElement)
|
||||
.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: element.transform,
|
||||
}));
|
||||
}
|
||||
|
||||
export class PreviewInteractionController {
|
||||
private readonly depsRef: PreviewInteractionDepsRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private gesture: GestureSession = IDLE_GESTURE;
|
||||
private editingTextState: EditingTextState | null = null;
|
||||
private wasPlaying: boolean;
|
||||
private unsubscribePlayback: (() => void) | null = null;
|
||||
|
||||
constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
|
||||
this.depsRef = depsRef;
|
||||
this.wasPlaying = this.deps.playback.getIsPlaying();
|
||||
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.commitTextEdit = this.commitTextEdit.bind(this);
|
||||
this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
|
||||
|
||||
this.unsubscribePlayback = this.deps.playback.subscribe(
|
||||
this.handlePlaybackChange,
|
||||
);
|
||||
}
|
||||
|
||||
private get deps(): PreviewInteractionDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this.gesture.kind === "dragging";
|
||||
}
|
||||
|
||||
get editingText(): EditingTextState | null {
|
||||
return this.editingTextState;
|
||||
}
|
||||
|
||||
subscribe({ listener }: { listener: () => void }): () => void {
|
||||
this.subscribers.add(listener);
|
||||
return () => this.subscribers.delete(listener);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribePlayback?.();
|
||||
this.unsubscribePlayback = null;
|
||||
this.abortActiveGesture();
|
||||
this.editingTextState = null;
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
this.abortActiveGesture();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private abortActiveGesture(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
|
||||
if (this.gesture.kind === "dragging") {
|
||||
this.deps.timeline.discardPreview();
|
||||
}
|
||||
|
||||
this.releaseCapturedPointer({ pointerState: this.gesture });
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
}
|
||||
|
||||
commitTextEdit(): void {
|
||||
if (!this.editingTextState) return;
|
||||
|
||||
this.editingTextState = null;
|
||||
this.deps.timeline.commitPreview();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
|
||||
if (this.editingTextState || this.deps.preview.isMaskMode()) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
this.editingTextState = {
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: hit.element,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onPointerDown({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: ReactPointerEvent): void {
|
||||
if (this.editingTextState) return;
|
||||
if (this.deps.preview.isMaskMode()) return;
|
||||
if (button !== PRIMARY_POINTER_BUTTON) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
const selectedElements = this.deps.selection.getSelected();
|
||||
|
||||
this.gesture = {
|
||||
kind: "pending",
|
||||
origin: startPos,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit: hits[0] ?? null,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: [...selectedElements],
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
}
|
||||
|
||||
onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
|
||||
const currentPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
if (this.gesture.kind === "pending") {
|
||||
const pending = this.gesture;
|
||||
if (
|
||||
!movedPastDragThreshold({
|
||||
current: currentPos,
|
||||
origin: pending.origin,
|
||||
})
|
||||
) {
|
||||
this.clearSnapLines();
|
||||
return;
|
||||
}
|
||||
|
||||
this.beginDragFromPending({ pending });
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "dragging") return;
|
||||
|
||||
this.updateDragPreview({
|
||||
drag: this.gesture,
|
||||
currentPos,
|
||||
});
|
||||
}
|
||||
|
||||
onPointerUp({ type }: ReactPointerEvent): void {
|
||||
if (this.gesture.kind === "dragging") {
|
||||
const drag = this.gesture;
|
||||
|
||||
if (type === "pointercancel") {
|
||||
this.deps.timeline.discardPreview();
|
||||
} else {
|
||||
this.deps.timeline.commitPreview();
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: drag });
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "pending") return;
|
||||
|
||||
const pending = this.gesture;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pending.topmostHit;
|
||||
if (!clickTarget) {
|
||||
this.deps.selection.clearSelection();
|
||||
} else {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.subscribers) listener();
|
||||
}
|
||||
|
||||
private clearSnapLines(): void {
|
||||
this.deps.preview.onSnapLinesChange?.([]);
|
||||
}
|
||||
|
||||
private releaseCapturedPointer({
|
||||
pointerState,
|
||||
}: {
|
||||
pointerState: CapturedPointerState | null;
|
||||
}): void {
|
||||
if (!pointerState) return;
|
||||
|
||||
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 handlePlaybackChange(): void {
|
||||
const isPlaying = this.deps.playback.getIsPlaying();
|
||||
if (isPlaying && !this.wasPlaying && this.editingTextState) {
|
||||
this.commitTextEdit();
|
||||
}
|
||||
this.wasPlaying = isPlaying;
|
||||
}
|
||||
|
||||
private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
|
||||
const dragTarget = pending.selectedHit ?? pending.topmostHit;
|
||||
if (!dragTarget) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pending.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const draggableElements = toDragElementSnapshots({
|
||||
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
}),
|
||||
});
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.selectedHit === null) {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.gesture = {
|
||||
kind: "dragging",
|
||||
origin: pending.origin,
|
||||
pointerId: pending.pointerId,
|
||||
captureTarget: pending.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private updateDragPreview({
|
||||
drag,
|
||||
currentPos,
|
||||
}: {
|
||||
drag: DraggingGesture;
|
||||
currentPos: Point;
|
||||
}): void {
|
||||
const firstElement = drag.elements[0];
|
||||
if (!firstElement) return;
|
||||
|
||||
const deltaX = currentPos.x - drag.origin.x;
|
||||
const deltaY = currentPos.y - drag.origin.y;
|
||||
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !this.deps.input.isShiftHeld();
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
elementSize: drag.bounds,
|
||||
rotation: drag.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
this.deps.timeline.previewElements(
|
||||
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementRef, TextElement } from "@/timeline";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { SnapLine } from "@/preview/preview-snap";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import {
|
||||
PreviewInteractionController,
|
||||
type PreviewInteractionDeps,
|
||||
type PreviewInteractionDepsRef,
|
||||
} from "@/preview/controllers/preview-interaction-controller";
|
||||
|
||||
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
|
||||
interface CapturedPointerState {
|
||||
pointerId: number;
|
||||
captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGestureState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
topmostHit: ElementWithBounds | null;
|
||||
selectedHit: ElementWithBounds | null;
|
||||
selectedElements: ElementRef[];
|
||||
}
|
||||
|
||||
interface DragState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
bounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
elements: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
}>;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function usePreviewInteraction({
|
||||
onSnapLinesChange,
|
||||
isMaskMode = false,
|
||||
@@ -102,355 +22,74 @@ export function usePreviewInteraction({
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [editingText, setEditingText] = useState<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
originalOpacity: number;
|
||||
} | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingGestureRef = useRef<PendingGestureState | null>(null);
|
||||
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
|
||||
const editingTextRef = useRef(editingText);
|
||||
editingTextRef.current = editingText;
|
||||
|
||||
const releaseCapturedPointer = useCallback(
|
||||
(pointerState: CapturedPointerState | null) => {
|
||||
if (!pointerState) return;
|
||||
|
||||
if (
|
||||
!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
const deps: PreviewInteractionDeps = {
|
||||
viewport: {
|
||||
screenToCanvas: viewport.screenToCanvas,
|
||||
screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold,
|
||||
},
|
||||
[],
|
||||
);
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||
getMediaAssets: () => editor.media.getAssets(),
|
||||
getCanvasSize: () => editor.project.getActive().settings.canvasSize,
|
||||
},
|
||||
selection: {
|
||||
getSelected: () => editor.selection.getSelectedElements(),
|
||||
setSelected: (elements) =>
|
||||
editor.selection.setSelectedElements({ elements: [...elements] }),
|
||||
clearSelection: () => editor.selection.clearSelection(),
|
||||
},
|
||||
timeline: {
|
||||
getElementsWithTracks: ({ elements }) =>
|
||||
editor.timeline.getElementsWithTracks({ elements: [...elements] }),
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates: [...updates] }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
playback: {
|
||||
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||
subscribe: (listener) => editor.playback.subscribe(listener),
|
||||
},
|
||||
preview: {
|
||||
isMaskMode: () => isMaskMode,
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const commitTextEdit = useCallback(() => {
|
||||
const current = editingTextRef.current;
|
||||
if (!current) return;
|
||||
editingTextRef.current = null;
|
||||
editor.timeline.commitPreview();
|
||||
setEditingText(null);
|
||||
}, [editor.timeline]);
|
||||
const depsRef = useRef<PreviewInteractionDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const controllerRef = useRef<PreviewInteractionController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new PreviewInteractionController({
|
||||
depsRef: depsRef as PreviewInteractionDepsRef,
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(
|
||||
() => controller.subscribe({ listener: rerender }),
|
||||
[controller],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = editor.playback.subscribe(() => {
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
|
||||
commitTextEdit();
|
||||
}
|
||||
wasPlayingRef.current = isPlaying;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [editor.playback, commitTextEdit]);
|
||||
if (!controller.isDragging) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isDragging, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState) return;
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
},
|
||||
});
|
||||
}, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (editingText || isMaskMode) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
const textElement = hit.element as TextElement;
|
||||
setEditingText({
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: textElement,
|
||||
originalOpacity: textElement.opacity,
|
||||
});
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: React.PointerEvent) => {
|
||||
if (editingText) return;
|
||||
if (isMaskMode) return;
|
||||
if (button !== 0) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
const selectedElements = editor.selection.getSelectedElements();
|
||||
const topmostHit = hits[0] ?? null;
|
||||
|
||||
pendingGestureRef.current = {
|
||||
startX: startPos.x,
|
||||
startY: startPos.y,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: selectedElements,
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ clientX, clientY }: React.PointerEvent) => {
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const currentPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
let dragState = dragStateRef.current;
|
||||
|
||||
if (!dragState) {
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
const deltaX = currentPos.x - pendingGesture.startX;
|
||||
const deltaY = currentPos.y - pendingGesture.startY;
|
||||
const hasMovement =
|
||||
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
|
||||
|
||||
if (!hasMovement) {
|
||||
onSnapLinesChange?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
|
||||
if (!dragTarget) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pendingGesture.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
});
|
||||
const draggableElements = elementsWithTracks.filter(({ element }) =>
|
||||
isVisualElement(element),
|
||||
);
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingGesture.selectedHit === null) {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
dragState = {
|
||||
startX: pendingGesture.startX,
|
||||
startY: pendingGesture.startY,
|
||||
pointerId: pendingGesture.pointerId,
|
||||
captureTarget: pendingGesture.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: (element as { transform: Transform }).transform,
|
||||
})),
|
||||
};
|
||||
dragStateRef.current = dragState;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
const deltaX = currentPos.x - dragState.startX;
|
||||
const deltaY = currentPos.y - dragState.startY;
|
||||
const firstElement = dragState.elements[0];
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize: dragState.bounds,
|
||||
rotation: dragState.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
const updates = dragState.elements.map(
|
||||
({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
editor.timeline.previewElements({ updates });
|
||||
},
|
||||
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
({ type }: React.PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (dragState) {
|
||||
if (type === "pointercancel") {
|
||||
editor.timeline.discardPreview();
|
||||
} else {
|
||||
editor.timeline.commitPreview();
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pendingGesture.topmostHit;
|
||||
if (!clickTarget) {
|
||||
editor.selection.clearSelection();
|
||||
} else {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
},
|
||||
[editor, onSnapLinesChange, releaseCapturedPointer],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onDoubleClick: handleDoubleClick,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
onPointerDown: controller.onPointerDown,
|
||||
onPointerMove: controller.onPointerMove,
|
||||
onPointerUp: controller.onPointerUp,
|
||||
onDoubleClick: controller.onDoubleClick,
|
||||
editingText: controller.editingText,
|
||||
commitTextEdit: controller.commitTextEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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