summaryrefslogtreecommitdiff
path: root/apps/web/src/preview
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/preview')
-rw-r--r--apps/web/src/preview/components/toolbar.tsx25
-rw-r--r--apps/web/src/preview/controllers/preview-interaction-controller.ts583
-rw-r--r--apps/web/src/preview/controllers/transform-handle-controller.ts753
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts491
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts639
5 files changed, 1458 insertions, 1033 deletions
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
index b44e42ef..783dd239 100644
--- a/apps/web/src/preview/components/toolbar.tsx
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
- GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { getGuideById } from "@/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
@@ -24,17 +22,12 @@ import {
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
-import { GridPopover } from "./guide-popover";
-import { usePreviewStore } from "@/preview/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
}: {
onToggleFullscreen: () => void;
}) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const activeGuideDefinition = getGuideById(activeGuide);
-
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
@@ -72,15 +65,13 @@ function TimecodeDisplay() {
);
useEffect(() => {
- const handler = (e: Event) =>
- setCurrentTime((e as CustomEvent<{ time: number }>).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">
@@ -94,7 +85,11 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
- {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
+ {formatTimecode({
+ time: totalDuration,
+ format: "HH:MM:SS:FF",
+ rate: fps,
+ })}
</span>
</div>
);
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts
new file mode 100644
index 00000000..00573662
--- /dev/null
+++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts
@@ -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,
+ },
+ },
+ },
+ })),
+ );
+ }
+}
diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts
new file mode 100644
index 00000000..e9d251e3
--- /dev/null
+++ b/apps/web/src/preview/controllers/transform-handle-controller.ts
@@ -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,
+ },
+ },
+ },
+ ]);
+ }
+}
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
index 78212e74..d093dcf2 100644
--- a/apps/web/src/preview/hooks/use-preview-interaction.ts
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -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,
},
- [],
- );
-
- const commitTextEdit = useCallback(() => {
- const current = editingTextRef.current;
- if (!current) return;
- editingTextRef.current = null;
- editor.timeline.commitPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
- 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]);
-
- 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,
- });
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
},
- [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);
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentTime: () => editor.playback.getCurrentTime(),
+ getMediaAssets: () => editor.media.getAssets(),
+ getCanvasSize: () => editor.project.getActive().settings.canvasSize,
},
- [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 });
+ selection: {
+ getSelected: () => editor.selection.getSelectedElements(),
+ setSelected: (elements) =>
+ editor.selection.setSelectedElements({ elements: [...elements] }),
+ clearSelection: () => editor.selection.clearSelection(),
},
- [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
- );
+ 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 handlePointerUp = useCallback(
- ({ type }: React.PointerEvent) => {
- const dragState = dragStateRef.current;
- if (dragState) {
- if (type === "pointercancel") {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
- }
+ const depsRef = useRef<PreviewInteractionDeps>(deps);
+ depsRef.current = deps;
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- return;
- }
+ const controllerRef = useRef<PreviewInteractionController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new PreviewInteractionController({
+ depsRef: depsRef as PreviewInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(
+ () => controller.subscribe({ listener: rerender }),
+ [controller],
+ );
- if (type !== "pointercancel") {
- const clickTarget = pendingGesture.topmostHit;
- if (!clickTarget) {
- editor.selection.clearSelection();
- } else {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: clickTarget.trackId,
- elementId: clickTarget.elementId,
- },
- ],
- });
- }
- }
+ useEffect(() => {
+ if (!controller.isDragging) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isDragging, controller]);
- 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,
};
}
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
index 030e026c..f4b7e306 100644
--- a/apps/web/src/preview/hooks/use-transform-handles.ts
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -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 elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const selectedWithBounds: ElementWithBounds | null =
- selectedElements.length === 1
- ? (elementsWithBounds.find(
- (entry) =>
- entry.trackId === selectedElements[0].trackId &&
- entry.elementId === selectedElements[0].elementId,
- ) ?? null)
- : null;
-
- 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;
- }, []);
-
- useEffect(() => {
- if (!activeHandle) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearActiveHandleState();
- releaseCapturedPointer();
- },
- });
- }, [
- activeHandle,
- clearActiveHandleState,
- editor.timeline,
- releaseCapturedPointer,
- ]);
-
- 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,
- };
+ const deps: TransformHandleDeps = {
+ viewport,
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
},
- [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,
- };
+ scene: {
+ getSelectedElements: () => selectedElements,
+ getTracks: () => tracks,
+ getCurrentTime: () => currentTime,
+ getMediaAssets: () => mediaAssets,
+ getCanvasSize: () => canvasSize,
},
- [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,
- };
+ timeline: {
+ previewElements: (updates) =>
+ editor.timeline.previewElements({ updates }),
+ commitPreview: () => editor.timeline.commitPreview(),
+ discardPreview: () => editor.timeline.discardPreview(),
},
- [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 }),
- });
+ preview: {
+ onSnapLinesChange,
+ },
+ };
- const relevantSnap =
- edge === "right" || edge === "left" ? xSnap : ySnap;
- onSnapLinesChange?.(relevantSnap.activeLines);
+ const depsRef = useRef<TransformHandleDeps>(deps);
+ depsRef.current = deps;
- 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;
- }
+ const controllerRef = useRef<TransformHandleController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new TransformHandleController({ depsRef });
+ }
+ const controller = controllerRef.current;
- if (rotationStateRef.current && activeHandle === "rotation") {
- const {
- trackId,
- elementId,
- initialTransform,
- initialAngle,
- initialBoundsCx,
- initialBoundsCy,
- } = rotationStateRef.current;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- 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 });
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller, controller.isActive]);
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: { ...initialTransform, rotate: snappedRotation },
- },
- },
- ],
- });
- }
- },
- [
- activeHandle,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
- 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,
};
}