From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- apps/web/src/hooks/actions/use-editor-actions.ts | 59 +- apps/web/src/hooks/use-mask-handles.ts | 893 ++++++++++++++++------- 2 files changed, 677 insertions(+), 275 deletions(-) (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 1c4735bb..873fd33a 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -21,19 +21,27 @@ export function useEditorActions() { const editor = useEditor(); const { selectedElements, setElementSelection } = useElementSelection(); const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); + const selectedMaskPointSelection = useEditor((e) => + e.selection.getSelectedMaskPointSelection(), + ); const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); const hasTimelineSelectionRef = useRef(false); const clearTimelineSelectionRef = useRef(() => {}); + const clearTimelineActiveSelectionRef = useRef(() => {}); const timelineScopeRef = useRef(null); const hasTimelineSelection = - selectedElements.length > 0 || selectedKeyframes.length > 0; + selectedElements.length > 0 || + selectedKeyframes.length > 0 || + selectedMaskPointSelection !== null; hasTimelineSelectionRef.current = hasTimelineSelection; clearTimelineSelectionRef.current = () => { - setElementSelection({ elements: [] }); - clearKeyframeSelection(); + editor.selection.clearSelection(); + }; + clearTimelineActiveSelectionRef.current = () => { + editor.selection.clearMostSpecificSelection(); }; if (!timelineScopeRef.current) { @@ -42,6 +50,9 @@ export function useEditorActions() { clear: () => { clearTimelineSelectionRef.current(); }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, }; } @@ -257,17 +268,36 @@ export function useEditorActions() { useActionHandler( "delete-selected", () => { - if (selectedKeyframes.length > 0) { - editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); - clearKeyframeSelection(); - return; + switch (editor.selection.getActiveSelectionKind()) { + case "mask-points": + if (!selectedMaskPointSelection) { + return; + } + editor.timeline.deleteCustomMaskPoints({ + trackId: selectedMaskPointSelection.trackId, + elementId: selectedMaskPointSelection.elementId, + maskId: selectedMaskPointSelection.maskId, + pointIds: selectedMaskPointSelection.pointIds, + }); + return; + case "keyframes": + if (selectedKeyframes.length === 0) { + return; + } + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + case "elements": + if (selectedElements.length === 0) { + return; + } + editor.timeline.deleteElements({ + elements: selectedElements, + }); + return; + default: + return; } - if (selectedElements.length === 0) { - return; - } - editor.timeline.deleteElements({ - elements: selectedElements, - }); }, undefined, ); @@ -343,8 +373,7 @@ export function useEditorActions() { "deselect-all", () => { if (!clearActiveScope()) { - setElementSelection({ elements: [] }); - clearKeyframeSelection(); + editor.selection.clearMostSpecificSelection(); } }, undefined, diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts index d2400ee0..917a0677 100644 --- a/apps/web/src/hooks/use-mask-handles.ts +++ b/apps/web/src/hooks/use-mask-handles.ts @@ -1,260 +1,633 @@ -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 { - getMaskHandlePositions, - getLineMaskLinePoints, -} from "@/lib/masks/handle-positions"; -import { snapMaskInteraction } from "@/lib/masks/snap"; -import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import type { ParamValues } from "@/lib/params"; -import type { - BaseMaskParams, - MaskHandlePosition, - MaskLinePoints, -} 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: BaseMaskParams & ParamValues; -} - -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 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 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; - if (!element.masks?.length) return null; - return { ...entry, element, mask: element.masks[0] }; - })() - : null; - - const handlePositions: MaskHandlePosition[] = selectedWithMask - ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); - const displayScale = (scaleX + scaleY) / 2; - return getMaskHandlePositions({ - overlayShape: def.overlayShape, - features: def.features, - params: selectedWithMask.mask.params, - bounds: selectedWithMask.bounds, - displayScale, - }); - })() - : []; - - const linePoints: MaskLinePoints | null = - selectedWithMask?.mask.type === "split" - ? getLineMaskLinePoints({ - centerX: selectedWithMask.mask.params.centerX, - centerY: selectedWithMask.mask.params.centerY, - rotation: selectedWithMask.mask.params.rotation, - bounds: selectedWithMask.bounds, - }) - : null; - - const clearMaskHandleState = useCallback(() => { - dragStateRef.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; - event.stopPropagation(); - - const pos = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!pos) return; - - 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, - }; - }, - [selectedWithMask, viewport], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - 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[] } - : snapMaskInteraction({ - handleId: drag.handleId, - startParams: drag.startParams, - proposedParams, - bounds: selectedWithMask.bounds, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - const updatedMask = { - ...selectedWithMask.mask, - params: nextParams, - }; - editor.timeline.previewElements({ - updates: [ - { - trackId: drag.trackId, - elementId: drag.elementId, - updates: { - masks: [ - updatedMask, - ...(selectedWithMask.element.masks?.slice(1) ?? []), - ], - } as Partial, - }, - ], - }); - }, - [ - selectedWithMask, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if (dragStateRef.current) { - editor.timeline.commitPreview(); - clearMaskHandleState(); - } - releaseCapturedPointer(); - }, - [clearMaskHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithMask, - handlePositions, - linePoints, - activeHandleId, - handlePointerDown, - handlePointerMove, - handlePointerUp, - }; -} +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, + }; +} -- cgit v1.3.1