summaryrefslogtreecommitdiff
path: root/apps/web/src/preview/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/preview/hooks
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/preview/hooks')
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts456
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts629
2 files changed, 1085 insertions, 0 deletions
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
new file mode 100644
index 00000000..78212e74
--- /dev/null
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -0,0 +1,456 @@
+import { useCallback, useEffect, useRef, useState } 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 { registerCanceller } from "@/editor/cancel-interaction";
+
+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,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
+}) {
+ 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 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,
+ });
+ },
+ [editor, editingText, isMaskMode, viewport],
+ );
+
+ const handlePointerDown = useCallback(
+ ({
+ clientX,
+ clientY,
+ currentTarget,
+ pointerId,
+ button,
+ }: React.PointerEvent) => {
+ if (editingText) return;
+ if (isMaskMode) return;
+ if (button !== 0) return;
+
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const startPos = viewport.screenToCanvas({
+ clientX,
+ clientY,
+ });
+ if (!startPos) return;
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const hits = getHitElements({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds,
+ });
+ const selectedElements = editor.selection.getSelectedElements();
+ const topmostHit = hits[0] ?? null;
+
+ pendingGestureRef.current = {
+ startX: startPos.x,
+ startY: startPos.y,
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: selectedElements,
+ }),
+ selectedElements,
+ };
+ currentTarget.setPointerCapture(pointerId);
+ },
+ [editor, editingText, isMaskMode, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ clientX, clientY }: React.PointerEvent) => {
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const currentPos = viewport.screenToCanvas({
+ clientX,
+ clientY,
+ });
+ if (!currentPos) return;
+
+ let dragState = dragStateRef.current;
+
+ if (!dragState) {
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ const deltaX = currentPos.x - pendingGesture.startX;
+ const deltaY = currentPos.y - pendingGesture.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+
+ if (!hasMovement) {
+ onSnapLinesChange?.([]);
+ return;
+ }
+
+ const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
+ if (!dragTarget) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ const dragSelection = buildDragSelection({
+ selectedElements: pendingGesture.selectedElements,
+ dragTarget,
+ });
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: dragSelection,
+ });
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
+ );
+
+ if (draggableElements.length === 0) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ if (pendingGesture.selectedHit === null) {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ },
+ ],
+ });
+ }
+
+ dragState = {
+ startX: pendingGesture.startX,
+ startY: pendingGesture.startY,
+ pointerId: pendingGesture.pointerId,
+ captureTarget: pendingGesture.captureTarget,
+ bounds: {
+ width: dragTarget.bounds.width,
+ height: dragTarget.bounds.height,
+ rotation: dragTarget.bounds.rotation,
+ },
+ elements: draggableElements.map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: (element as { transform: Transform }).transform,
+ })),
+ };
+ dragStateRef.current = dragState;
+ pendingGestureRef.current = null;
+ setIsDragging(true);
+ }
+
+ const deltaX = currentPos.x - dragState.startX;
+ const deltaY = currentPos.y - dragState.startY;
+ const firstElement = dragState.elements[0];
+ const proposedPosition = {
+ x: firstElement.initialTransform.position.x + deltaX,
+ y: firstElement.initialTransform.position.y + deltaY,
+ };
+
+ const shouldSnap = !isShiftHeldRef.current;
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { snappedPosition, activeLines } = shouldSnap
+ ? snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize: dragState.bounds,
+ rotation: dragState.bounds.rotation,
+ snapThreshold,
+ })
+ : {
+ snappedPosition: proposedPosition,
+ activeLines: [] as SnapLine[],
+ };
+
+ onSnapLinesChange?.(activeLines);
+
+ const deltaSnappedX =
+ snappedPosition.x - firstElement.initialTransform.position.x;
+ const deltaSnappedY =
+ snappedPosition.y - firstElement.initialTransform.position.y;
+
+ const updates = dragState.elements.map(
+ ({ trackId, elementId, initialTransform }) => ({
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: {
+ x: initialTransform.position.x + deltaSnappedX,
+ y: initialTransform.position.y + deltaSnappedY,
+ },
+ },
+ },
+ }),
+ );
+
+ editor.timeline.previewElements({ updates });
+ },
+ [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
+ );
+
+ const handlePointerUp = useCallback(
+ ({ type }: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (dragState) {
+ if (type === "pointercancel") {
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
+ }
+
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ return;
+ }
+
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ if (type !== "pointercancel") {
+ const clickTarget = pendingGesture.topmostHit;
+ if (!clickTarget) {
+ editor.selection.clearSelection();
+ } else {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ],
+ });
+ }
+ }
+
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ },
+ [editor, onSnapLinesChange, releaseCapturedPointer],
+ );
+
+ return {
+ onPointerDown: handlePointerDown,
+ onPointerMove: handlePointerMove,
+ onPointerUp: handlePointerUp,
+ onDoubleClick: handleDoubleClick,
+ editingText,
+ commitTextEdit,
+ };
+}
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
new file mode 100644
index 00000000..030e026c
--- /dev/null
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -0,0 +1,629 @@
+import { useCallback, useEffect, useRef, useState } 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;
+}
+
+export function useTransformHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+}) {
+ 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,
+ };
+ },
+ [selectedWithBounds],
+ );
+
+ const handleRotationPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+
+ rotationStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialAngle,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ };
+ setActiveHandle("rotation");
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds, viewport],
+ );
+
+ const handleEdgePointerDown = useCallback(
+ ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const rotationRad = (bounds.rotation * Math.PI) / 180;
+
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+ const shouldClearScaleAnimation = hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath,
+ });
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : element.animations;
+
+ edgeScaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(edge);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
+
+ const position = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+
+ if (
+ scaleStateRef.current &&
+ activeHandle &&
+ activeHandle !== "rotation"
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialDistance,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = scaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance =
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const scaleFactor = currentDistance / initialDistance;
+
+ // Use actual element dimensions (base * current scale) so snap
+ // computes the correct edges when scaleX ≠ scaleY
+ const effectiveWidth = baseWidth * initialTransform.scaleX;
+ const effectiveHeight = baseHeight * initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { snappedScale: snappedFactor, activeLines } =
+ isShiftHeldRef.current
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX: clampScaleNonZero(
+ initialTransform.scaleX * snappedFactor,
+ ),
+ scaleY: clampScaleNonZero(
+ initialTransform.scaleY * snappedFactor,
+ ),
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (
+ edgeScaleStateRef.current &&
+ (activeHandle === "right" ||
+ activeHandle === "left" ||
+ activeHandle === "bottom")
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = edgeScaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
+ const projection =
+ edge === "right"
+ ? xProjection
+ : edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ edge === "right" || edge === "left"
+ ? proposedScale
+ : initialTransform.scaleX;
+ const proposedScaleY =
+ edge === "bottom" ? proposedScale : initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = isShiftHeldRef.current
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position: initialTransform.position,
+ baseWidth,
+ baseHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ preferredEdges: getPreferredEdge({ edge }),
+ });
+
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (rotationStateRef.current && activeHandle === "rotation") {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialAngle,
+ initialBoundsCx,
+ initialBoundsCy,
+ } = rotationStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ let deltaAngle = currentAngle - initialAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+ const newRotate = initialTransform.rotate + deltaAngle;
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, rotate: snappedRotation },
+ },
+ },
+ ],
+ });
+ }
+ },
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
+ editor.timeline.commitPreview();
+ clearActiveHandleState();
+ }
+ releaseCapturedPointer();
+ }, [clearActiveHandleState, editor, releaseCapturedPointer]);
+
+ return {
+ selectedWithBounds,
+ hasVisualSelection,
+ activeHandle,
+ handleCornerPointerDown,
+ handleEdgePointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}