mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user