diff options
Diffstat (limited to 'apps/web/src/hooks/use-transform-handles.ts')
| -rw-r--r-- | apps/web/src/hooks/use-transform-handles.ts | 455 |
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, |
