diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/masks/use-mask-handles.ts | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/masks/use-mask-handles.ts')
| -rw-r--r-- | apps/web/src/masks/use-mask-handles.ts | 633 |
1 files changed, 633 insertions, 0 deletions
diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts new file mode 100644 index 00000000..b81de4f4 --- /dev/null +++ b/apps/web/src/masks/use-mask-handles.ts @@ -0,0 +1,633 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/preview/components/preview-viewport"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { masksRegistry } from "@/masks"; +import { + parseCustomMaskHandleId, + parseCustomMaskSegmentHandleId, +} from "@/masks/custom-path"; +import { appendPointToCustomMask } from "@/masks/definitions/custom"; +import { + getVisibleElementsWithBounds, + type ElementBounds, +} from "@/preview/element-bounds"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + type SnapLine, +} from "@/preview/preview-snap"; +import type { SelectedMaskPointSelection } from "@/selection/editor-selection"; +import type { Mask, MaskInteractionResult } from "@/masks/types"; +import type { MaskableElement } from "@/timeline"; +import { registerCanceller } from "@/editor/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<TMask extends Mask>({ + 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<string | null>(null); + const dragStateRef = useRef<DragState | null>(null); + const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( + 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<MaskableElement>, + }, + ], + }); + } + 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<MaskableElement>, + }, + ], + }); + }, + [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<MaskableElement>, + }, + ], + }); + }, + [ + 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, + }; +} |
