summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-transform-handles.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/hooks/use-transform-handles.ts
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/hooks/use-transform-handles.ts')
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts455
1 files changed, 341 insertions, 114 deletions
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index fb561396..a01fb361 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,4 +1,6 @@
-import { useCallback, useRef, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -6,14 +8,12 @@ import {
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
@@ -22,11 +22,25 @@ import {
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
-import type { Transform } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { registerCanceller } from "@/lib/cancel-interaction";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type HandleType = Corner | "rotation";
+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;
@@ -50,26 +64,25 @@ interface RotationState {
initialBoundsCy: number;
}
-function areSnapLinesEqual({
- previousLines,
- nextLines,
-}: {
- previousLines: SnapLine[];
- nextLines: SnapLine[];
-}): boolean {
- if (previousLines.length !== nextLines.length) {
- return false;
- }
- for (const [index, line] of previousLines.entries()) {
- const nextLine = nextLines[index];
- if (!nextLine) {
- return false;
- }
- if (line.type !== nextLine.type || line.position !== nextLine.position) {
- return false;
- }
+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 true;
+ return scale;
}
function getCornerDistance({
@@ -102,29 +115,30 @@ function getCornerDistance({
}
export function useTransformHandles({
- canvasRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const snapLinesRef = useRef<SnapLine[]>([]);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
-
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
+ const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -145,6 +159,37 @@ export function useTransformHandles({
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;
@@ -165,14 +210,19 @@ export function useTransformHandles({
});
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scale;
- const baseHeight = bounds.height / resolvedTransform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
- !!element.animations?.channels["transform.scale"];
+ !!element.animations?.channels["transform.scaleX"] ||
+ !!element.animations?.channels["transform.scaleY"];
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
- animations: element.animations,
- propertyPath: "transform.scale",
+ animations: setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
@@ -190,14 +240,19 @@ export function useTransformHandles({
animationsWithoutScale,
};
setActiveHandle(corner);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ 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 || !canvasRef.current) return;
+ if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
@@ -214,14 +269,14 @@ export function useTransformHandles({
localTime,
});
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ 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,
@@ -232,21 +287,91 @@ export function useTransformHandles({
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
- [selectedWithBounds, canvasRef],
+ [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 =
+ !!element.animations?.channels[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 (!canvasRef.current) return;
- if (!scaleStateRef.current && !rotationStateRef.current) return;
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
+ if (!position) return;
if (
scaleStateRef.current &&
@@ -266,58 +391,157 @@ export function useTransformHandles({
animationsWithoutScale,
} = scaleStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance =
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
- const proposedScale = Math.max(
- MIN_SCALE,
- initialTransform.scale * scaleFactor,
- );
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ // 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 shouldSnap = !isShiftHeldRef.current;
- const { snappedScale, activeLines } = shouldSnap
- ? snapScale({
- proposedScale,
+ 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,
- })
- : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
-
- const isSameLines = areSnapLinesEqual({
- previousLines: snapLinesRef.current,
- nextLines: activeLines,
- });
-
- if (!isSameLines) {
- snapLinesRef.current = activeLines;
- setSnapLines(activeLines);
- }
+ preferredEdges: getPreferredEdge({ edge }),
+ });
- const updates: {
- transform: Transform;
- animations?: ElementAnimations;
- } = {
- transform: { ...initialTransform, scale: snappedScale },
- };
- if (shouldClearScaleAnimation) {
- updates.animations = animationsWithoutScale;
- }
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
},
],
});
@@ -334,17 +558,16 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ 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 shouldSnapRotation = !isShiftHeldRef.current;
- const { snappedRotation } = shouldSnapRotation
- ? snapRotation({ proposedRotation: newRotate })
- : { snappedRotation: newRotate };
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
editor.timeline.previewElements({
updates: [
@@ -359,32 +582,36 @@ export function useTransformHandles({
});
}
},
- [activeHandle, canvasRef, editor, isShiftHeldRef],
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
);
- const handlePointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (scaleStateRef.current || rotationStateRef.current) {
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
editor.timeline.commitPreview();
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- setActiveHandle(null);
- snapLinesRef.current = [];
- setSnapLines([]);
+ clearActiveHandleState();
}
- (event.currentTarget as HTMLElement).releasePointerCapture(
- event.pointerId,
- );
+ releaseCapturedPointer();
},
- [editor],
+ [clearActiveHandleState, editor, releaseCapturedPointer],
);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,