summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-mask-handles.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 15:45:50 +0200
commitc46d28891fee62ca2d3132da876af6411a41d9da (patch)
treef856f71d04c69628f26b904088d675dd298a5080 /apps/web/src/hooks/use-mask-handles.ts
parent66e4367bc4727f1d0bcf8f869529c7518812277e (diff)
downloadopencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web/src/hooks/use-mask-handles.ts')
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts893
1 files changed, 633 insertions, 260 deletions
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<string | null>(null);
- const dragStateRef = useRef<DragState | 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 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<MaskableElement>,
- },
- ],
- });
- },
- [
- 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<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,
+ };
+}