summaryrefslogtreecommitdiff
path: root/apps/web/src/preview/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/preview/hooks
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
Diffstat (limited to 'apps/web/src/preview/hooks')
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts491
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts639
2 files changed, 112 insertions, 1018 deletions
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,
};
}