diff options
Diffstat (limited to 'apps/web/src/hooks/use-preview-interaction.ts')
| -rw-r--r-- | apps/web/src/hooks/use-preview-interaction.ts | 345 |
1 files changed, 247 insertions, 98 deletions
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index d29f2a4b..90fa0450 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -1,28 +1,50 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import type { TextElement, Transform } from "@/types/timeline"; -import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; -import { hitTest } from "@/lib/preview/hit-test"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { Transform } from "@/lib/rendering"; +import type { ElementRef, TextElement } from "@/lib/timeline"; import { - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + getHitElements, + hitTest, + resolvePreferredHit, +} from "@/lib/preview/hit-test"; import { isVisualElement } from "@/lib/timeline/element-utils"; import { SNAP_THRESHOLD_SCREEN_PIXELS, snapPosition, type SnapLine, } from "@/lib/preview/preview-snap"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +export type OnSnapLinesChange = (lines: SnapLine[]) => void; const MIN_DRAG_DISTANCE = 0.5; -interface DragState { +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; @@ -31,15 +53,56 @@ interface DragState { }>; } +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({ - canvasRef, + onSnapLinesChange, + isMaskMode = false, }: { - canvasRef: React.RefObject<HTMLCanvasElement | null>; + onSnapLinesChange?: OnSnapLinesChange; + isMaskMode?: boolean; }) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); const [isDragging, setIsDragging] = useState(false); - const [snapLines, setSnapLines] = useState<SnapLine[]>([]); const [editingText, setEditingText] = useState<{ trackId: string; elementId: string; @@ -47,13 +110,30 @@ export function usePreviewInteraction({ 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.previewElements({ updates: [ { @@ -67,11 +147,6 @@ export function usePreviewInteraction({ setEditingText(null); }, [editor.timeline]); - const cancelTextEdit = useCallback(() => { - editor.timeline.discardPreview(); - setEditingText(null); - }, [editor.timeline]); - useEffect(() => { const unsubscribe = editor.playback.subscribe(() => { const isPlaying = editor.playback.getIsPlaying(); @@ -83,20 +158,38 @@ export function usePreviewInteraction({ 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 (!canvasRef.current || editingText) return; + if (editingText || isMaskMode) return; const tracks = editor.timeline.getTracks(); const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; - const startPos = screenToCanvas({ + const startPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!startPos) return; const elementsWithBounds = getVisibleElementsWithBounds({ tracks, @@ -130,7 +223,7 @@ export function usePreviewInteraction({ originalOpacity: textElement.opacity, }); }, - [canvasRef, editor, editingText], + [editor, editingText, isMaskMode, viewport], ); const handlePointerDown = useCallback( @@ -141,8 +234,8 @@ export function usePreviewInteraction({ pointerId, button, }: React.PointerEvent) => { - if (!canvasRef.current) return; if (editingText) return; + if (isMaskMode) return; if (button !== 0) return; const tracks = editor.timeline.getTracks(); @@ -150,11 +243,11 @@ export function usePreviewInteraction({ const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; - const startPos = screenToCanvas({ + const startPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!startPos) return; const elementsWithBounds = getVisibleElementsWithBounds({ tracks, @@ -163,89 +256,134 @@ export function usePreviewInteraction({ mediaAssets, }); - const hit = hitTest({ + const hits = getHitElements({ canvasX: startPos.x, canvasY: startPos.y, elementsWithBounds, }); + const selectedElements = editor.selection.getSelectedElements(); + const topmostHit = hits[0] ?? null; - if (!hit) { - editor.selection.clearSelection(); - return; - } - - editor.selection.setSelectedElements({ - elements: [{ trackId: hit.trackId, elementId: hit.elementId }], - }); - - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: [{ trackId: hit.trackId, elementId: hit.elementId }], - }); - - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) return; - - dragStateRef.current = { + pendingGestureRef.current = { startX: startPos.x, startY: startPos.y, - bounds: { - width: hit.bounds.width, - height: hit.bounds.height, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), + pointerId, + captureTarget: currentTarget as HTMLElement, + topmostHit, + selectedHit: resolvePreferredHit({ + hits, + preferredElements: selectedElements, + }), + selectedElements, }; - - setIsDragging(true); currentTarget.setPointerCapture(pointerId); }, - [editor, canvasRef, editingText], + [editor, editingText, isMaskMode, viewport], ); const handlePointerMove = useCallback( ({ clientX, clientY }: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging || !canvasRef.current) return; - const canvasSize = editor.project.getActive().settings.canvasSize; - const currentPos = screenToCanvas({ + const currentPos = viewport.screenToCanvas({ clientX, clientY, - canvas: canvasRef.current, }); + if (!currentPos) return; - const deltaX = currentPos.x - dragStateRef.current.startX; - const deltaY = currentPos.y - dragStateRef.current.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - if (!hasMovement) { - setSnapLines([]); - 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 firstElement = dragStateRef.current.elements[0]; + 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 = screenPixelsToLogicalThreshold({ - canvas: canvasRef.current, + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, }); const { snappedPosition, activeLines } = shouldSnap ? snapPosition({ proposedPosition, canvasSize, - elementSize: dragStateRef.current.bounds, + elementSize: dragState.bounds, + rotation: dragState.bounds.rotation, snapThreshold, }) : { @@ -253,14 +391,14 @@ export function usePreviewInteraction({ activeLines: [] as SnapLine[], }; - setSnapLines(activeLines); + onSnapLinesChange?.(activeLines); const deltaSnappedX = snappedPosition.x - firstElement.initialTransform.position.x; const deltaSnappedY = snappedPosition.y - firstElement.initialTransform.position.y; - const updates = dragStateRef.current.elements.map( + const updates = dragState.elements.map( ({ trackId, elementId, initialTransform }) => ({ trackId, elementId, @@ -278,38 +416,51 @@ export function usePreviewInteraction({ editor.timeline.previewElements({ updates }); }, - [isDragging, canvasRef, editor, isShiftHeldRef], + [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], ); const handlePointerUp = useCallback( - ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => { - if (!dragStateRef.current || !isDragging || !canvasRef.current) return; + ({ type }: React.PointerEvent) => { + const dragState = dragStateRef.current; + if (dragState) { + if (type === "pointercancel") { + editor.timeline.discardPreview(); + } else { + editor.timeline.commitPreview(); + } - const currentPos = screenToCanvas({ - clientX, - clientY, - canvas: canvasRef.current, - }); - - const deltaX = currentPos.x - dragStateRef.current.startX; - const deltaY = currentPos.y - dragStateRef.current.startY; + dragStateRef.current = null; + pendingGestureRef.current = null; + setIsDragging(false); + onSnapLinesChange?.([]); + releaseCapturedPointer(dragState); + return; + } - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; + const pendingGesture = pendingGestureRef.current; + if (!pendingGesture) return; - if (!hasMovement) { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); + if (type !== "pointercancel") { + const clickTarget = pendingGesture.topmostHit; + if (!clickTarget) { + editor.selection.clearSelection(); + } else { + editor.selection.setSelectedElements({ + elements: [ + { + trackId: clickTarget.trackId, + elementId: clickTarget.elementId, + }, + ], + }); + } } - dragStateRef.current = null; - setIsDragging(false); - setSnapLines([]); - currentTarget.releasePointerCapture(pointerId); + pendingGestureRef.current = null; + onSnapLinesChange?.([]); + releaseCapturedPointer(pendingGesture); }, - [isDragging, canvasRef, editor], + [editor, onSnapLinesChange, releaseCapturedPointer], ); return { @@ -317,9 +468,7 @@ export function usePreviewInteraction({ onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onDoubleClick: handleDoubleClick, - snapLines, editingText, commitTextEdit, - cancelTextEdit, }; } |
