import { useCallback, useEffect, useRef, useState } from "react"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { masksRegistry } from "@/lib/masks"; import { parseCustomMaskHandleId, parseCustomMaskSegmentHandleId, } from "@/lib/masks/custom-path"; import { appendPointToCustomMask } from "@/lib/masks/definitions/custom"; import { getVisibleElementsWithBounds, type ElementBounds, } from "@/lib/preview/element-bounds"; import { SNAP_THRESHOLD_SCREEN_PIXELS, type SnapLine, } from "@/lib/preview/preview-snap"; import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection"; import type { Mask, MaskInteractionResult } from "@/lib/masks/types"; import type { MaskableElement } from "@/lib/timeline"; import { registerCanceller } from "@/lib/cancel-interaction"; interface DragState { trackId: string; elementId: string; handleId: string; startCanvasX: number; startCanvasY: number; startParams: Mask["params"]; } interface PendingSegmentInsertState { trackId: string; elementId: string; maskId: string; segmentIndex: number; startClientX: number; startClientY: number; startCanvasX: number; startCanvasY: number; startParams: Mask["params"]; bounds: ElementBounds; } const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4; function isMaskSelectionForElement({ trackId, elementId, maskId, selection, }: { trackId: string; elementId: string; maskId: string; selection: SelectedMaskPointSelection | null; }): boolean { if (!selection) { return false; } return ( selection.trackId === trackId && selection.elementId === elementId && selection.maskId === maskId ); } function replaceElementMask({ masks, updatedMask, }: { masks: MaskableElement["masks"]; updatedMask: Mask; }): Mask[] { return (masks ?? []).map((mask) => mask.id === updatedMask.id ? updatedMask : mask, ); } function withUpdatedMaskParams({ mask, params, }: { mask: TMask; params: TMask["params"]; }): TMask { return { ...mask, params, }; } export function useMaskHandles({ onSnapLinesChange, }: { onSnapLinesChange?: (lines: SnapLine[]) => void; }) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); const [activeHandleId, setActiveHandleId] = useState(null); const dragStateRef = useRef(null); const pendingSegmentInsertRef = useRef( null, ); const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( null, ); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const selectedMaskPointSelection = useEditor((e) => e.selection.getSelectedMaskPointSelection(), ); const elementsWithBounds = getVisibleElementsWithBounds({ tracks, currentTime, canvasSize, mediaAssets, }); const selectedWithMask = selectedElements.length === 1 ? (() => { const sel = selectedElements[0]; const entry = elementsWithBounds.find( (item) => item.trackId === sel.trackId && item.elementId === sel.elementId, ); if (!entry) return null; const element = entry.element as MaskableElement; const masks = element.masks ?? []; if (masks.length === 0) return null; const activeMaskId = masks.some((mask) => isMaskSelectionForElement({ trackId: entry.trackId, elementId: entry.elementId, maskId: mask.id, selection: selectedMaskPointSelection, }), ) ? selectedMaskPointSelection?.maskId : masks[0].id; const mask = masks.find((candidate) => candidate.id === activeMaskId) ?? masks[0]; return { ...entry, element, mask }; })() : null; const { handles: baseHandlePositions, overlays }: MaskInteractionResult = selectedWithMask ? (() => { const def = masksRegistry.get(selectedWithMask.mask.type); const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); const displayScale = (scaleX + scaleY) / 2; return def.interaction.getInteraction({ params: selectedWithMask.mask.params, bounds: selectedWithMask.bounds, displayScale, scaleX, scaleY, }); })() : { handles: [], overlays: [] }; const selectedPointIds = new Set( selectedWithMask && isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, selection: selectedMaskPointSelection, }) ? (selectedMaskPointSelection?.pointIds ?? []) : [], ); const handlePositions = baseHandlePositions.map((handle) => { const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); if (!parsedHandle || parsedHandle.part !== "anchor") { return handle; } return { ...handle, isSelected: selectedPointIds.has(parsedHandle.pointId), }; }); const customMaskPointIds = selectedWithMask?.mask.type === "custom" ? handlePositions .filter((h) => h.kind === "point") .map((h) => { const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); return parsedHandle?.part === "anchor" ? parsedHandle.pointId : null; }) .filter((id): id is string => id !== null) : []; const isCreatingCustomMask = selectedWithMask?.mask.type === "custom" && (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); useEffect(() => { if (!selectedMaskPointSelection) { return; } if ( !selectedWithMask || selectedWithMask.mask.type !== "custom" || !isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, selection: selectedMaskPointSelection, }) ) { editor.selection.clearMaskPointSelection(); return; } const availablePointIds = new Set(customMaskPointIds); const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter( (pointId) => availablePointIds.has(pointId), ); if ( nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length ) { return; } if (nextSelectedPointIds.length === 0) { editor.selection.clearMaskPointSelection(); return; } editor.selection.setSelectedMaskPoints({ selection: { ...selectedMaskPointSelection, pointIds: nextSelectedPointIds, }, }); }, [ customMaskPointIds, editor.selection, selectedMaskPointSelection, selectedWithMask, ]); const updateCustomMaskPointSelection = useCallback( ({ pointId, toggleSelection, }: { pointId: string; toggleSelection: boolean; }) => { if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { return; } const isSelectionForCurrentMask = isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, selection: selectedMaskPointSelection, }); const currentPointIds = isSelectionForCurrentMask ? (selectedMaskPointSelection?.pointIds ?? []) : []; const nextPointIds = toggleSelection ? currentPointIds.includes(pointId) ? currentPointIds.filter( (currentPointId) => currentPointId !== pointId, ) : [...currentPointIds, pointId] : [pointId]; if (nextPointIds.length === 0) { editor.selection.clearMaskPointSelection(); return; } editor.selection.setSelectedMaskPoints({ selection: { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, pointIds: nextPointIds, }, }); }, [editor.selection, selectedMaskPointSelection, selectedWithMask], ); const clearMaskHandleState = useCallback(() => { dragStateRef.current = null; pendingSegmentInsertRef.current = null; setActiveHandleId(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 (!activeHandleId) return; return registerCanceller({ fn: () => { editor.timeline.discardPreview(); clearMaskHandleState(); releaseCapturedPointer(); }, }); }, [ activeHandleId, clearMaskHandleState, editor.timeline, releaseCapturedPointer, ]); const handlePointerDown = useCallback( ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { if (!selectedWithMask) return; if (event.button !== 0) return; event.stopPropagation(); const parsedHandle = selectedWithMask.mask.type === "custom" ? parseCustomMaskHandleId({ handleId }) : null; const parsedSegmentHandle = selectedWithMask.mask.type === "custom" ? parseCustomMaskSegmentHandleId({ handleId }) : null; if (isCreatingCustomMask) { const firstPointId = customMaskPointIds[0]; if ( firstPointId && handleId === `point:${firstPointId}:anchor` && customMaskPointIds.length >= 3 && selectedWithMask.mask.type === "custom" ) { const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, params: { ...selectedWithMask.mask.params, closed: true, }, }); editor.timeline.updateElements({ updates: [ { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, patch: { masks: replaceElementMask({ masks: selectedWithMask.element.masks, updatedMask, }), } as Partial, }, ], }); } return; } const pos = viewport.screenToCanvas({ clientX: event.clientX, clientY: event.clientY, }); if (!pos) return; if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { setActiveHandleId(handleId); pendingSegmentInsertRef.current = { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, segmentIndex: parsedSegmentHandle.segmentIndex, startClientX: event.clientX, startClientY: event.clientY, startCanvasX: pos.x, startCanvasY: pos.y, startParams: { ...selectedWithMask.mask.params }, bounds: selectedWithMask.bounds, }; const captureTarget = event.currentTarget as HTMLElement; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, pointerId: event.pointerId, }; return; } if (parsedHandle?.part === "anchor") { updateCustomMaskPointSelection({ pointId: parsedHandle.pointId, toggleSelection: event.shiftKey, }); if (event.shiftKey) { return; } } else if (selectedWithMask.mask.type === "custom") { editor.selection.clearMaskPointSelection(); } dragStateRef.current = { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, handleId, startCanvasX: pos.x, startCanvasY: pos.y, startParams: { ...selectedWithMask.mask.params }, }; setActiveHandleId(handleId); const captureTarget = event.currentTarget as HTMLElement; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, pointerId: event.pointerId, }; }, [ customMaskPointIds, editor.selection, editor.timeline, isCreatingCustomMask, selectedWithMask, updateCustomMaskPointSelection, viewport, ], ); const handleCanvasPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { if (!selectedWithMask || !isCreatingCustomMask) { return; } if (event.button !== 0) { return; } if (selectedWithMask.mask.type !== "custom") { return; } event.stopPropagation(); const pos = viewport.screenToCanvas({ clientX: event.clientX, clientY: event.clientY, }); if (!pos) { return; } const nextParams = appendPointToCustomMask({ params: selectedWithMask.mask.params, canvasPoint: pos, bounds: selectedWithMask.bounds, }); const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, params: nextParams, }); editor.timeline.updateElements({ updates: [ { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, patch: { masks: replaceElementMask({ masks: selectedWithMask.element.masks, updatedMask, }), } as Partial, }, ], }); }, [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], ); const handlePointerMove = useCallback( ({ event }: { event: React.PointerEvent }) => { const pendingSegmentInsert = pendingSegmentInsertRef.current; if (pendingSegmentInsert && !dragStateRef.current) { const distance = Math.hypot( event.clientX - pendingSegmentInsert.startClientX, event.clientY - pendingSegmentInsert.startClientY, ); if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) { dragStateRef.current = { trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, handleId: "position", startCanvasX: pendingSegmentInsert.startCanvasX, startCanvasY: pendingSegmentInsert.startCanvasY, startParams: pendingSegmentInsert.startParams, }; pendingSegmentInsertRef.current = null; setActiveHandleId("position"); } } const drag = dragStateRef.current; if (!drag || !selectedWithMask) return; const pos = viewport.screenToCanvas({ clientX: event.clientX, clientY: event.clientY, }); if (!pos) return; const deltaX = pos.x - drag.startCanvasX; const deltaY = pos.y - drag.startCanvasY; const def = masksRegistry.get(selectedWithMask.mask.type); const rawParams = def.computeParamUpdate({ handleId: drag.handleId, startParams: drag.startParams, deltaX, deltaY, startCanvasX: drag.startCanvasX, startCanvasY: drag.startCanvasY, bounds: selectedWithMask.bounds, canvasSize, }); const proposedParams = { ...drag.startParams, ...rawParams }; const snapThreshold = viewport.screenPixelsToLogicalThreshold({ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, }); const { params: nextParams, activeLines } = isShiftHeldRef.current ? { params: proposedParams, activeLines: [] as SnapLine[] } : (def.interaction.snap?.({ handleId: drag.handleId, startParams: drag.startParams, proposedParams, bounds: selectedWithMask.bounds, canvasSize, snapThreshold, }) ?? { params: proposedParams, activeLines: [] as SnapLine[] }); onSnapLinesChange?.(activeLines); const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, params: nextParams as typeof selectedWithMask.mask.params, }); editor.timeline.previewElements({ updates: [ { trackId: drag.trackId, elementId: drag.elementId, updates: { masks: replaceElementMask({ masks: selectedWithMask.element.masks, updatedMask, }), } as Partial, }, ], }); }, [ selectedWithMask, canvasSize, editor, isShiftHeldRef, onSnapLinesChange, viewport, ], ); const handlePointerUp = useCallback(() => { const pendingSegmentInsert = pendingSegmentInsertRef.current; if (pendingSegmentInsert && !dragStateRef.current) { editor.timeline.insertCustomMaskPoint({ trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, maskId: pendingSegmentInsert.maskId, segmentIndex: pendingSegmentInsert.segmentIndex, canvasPoint: { x: pendingSegmentInsert.startCanvasX, y: pendingSegmentInsert.startCanvasY, }, bounds: pendingSegmentInsert.bounds, }); clearMaskHandleState(); releaseCapturedPointer(); return; } if (dragStateRef.current) { editor.timeline.commitPreview(); clearMaskHandleState(); } releaseCapturedPointer(); }, [clearMaskHandleState, editor, releaseCapturedPointer]); return { selectedWithMask, handlePositions, overlays, isCreatingCustomMask, handleCanvasPointerDown, activeHandleId, handlePointerDown, handlePointerMove, handlePointerUp, }; }