diff options
Diffstat (limited to 'apps/web/src/preview/hooks/use-preview-interaction.ts')
| -rw-r--r-- | apps/web/src/preview/hooks/use-preview-interaction.ts | 491 |
1 files changed, 65 insertions, 426 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, }; } |
