refactor: type mask handle identifiers

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-29 18:31:24 +02:00
parent ae1292a14b
commit 97f8d1968f
11 changed files with 252 additions and 209 deletions

View File

@ -150,10 +150,10 @@ function buildCustomMaskParams(
function sortSegment( function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }], segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] { ): [{ x: number; y: number }, { x: number; y: number }] {
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ const [first, second] = segment;
{ x: number; y: number }, return first.x < second.x || (first.x === second.x && first.y <= second.y)
{ x: number; y: number }, ? [first, second]
]; : [second, first];
} }
describe("mask geometry", () => { describe("mask geometry", () => {
@ -231,7 +231,7 @@ describe("mask geometry", () => {
describe("mask snapping", () => { describe("mask snapping", () => {
test("snaps split mask movement using the shared position pipeline", () => { test("snaps split mask movement using the shared position pipeline", () => {
const result = snapSplitMaskInteraction({ const result = snapSplitMaskInteraction({
handleId: "position", handleId: { kind: "position" },
startParams: buildSplitParams({ startParams: buildSplitParams({
centerX: 0.03, centerX: 0.03,
centerY: -0.04, centerY: -0.04,
@ -255,7 +255,7 @@ describe("mask snapping", () => {
test("snaps box mask movement against element center and edges", () => { test("snaps box mask movement against element center and edges", () => {
const result = snapBoxMaskInteraction({ const result = snapBoxMaskInteraction({
handleId: "position", handleId: { kind: "position" },
startParams: buildRectangleParams(), startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({ proposedParams: buildRectangleParams({
centerX: 0.29, centerX: 0.29,
@ -276,7 +276,7 @@ describe("mask snapping", () => {
test("snaps mask rotation through the shared rotation path", () => { test("snaps mask rotation through the shared rotation path", () => {
const result = snapBoxMaskInteraction({ const result = snapBoxMaskInteraction({
handleId: "rotation", handleId: { kind: "rotation" },
startParams: buildRectangleParams(), startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({ proposedParams: buildRectangleParams({
rotation: 88, rotation: 88,
@ -292,7 +292,7 @@ describe("mask snapping", () => {
test("snaps edge resize for box masks", () => { test("snaps edge resize for box masks", () => {
const result = snapBoxMaskInteraction({ const result = snapBoxMaskInteraction({
handleId: "right", handleId: { kind: "edge", side: "right" },
startParams: buildRectangleParams(), startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({ proposedParams: buildRectangleParams({
width: 0.98, width: 0.98,
@ -308,7 +308,7 @@ describe("mask snapping", () => {
test("snaps corner resize for box masks", () => { test("snaps corner resize for box masks", () => {
const result = snapBoxMaskInteraction({ const result = snapBoxMaskInteraction({
handleId: "bottom-right", handleId: { kind: "corner", corner: { x: "right", y: "bottom" } },
startParams: buildRectangleParams(), startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({ proposedParams: buildRectangleParams({
width: 0.99, width: 0.99,
@ -330,7 +330,7 @@ describe("mask snapping", () => {
centerY: -0.04, centerY: -0.04,
}); });
const result = textMaskDefinition.interaction.snap?.({ const result = textMaskDefinition.interaction.snap?.({
handleId: "position", handleId: { kind: "position" },
startParams: params, startParams: params,
proposedParams: params, proposedParams: params,
bounds, bounds,
@ -352,7 +352,7 @@ describe("mask snapping", () => {
centerY: -0.04, centerY: -0.04,
}); });
const result = customMaskDefinition.interaction.snap?.({ const result = customMaskDefinition.interaction.snap?.({
handleId: "position", handleId: { kind: "position" },
startParams: params, startParams: params,
proposedParams: params, proposedParams: params,
bounds, bounds,

View File

@ -10,56 +10,29 @@ export interface CustomMaskPathPoint {
outY: number; outY: number;
} }
export type CustomMaskHandlePart = "anchor" | "in" | "out";
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return false; return false;
} }
const candidate = value as Record<string, unknown>;
return ( return (
typeof candidate.id === "string" && "id" in value &&
typeof candidate.x === "number" && typeof value.id === "string" &&
typeof candidate.y === "number" && "x" in value &&
typeof candidate.inX === "number" && typeof value.x === "number" &&
typeof candidate.inY === "number" && "y" in value &&
typeof candidate.outX === "number" && typeof value.y === "number" &&
typeof candidate.outY === "number" "inX" in value &&
typeof value.inX === "number" &&
"inY" in value &&
typeof value.inY === "number" &&
"outX" in value &&
typeof value.outX === "number" &&
"outY" in value &&
typeof value.outY === "number"
); );
} }
export function parseCustomMaskHandleId({
handleId,
}: {
handleId: string;
}): { pointId: string; part: CustomMaskHandlePart } | null {
const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
if (!match) {
return null;
}
return {
pointId: match[1],
part: match[2] as CustomMaskHandlePart,
};
}
export function parseCustomMaskSegmentHandleId({
handleId,
}: {
handleId: string;
}): { segmentIndex: number } | null {
const match = /^segment:(\d+)$/.exec(handleId);
if (!match) {
return null;
}
return {
segmentIndex: Number.parseInt(match[1], 10),
};
}
export function parseCustomMaskPath({ export function parseCustomMaskPath({
path, path,
}: { }: {

View File

@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({
deltaY, deltaY,
bounds, bounds,
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> { }: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
return { return {
centerX: startParams.centerX + deltaX / bounds.width, centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height, centerY: startParams.centerY + deltaY / bounds.height,
}; };
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
const newRotation = (startParams.rotation + currentAngle) % 360; const newRotation = (startParams.rotation + currentAngle) % 360;
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
} }
if (handleId === "feather") { if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180; const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({ return computeFeatherUpdate({
startFeather: startParams.feather, startFeather: startParams.feather,
@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({
const halfWidth = startParams.width * bounds.width; const halfWidth = startParams.width * bounds.width;
const halfHeight = startParams.height * bounds.height; const halfHeight = startParams.height * bounds.height;
if (handleId === "right" || handleId === "left") { if (
const sign = handleId === "right" ? 1 : -1; handleId.kind === "edge" &&
(handleId.side === "right" || handleId.side === "left")
) {
const sign = handleId.side === "right" ? 1 : -1;
return { return {
width: Math.max( width: Math.max(
MIN_MASK_DIMENSION, MIN_MASK_DIMENSION,
@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({
}; };
} }
if (handleId === "bottom" || handleId === "top") { if (
const sign = handleId === "bottom" ? 1 : -1; handleId.kind === "edge" &&
(handleId.side === "bottom" || handleId.side === "top")
) {
const sign = handleId.side === "bottom" ? 1 : -1;
return { return {
height: Math.max( height: Math.max(
MIN_MASK_DIMENSION, MIN_MASK_DIMENSION,
@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({
}; };
} }
if ( if (handleId.kind === "corner") {
handleId === "top-left" || const signX = handleId.corner.x === "right" ? 1 : -1;
handleId === "top-right" || const signY = handleId.corner.y === "bottom" ? 1 : -1;
handleId === "bottom-left" ||
handleId === "bottom-right"
) {
const signX = handleId.includes("right") ? 1 : -1;
const signY = handleId.includes("bottom") ? 1 : -1;
const distance = Math.sqrt( const distance = Math.sqrt(
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
); );
@ -276,7 +277,7 @@ export function computeBoxMaskParamUpdate({
}; };
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;

View File

@ -20,7 +20,6 @@ import {
getCustomMaskLocalBounds, getCustomMaskLocalBounds,
getCustomMaskSegmentCount, getCustomMaskSegmentCount,
insertPointIntoCustomMaskSegment, insertPointIntoCustomMaskSegment,
parseCustomMaskHandleId,
recenterCustomMaskPath, recenterCustomMaskPath,
type CustomMaskPathPoint, type CustomMaskPathPoint,
} from "@/masks/custom-path"; } from "@/masks/custom-path";
@ -133,12 +132,12 @@ function getCustomMaskDisplayHandles({
scale: params.scale, scale: params.scale,
bounds, bounds,
closed: true, closed: true,
}).map((segment) => ({ }).map((segment): MaskOverlay => ({
id: `segment:${segment.index}`, id: `segment:${segment.index}`,
type: "canvas-path" as const, type: "canvas-path" as const,
pathData: segment.pathData, pathData: segment.pathData,
coordinateSpace: "canvas" as const, coordinateSpace: "canvas" as const,
handleId: `segment:${segment.index}`, handleId: { kind: "segment", index: segment.index },
cursor: PEN_CURSOR, cursor: PEN_CURSOR,
strokeOpacity: 0, strokeOpacity: 0,
strokeWidth: segmentStrokeWidth, strokeWidth: segmentStrokeWidth,
@ -166,7 +165,7 @@ function getCustomMaskDisplayHandles({
geometry.anchors.forEach((point) => { geometry.anchors.forEach((point) => {
handles.push({ handles.push({
id: `point:${point.id}:anchor`, id: { kind: "anchor", pointId: point.id },
x: point.anchor.x, x: point.anchor.x,
y: point.anchor.y, y: point.anchor.y,
cursor: params.closed ? "move" : "pointer", cursor: params.closed ? "move" : "pointer",
@ -201,7 +200,7 @@ function computeCustomMaskParamUpdate({
startCanvasY, startCanvasY,
bounds, bounds,
}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> { }: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
return { return {
centerX: startParams.centerX + deltaX / bounds.width, centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height, centerY: startParams.centerY + deltaY / bounds.height,
@ -211,7 +210,7 @@ function computeCustomMaskParamUpdate({
const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height; const pivotY = bounds.cy + startParams.centerY * bounds.height;
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const startAngle = const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI; Math.PI;
@ -232,7 +231,7 @@ function computeCustomMaskParamUpdate({
}; };
} }
if (handleId === "feather") { if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180; const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({ return computeFeatherUpdate({
startFeather: startParams.feather, startFeather: startParams.feather,
@ -243,7 +242,7 @@ function computeCustomMaskParamUpdate({
}); });
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const startDistance = Math.hypot( const startDistance = Math.hypot(
startCanvasX - pivotX, startCanvasX - pivotX,
startCanvasY - pivotY, startCanvasY - pivotY,
@ -258,8 +257,7 @@ function computeCustomMaskParamUpdate({
}; };
} }
const parsedHandle = parseCustomMaskHandleId({ handleId }); if (handleId.kind !== "anchor" && handleId.kind !== "tangent") {
if (!parsedHandle) {
return {}; return {};
} }
@ -280,9 +278,9 @@ function computeCustomMaskParamUpdate({
return { return {
path: updateCustomMaskPoint({ path: updateCustomMaskPoint({
points, points,
pointId: parsedHandle.pointId, pointId: handleId.pointId,
updater: (point) => { updater: (point) => {
if (parsedHandle.part === "anchor") { if (handleId.kind === "anchor") {
return { return {
...point, ...point,
x: localPoint.x, x: localPoint.x,
@ -290,7 +288,7 @@ function computeCustomMaskParamUpdate({
}; };
} }
if (parsedHandle.part === "in") { if (handleId.side === "in") {
return { return {
...point, ...point,
inX: localPoint.x - point.x, inX: localPoint.x - point.x,
@ -349,7 +347,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
y: proposedParams.centerY * bounds.height, y: proposedParams.centerY * bounds.height,
}; };
if (handleId === "position") { if (handleId.kind === "position") {
const { snappedPosition, activeLines } = snapPosition({ const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position, proposedPosition: position,
canvasSize: bounds, canvasSize: bounds,
@ -377,7 +375,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
}; };
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const { snappedRotation } = snapRotation({ const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation, proposedRotation: proposedParams.rotation,
}); });
@ -390,7 +388,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = {
}; };
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const { snappedScale, activeLines } = snapScale({ const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale, proposedScale: proposedParams.scale,
position, position,

View File

@ -128,7 +128,7 @@ function computeSplitMaskParamUpdate({
bounds, bounds,
canvasSize, canvasSize,
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> { }: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
const rawX = startParams.centerX + deltaX / bounds.width; const rawX = startParams.centerX + deltaX / bounds.width;
const rawY = startParams.centerY + deltaY / bounds.height; const rawY = startParams.centerY + deltaY / bounds.height;
@ -143,7 +143,7 @@ function computeSplitMaskParamUpdate({
}; };
} }
if (handleId === "feather") { if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180; const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({ return computeFeatherUpdate({
startFeather: startParams.feather, startFeather: startParams.feather,
@ -154,7 +154,7 @@ function computeSplitMaskParamUpdate({
}); });
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height; const pivotY = bounds.cy + startParams.centerY * bounds.height;
const startAngle = const startAngle =

View File

@ -4,6 +4,7 @@ import type {
MaskParamUpdateArgs, MaskParamUpdateArgs,
TextMask, TextMask,
TextMaskParams, TextMaskParams,
MaskHandleId,
} from "@/masks/types"; } from "@/masks/types";
import { DEFAULTS } from "@/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography";
@ -124,9 +125,9 @@ function measureTextMask({
function getScalePreferredEdges({ function getScalePreferredEdges({
handleId, handleId,
}: { }: {
handleId: string; handleId: MaskHandleId;
}): ScaleEdgePreference | undefined { }): ScaleEdgePreference | undefined {
if (handleId !== "scale") { if (handleId.kind !== "scale") {
return undefined; return undefined;
} }
@ -145,7 +146,7 @@ function computeTextMaskParamUpdate({
startCanvasY, startCanvasY,
bounds, bounds,
}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> { }: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
return { return {
centerX: startParams.centerX + deltaX / bounds.width, centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height, centerY: startParams.centerY + deltaY / bounds.height,
@ -155,7 +156,7 @@ function computeTextMaskParamUpdate({
const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height; const pivotY = bounds.cy + startParams.centerY * bounds.height;
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const startAngle = const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI; Math.PI;
@ -176,7 +177,7 @@ function computeTextMaskParamUpdate({
}; };
} }
if (handleId === "feather") { if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180; const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({ return computeFeatherUpdate({
startFeather: startParams.feather, startFeather: startParams.feather,
@ -187,7 +188,7 @@ function computeTextMaskParamUpdate({
}); });
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const startDistance = Math.hypot( const startDistance = Math.hypot(
startCanvasX - pivotX, startCanvasX - pivotX,
startCanvasY - pivotY, startCanvasY - pivotY,
@ -269,7 +270,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = {
y: proposedParams.centerY * bounds.height, y: proposedParams.centerY * bounds.height,
}; };
if (handleId === "position") { if (handleId.kind === "position") {
const { snappedPosition, activeLines } = snapPosition({ const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position, proposedPosition: position,
canvasSize: bounds, canvasSize: bounds,
@ -297,7 +298,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = {
}; };
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
const { snappedRotation } = snapRotation({ const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation, proposedRotation: proposedParams.rotation,
}); });
@ -310,7 +311,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = {
}; };
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const { snappedScale, activeLines } = snapScale({ const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale, proposedScale: proposedParams.scale,
position, position,

View File

@ -2,6 +2,7 @@ import { FEATHER_HANDLE_SCALE } from "@/masks/feather";
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { import type {
MaskFeatures, MaskFeatures,
MaskHandleId,
MaskHandlePosition, MaskHandlePosition,
MaskLineOverlay, MaskLineOverlay,
MaskOverlay, MaskOverlay,
@ -68,14 +69,14 @@ export function getLineMaskOverlay({
centerY, centerY,
rotation, rotation,
bounds, bounds,
handleId = "position", handleId = { kind: "position" },
cursor = "move", cursor = "move",
}: { }: {
centerX: number; centerX: number;
centerY: number; centerY: number;
rotation: number; rotation: number;
bounds: ElementBounds; bounds: ElementBounds;
handleId?: string; handleId?: MaskHandleId;
cursor?: string; cursor?: string;
}): MaskLineOverlay { }): MaskLineOverlay {
const { start, end } = getLineMaskLinePoints({ const { start, end } = getLineMaskLinePoints({
@ -122,7 +123,7 @@ export function getLineMaskHandlePositions({
return [ return [
{ {
id: "rotation", id: { kind: "rotation" },
x: cx + normalX * iconOffsetCanvas, x: cx + normalX * iconOffsetCanvas,
y: cy + normalY * iconOffsetCanvas, y: cy + normalY * iconOffsetCanvas,
cursor: CURSOR.rotate, cursor: CURSOR.rotate,
@ -130,7 +131,7 @@ export function getLineMaskHandlePositions({
icon: "rotate", icon: "rotate",
}, },
{ {
id: "feather", id: { kind: "feather" },
x: cx - normalX * featherOffset, x: cx - normalX * featherOffset,
y: cy - normalY * featherOffset, y: cy - normalY * featherOffset,
cursor: CURSOR.resizeHorizontal, cursor: CURSOR.resizeHorizontal,
@ -201,7 +202,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "rotation", id: { kind: "rotation" },
x: rotHandle.x, x: rotHandle.x,
y: rotHandle.y, y: rotHandle.y,
cursor: CURSOR.rotate, cursor: CURSOR.rotate,
@ -217,7 +218,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "feather", id: { kind: "feather" },
x: featherHandle.x, x: featherHandle.x,
y: featherHandle.y, y: featherHandle.y,
cursor: CURSOR.resizeVertical, cursor: CURSOR.resizeVertical,
@ -226,16 +227,36 @@ export function getBoxMaskHandlePositions({
}); });
if (sizeMode === "width-height") { if (sizeMode === "width-height") {
const corners = [ const corners: {
{ localX: -halfWidth, localY: -halfHeight, id: "top-left" }, localX: number;
{ localX: halfWidth, localY: -halfHeight, id: "top-right" }, localY: number;
{ localX: halfWidth, localY: halfHeight, id: "bottom-right" }, corner: Extract<MaskHandleId, { kind: "corner" }>["corner"];
{ localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, }[] = [
{
localX: -halfWidth,
localY: -halfHeight,
corner: { x: "left", y: "top" },
},
{
localX: halfWidth,
localY: -halfHeight,
corner: { x: "right", y: "top" },
},
{
localX: halfWidth,
localY: halfHeight,
corner: { x: "right", y: "bottom" },
},
{
localX: -halfWidth,
localY: halfHeight,
corner: { x: "left", y: "bottom" },
},
]; ];
for (const { localX, localY, id } of corners) { for (const { localX, localY, corner } of corners) {
const point = rotatePoint({ localX, localY, cx, cy, angleRad }); const point = rotatePoint({ localX, localY, cx, cy, angleRad });
handles.push({ handles.push({
id, id: { kind: "corner", corner },
x: point.x, x: point.x,
y: point.y, y: point.y,
cursor: CURSOR.resizeDiagonal, cursor: CURSOR.resizeDiagonal,
@ -264,7 +285,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "left", id: { kind: "edge", side: "left" },
x: left.x, x: left.x,
y: left.y, y: left.y,
cursor: CURSOR.resizeHorizontal, cursor: CURSOR.resizeHorizontal,
@ -273,7 +294,7 @@ export function getBoxMaskHandlePositions({
rotation, rotation,
}); });
handles.push({ handles.push({
id: "right", id: { kind: "edge", side: "right" },
x: right.x, x: right.x,
y: right.y, y: right.y,
cursor: CURSOR.resizeHorizontal, cursor: CURSOR.resizeHorizontal,
@ -282,7 +303,7 @@ export function getBoxMaskHandlePositions({
rotation, rotation,
}); });
handles.push({ handles.push({
id: "bottom", id: { kind: "edge", side: "bottom" },
x: bottom.x, x: bottom.x,
y: bottom.y, y: bottom.y,
cursor: CURSOR.resizeVertical, cursor: CURSOR.resizeVertical,
@ -306,7 +327,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "top", id: { kind: "edge", side: "top" },
x: top.x, x: top.x,
y: top.y, y: top.y,
cursor: CURSOR.resizeVertical, cursor: CURSOR.resizeVertical,
@ -315,7 +336,7 @@ export function getBoxMaskHandlePositions({
rotation, rotation,
}); });
handles.push({ handles.push({
id: "bottom", id: { kind: "edge", side: "bottom" },
x: bottom.x, x: bottom.x,
y: bottom.y, y: bottom.y,
cursor: CURSOR.resizeVertical, cursor: CURSOR.resizeVertical,
@ -339,7 +360,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "left", id: { kind: "edge", side: "left" },
x: left.x, x: left.x,
y: left.y, y: left.y,
cursor: CURSOR.resizeHorizontal, cursor: CURSOR.resizeHorizontal,
@ -348,7 +369,7 @@ export function getBoxMaskHandlePositions({
rotation, rotation,
}); });
handles.push({ handles.push({
id: "right", id: { kind: "edge", side: "right" },
x: right.x, x: right.x,
y: right.y, y: right.y,
cursor: CURSOR.resizeHorizontal, cursor: CURSOR.resizeHorizontal,
@ -365,7 +386,7 @@ export function getBoxMaskHandlePositions({
angleRad, angleRad,
}); });
handles.push({ handles.push({
id: "scale", id: { kind: "scale" },
x: point.x, x: point.x,
y: point.y, y: point.y,
cursor: CURSOR.resizeDiagonal, cursor: CURSOR.resizeDiagonal,
@ -383,7 +404,7 @@ export function getBoxMaskRectOverlay({
height, height,
rotation, rotation,
bounds, bounds,
handleId = "position", handleId = { kind: "position" },
cursor = "move", cursor = "move",
dashed = false, dashed = false,
}: { }: {
@ -393,7 +414,7 @@ export function getBoxMaskRectOverlay({
height: number; height: number;
rotation: number; rotation: number;
bounds: ElementBounds; bounds: ElementBounds;
handleId?: string; handleId?: MaskHandleId;
cursor?: string; cursor?: string;
dashed?: boolean; dashed?: boolean;
}): MaskRectOverlay { }): MaskRectOverlay {
@ -421,7 +442,7 @@ export function getBoxMaskShapeOverlay({
rotation, rotation,
bounds, bounds,
pathData, pathData,
handleId = "position", handleId = { kind: "position" },
cursor = "move", cursor = "move",
}: { }: {
centerX: number; centerX: number;
@ -431,7 +452,7 @@ export function getBoxMaskShapeOverlay({
rotation: number; rotation: number;
bounds: ElementBounds; bounds: ElementBounds;
pathData: string; pathData: string;
handleId?: string; handleId?: MaskHandleId;
cursor?: string; cursor?: string;
}): MaskShapeOverlay { }): MaskShapeOverlay {
return { return {

View File

@ -8,6 +8,7 @@ import {
type ScaleEdgePreference, type ScaleEdgePreference,
} from "@/preview/preview-snap"; } from "@/preview/preview-snap";
import type { import type {
MaskHandleId,
MaskSnapArgs, MaskSnapArgs,
MaskSnapResult, MaskSnapResult,
RectangleMaskParams, RectangleMaskParams,
@ -19,13 +20,6 @@ import {
toGlobalMaskSnapLines, toGlobalMaskSnapLines,
} from "./geometry"; } from "./geometry";
const CORNER_SIZE_HANDLES = new Set([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]);
function getClampedRatio({ function getClampedRatio({
next, next,
base, base,
@ -41,23 +35,31 @@ function getClampedRatio({
function getPreferredEdges({ function getPreferredEdges({
handleId, handleId,
}: { }: {
handleId: string; handleId: MaskHandleId;
}): ScaleEdgePreference | undefined { }): ScaleEdgePreference | undefined {
if (handleId.kind === "edge") {
return {
left: handleId.side === "left",
right: handleId.side === "right",
top: handleId.side === "top",
bottom: handleId.side === "bottom",
};
}
if (handleId.kind === "corner") {
return {
left: handleId.corner.x === "left",
right: handleId.corner.x === "right",
top: handleId.corner.y === "top",
bottom: handleId.corner.y === "bottom",
};
}
const preferredEdges = { const preferredEdges = {
left: left: false,
handleId === "left" || right: false,
handleId === "top-left" || top: false,
handleId === "bottom-left", bottom: false,
right:
handleId === "right" ||
handleId === "top-right" ||
handleId === "bottom-right",
top:
handleId === "top" || handleId === "top-left" || handleId === "top-right",
bottom:
handleId === "bottom" ||
handleId === "bottom-left" ||
handleId === "bottom-right",
} satisfies ScaleEdgePreference; } satisfies ScaleEdgePreference;
return Object.values(preferredEdges).some(Boolean) return Object.values(preferredEdges).some(Boolean)
@ -166,7 +168,7 @@ function snapBoxMaskSize({
canvasSize, canvasSize,
snapThreshold, snapThreshold,
}: { }: {
handleId: string; handleId: MaskHandleId;
startParams: RectangleMaskParams; startParams: RectangleMaskParams;
proposedParams: RectangleMaskParams; proposedParams: RectangleMaskParams;
bounds: ElementBounds; bounds: ElementBounds;
@ -188,7 +190,10 @@ function snapBoxMaskSize({
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
const preferredEdges = getPreferredEdges({ handleId }); const preferredEdges = getPreferredEdges({ handleId });
if (handleId === "right" || handleId === "left") { if (
handleId.kind === "edge" &&
(handleId.side === "right" || handleId.side === "left")
) {
const proposedScaleX = getClampedRatio({ const proposedScaleX = getClampedRatio({
next: proposedParams.width, next: proposedParams.width,
base: startParams.width, base: startParams.width,
@ -218,7 +223,10 @@ function snapBoxMaskSize({
}; };
} }
if (handleId === "top" || handleId === "bottom") { if (
handleId.kind === "edge" &&
(handleId.side === "top" || handleId.side === "bottom")
) {
const proposedScaleY = getClampedRatio({ const proposedScaleY = getClampedRatio({
next: proposedParams.height, next: proposedParams.height,
base: startParams.height, base: startParams.height,
@ -251,7 +259,7 @@ function snapBoxMaskSize({
}; };
} }
if (handleId === "scale") { if (handleId.kind === "scale") {
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
const proposedScale = getClampedRatio({ const proposedScale = getClampedRatio({
next: proposedParams.scale, next: proposedParams.scale,
@ -281,7 +289,7 @@ function snapBoxMaskSize({
}; };
} }
if (CORNER_SIZE_HANDLES.has(handleId)) { if (handleId.kind === "corner") {
const proposedScale = getClampedRatio({ const proposedScale = getClampedRatio({
next: proposedParams.width, next: proposedParams.width,
base: startParams.width, base: startParams.width,
@ -322,14 +330,14 @@ export function snapBoxMaskInteraction({
canvasSize, canvasSize,
snapThreshold, snapThreshold,
}: { }: {
handleId: string; handleId: MaskHandleId;
startParams: RectangleMaskParams; startParams: RectangleMaskParams;
proposedParams: RectangleMaskParams; proposedParams: RectangleMaskParams;
bounds: ElementBounds; bounds: ElementBounds;
canvasSize: { width: number; height: number }; canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number }; snapThreshold: { x: number; y: number };
}): MaskSnapResult<RectangleMaskParams> { }): MaskSnapResult<RectangleMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
return snapMaskPosition({ return snapMaskPosition({
proposedParams, proposedParams,
bounds, bounds,
@ -338,7 +346,7 @@ export function snapBoxMaskInteraction({
}); });
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
return snapMaskRotation({ proposedParams }); return snapMaskRotation({ proposedParams });
} }
@ -359,7 +367,7 @@ export function snapSplitMaskInteraction({
canvasSize, canvasSize,
snapThreshold, snapThreshold,
}: MaskSnapArgs<SplitMaskParams>): MaskSnapResult<SplitMaskParams> { }: MaskSnapArgs<SplitMaskParams>): MaskSnapResult<SplitMaskParams> {
if (handleId === "position") { if (handleId.kind === "position") {
return snapMaskPosition({ return snapMaskPosition({
proposedParams, proposedParams,
bounds, bounds,
@ -368,7 +376,7 @@ export function snapSplitMaskInteraction({
}); });
} }
if (handleId === "rotation") { if (handleId.kind === "rotation") {
return snapMaskRotation({ proposedParams }); return snapMaskRotation({ proposedParams });
} }

View File

@ -187,8 +187,43 @@ export type MaskHandleIcon = "rotate" | "feather";
export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent"; export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
type Side = "left" | "right" | "top" | "bottom";
type CornerXY = { x: "left" | "right"; y: "top" | "bottom" };
type CustomTangent = "in" | "out";
export type MaskHandleId =
| { kind: "position" }
| { kind: "rotation" }
| { kind: "feather" }
| { kind: "scale" }
| { kind: "edge"; side: Side }
| { kind: "corner"; corner: CornerXY }
| { kind: "anchor"; pointId: string }
| { kind: "tangent"; pointId: string; side: CustomTangent }
| { kind: "segment"; index: number };
export function maskHandleIdKey({ id }: { id: MaskHandleId }): string {
switch (id.kind) {
case "position":
case "rotation":
case "feather":
case "scale":
return id.kind;
case "edge":
return id.side;
case "corner":
return `${id.corner.y}-${id.corner.x}`;
case "anchor":
return `point:${id.pointId}:anchor`;
case "tangent":
return `point:${id.pointId}:${id.side}`;
case "segment":
return `segment:${id.index}`;
}
}
export interface MaskHandlePosition { export interface MaskHandlePosition {
id: string; id: MaskHandleId;
x: number; x: number;
y: number; y: number;
cursor: string; cursor: string;
@ -205,7 +240,7 @@ export interface MaskLineOverlay {
start: { x: number; y: number }; start: { x: number; y: number };
end: { x: number; y: number }; end: { x: number; y: number };
cursor?: string; cursor?: string;
handleId?: string; handleId?: MaskHandleId;
} }
export interface MaskRectOverlay { export interface MaskRectOverlay {
@ -217,7 +252,7 @@ export interface MaskRectOverlay {
rotation: number; rotation: number;
dashed?: boolean; dashed?: boolean;
cursor?: string; cursor?: string;
handleId?: string; handleId?: MaskHandleId;
} }
export interface MaskShapeOverlay { export interface MaskShapeOverlay {
@ -229,7 +264,7 @@ export interface MaskShapeOverlay {
rotation: number; rotation: number;
pathData: string; pathData: string;
cursor?: string; cursor?: string;
handleId?: string; handleId?: MaskHandleId;
} }
export interface MaskCanvasPathOverlay { export interface MaskCanvasPathOverlay {
@ -238,7 +273,7 @@ export interface MaskCanvasPathOverlay {
pathData: string; pathData: string;
coordinateSpace?: "canvas" | "overlay"; coordinateSpace?: "canvas" | "overlay";
cursor?: string; cursor?: string;
handleId?: string; handleId?: MaskHandleId;
strokeWidth?: number; strokeWidth?: number;
strokeOpacity?: number; strokeOpacity?: number;
} }
@ -256,7 +291,7 @@ export interface MaskDefaultContext {
export interface MaskParamUpdateArgs< export interface MaskParamUpdateArgs<
TParams extends BaseMaskParams = BaseMaskParams, TParams extends BaseMaskParams = BaseMaskParams,
> { > {
handleId: string; handleId: MaskHandleId;
startParams: TParams; startParams: TParams;
deltaX: number; deltaX: number;
deltaY: number; deltaY: number;
@ -267,7 +302,7 @@ export interface MaskParamUpdateArgs<
} }
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> { export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
handleId: string; handleId: MaskHandleId;
startParams: TParams; startParams: TParams;
proposedParams: TParams; proposedParams: TParams;
bounds: ElementBounds; bounds: ElementBounds;

View File

@ -1,12 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { masksRegistry } from "@/masks"; import { masksRegistry } from "@/masks";
import {
parseCustomMaskHandleId,
parseCustomMaskSegmentHandleId,
} from "@/masks/custom-path";
import { appendPointToCustomMask } from "@/masks/definitions/custom"; import { appendPointToCustomMask } from "@/masks/definitions/custom";
import { import {
getVisibleElementsWithBounds, getVisibleElementsWithBounds,
@ -17,14 +13,15 @@ import {
type SnapLine, type SnapLine,
} from "@/preview/preview-snap"; } from "@/preview/preview-snap";
import type { SelectedMaskPointSelection } from "@/selection/editor-selection"; import type { SelectedMaskPointSelection } from "@/selection/editor-selection";
import type { Mask, MaskInteractionResult } from "@/masks/types"; import type { Mask, MaskHandleId, MaskInteractionResult } from "@/masks/types";
import type { MaskableElement } from "@/timeline"; import type { MaskableElement } from "@/timeline";
import { isMaskableElement } from "@/timeline/element-utils";
import { registerCanceller } from "@/editor/cancel-interaction"; import { registerCanceller } from "@/editor/cancel-interaction";
interface DragState { interface DragState {
trackId: string; trackId: string;
elementId: string; elementId: string;
handleId: string; handleId: MaskHandleId;
startCanvasX: number; startCanvasX: number;
startCanvasY: number; startCanvasY: number;
startParams: Mask["params"]; startParams: Mask["params"];
@ -99,12 +96,12 @@ export function useMaskHandles({
const editor = useEditor(); const editor = useEditor();
const isShiftHeldRef = useShiftKey(); const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport(); const viewport = usePreviewViewport();
const [activeHandleId, setActiveHandleId] = useState<string | null>(null); const [activeHandleId, setActiveHandleId] = useState<MaskHandleId | null>(null);
const dragStateRef = useRef<DragState | null>(null); const dragStateRef = useRef<DragState | null>(null);
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
null, null,
); );
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( const captureRef = useRef<{ element: Element; pointerId: number } | null>(
null, null,
); );
@ -137,7 +134,8 @@ export function useMaskHandles({
item.trackId === sel.trackId && item.elementId === sel.elementId, item.trackId === sel.trackId && item.elementId === sel.elementId,
); );
if (!entry) return null; if (!entry) return null;
const element = entry.element as MaskableElement; if (!isMaskableElement(entry.element)) return null;
const element = entry.element;
const masks = element.masks ?? []; const masks = element.masks ?? [];
if (masks.length === 0) return null; if (masks.length === 0) return null;
const activeMaskId = masks.some((mask) => const activeMaskId = masks.some((mask) =>
@ -186,28 +184,27 @@ export function useMaskHandles({
); );
const handlePositions = baseHandlePositions.map((handle) => { const handlePositions = baseHandlePositions.map((handle) => {
const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); if (handle.id.kind !== "anchor") {
if (!parsedHandle || parsedHandle.part !== "anchor") {
return handle; return handle;
} }
return { return {
...handle, ...handle,
isSelected: selectedPointIds.has(parsedHandle.pointId), isSelected: selectedPointIds.has(handle.id.pointId),
}; };
}); });
const customMaskPointIds = const customMaskPointIds = useMemo(
selectedWithMask?.mask.type === "custom" () =>
? handlePositions selectedWithMask?.mask.type === "custom"
.filter((h) => h.kind === "point") ? handlePositions
.map((h) => { .filter((h) => h.kind === "point")
const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); .map((h) => {
return parsedHandle?.part === "anchor" return h.id.kind === "anchor" ? h.id.pointId : null;
? parsedHandle.pointId })
: null; .filter((id): id is string => id !== null)
}) : [],
.filter((id): id is string => id !== null) [handlePositions, selectedWithMask?.mask.type],
: []; );
const isCreatingCustomMask = const isCreatingCustomMask =
selectedWithMask?.mask.type === "custom" && selectedWithMask?.mask.type === "custom" &&
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
@ -339,23 +336,30 @@ export function useMaskHandles({
]); ]);
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { ({
event,
handleId,
}: {
event: React.PointerEvent;
handleId: MaskHandleId;
}) => {
if (!selectedWithMask) return; if (!selectedWithMask) return;
if (event.button !== 0) return; if (event.button !== 0) return;
event.stopPropagation(); event.stopPropagation();
const parsedHandle = const anchorHandle =
selectedWithMask.mask.type === "custom" selectedWithMask.mask.type === "custom" && handleId.kind === "anchor"
? parseCustomMaskHandleId({ handleId }) ? handleId
: null; : null;
const parsedSegmentHandle = const segmentHandle =
selectedWithMask.mask.type === "custom" selectedWithMask.mask.type === "custom" && handleId.kind === "segment"
? parseCustomMaskSegmentHandleId({ handleId }) ? handleId
: null; : null;
if (isCreatingCustomMask) { if (isCreatingCustomMask) {
const firstPointId = customMaskPointIds[0]; const firstPointId = customMaskPointIds[0];
if ( if (
firstPointId && firstPointId &&
handleId === `point:${firstPointId}:anchor` && handleId.kind === "anchor" &&
handleId.pointId === firstPointId &&
customMaskPointIds.length >= 3 && customMaskPointIds.length >= 3 &&
selectedWithMask.mask.type === "custom" selectedWithMask.mask.type === "custom"
) { ) {
@ -390,13 +394,13 @@ export function useMaskHandles({
}); });
if (!pos) return; if (!pos) return;
if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { if (segmentHandle && selectedWithMask.mask.type === "custom") {
setActiveHandleId(handleId); setActiveHandleId(handleId);
pendingSegmentInsertRef.current = { pendingSegmentInsertRef.current = {
trackId: selectedWithMask.trackId, trackId: selectedWithMask.trackId,
elementId: selectedWithMask.elementId, elementId: selectedWithMask.elementId,
maskId: selectedWithMask.mask.id, maskId: selectedWithMask.mask.id,
segmentIndex: parsedSegmentHandle.segmentIndex, segmentIndex: segmentHandle.index,
startClientX: event.clientX, startClientX: event.clientX,
startClientY: event.clientY, startClientY: event.clientY,
startCanvasX: pos.x, startCanvasX: pos.x,
@ -404,7 +408,7 @@ export function useMaskHandles({
startParams: { ...selectedWithMask.mask.params }, startParams: { ...selectedWithMask.mask.params },
bounds: selectedWithMask.bounds, bounds: selectedWithMask.bounds,
}; };
const captureTarget = event.currentTarget as HTMLElement; const captureTarget = event.currentTarget;
captureTarget.setPointerCapture(event.pointerId); captureTarget.setPointerCapture(event.pointerId);
captureRef.current = { captureRef.current = {
element: captureTarget, element: captureTarget,
@ -413,9 +417,9 @@ export function useMaskHandles({
return; return;
} }
if (parsedHandle?.part === "anchor") { if (anchorHandle) {
updateCustomMaskPointSelection({ updateCustomMaskPointSelection({
pointId: parsedHandle.pointId, pointId: anchorHandle.pointId,
toggleSelection: event.shiftKey, toggleSelection: event.shiftKey,
}); });
if (event.shiftKey) { if (event.shiftKey) {
@ -434,7 +438,7 @@ export function useMaskHandles({
startParams: { ...selectedWithMask.mask.params }, startParams: { ...selectedWithMask.mask.params },
}; };
setActiveHandleId(handleId); setActiveHandleId(handleId);
const captureTarget = event.currentTarget as HTMLElement; const captureTarget = event.currentTarget;
captureTarget.setPointerCapture(event.pointerId); captureTarget.setPointerCapture(event.pointerId);
captureRef.current = { captureRef.current = {
element: captureTarget, element: captureTarget,
@ -513,13 +517,13 @@ export function useMaskHandles({
dragStateRef.current = { dragStateRef.current = {
trackId: pendingSegmentInsert.trackId, trackId: pendingSegmentInsert.trackId,
elementId: pendingSegmentInsert.elementId, elementId: pendingSegmentInsert.elementId,
handleId: "position", handleId: { kind: "position" },
startCanvasX: pendingSegmentInsert.startCanvasX, startCanvasX: pendingSegmentInsert.startCanvasX,
startCanvasY: pendingSegmentInsert.startCanvasY, startCanvasY: pendingSegmentInsert.startCanvasY,
startParams: pendingSegmentInsert.startParams, startParams: pendingSegmentInsert.startParams,
}; };
pendingSegmentInsertRef.current = null; pendingSegmentInsertRef.current = null;
setActiveHandleId("position"); setActiveHandleId({ kind: "position" });
} }
} }

View File

@ -3,6 +3,7 @@
import { PEN_CURSOR } from "@/preview/components/cursors"; import { PEN_CURSOR } from "@/preview/components/cursors";
import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useMaskHandles } from "@/masks/use-mask-handles"; import { useMaskHandles } from "@/masks/use-mask-handles";
import { maskHandleIdKey, type MaskHandleId } from "@/masks/types";
import type { SnapLine } from "@/preview/preview-snap"; import type { SnapLine } from "@/preview/preview-snap";
import { import {
CornerHandle, CornerHandle,
@ -72,7 +73,7 @@ export function MaskHandles({
handleId, handleId,
}: { }: {
event: React.PointerEvent; event: React.PointerEvent;
handleId: string; handleId: MaskHandleId;
}) => { }) => {
if (viewport.handlePanPointerDown({ event })) { if (viewport.handlePanPointerDown({ event })) {
return; return;
@ -193,11 +194,12 @@ export function MaskHandles({
})} })}
{handlePositions.map((handle) => { {handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
const key = maskHandleIdKey({ id: handle.id });
if (handle.kind === "icon" && handle.icon === "rotate") { if (handle.kind === "icon" && handle.icon === "rotate") {
return ( return (
<IconHandle <IconHandle
key={handle.id} key={key}
icon={Rotate01Icon} icon={Rotate01Icon}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
@ -212,7 +214,7 @@ export function MaskHandles({
if (handle.kind === "icon" && handle.icon === "feather") { if (handle.kind === "icon" && handle.icon === "feather") {
return ( return (
<IconHandle <IconHandle
key={handle.id} key={key}
icon={FeatherIcon} icon={FeatherIcon}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
@ -227,7 +229,7 @@ export function MaskHandles({
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
return ( return (
<EdgeHandle <EdgeHandle
key={handle.id} key={key}
edge="right" edge="right"
screen={screen} screen={screen}
rotation={handle.rotation ?? 0} rotation={handle.rotation ?? 0}
@ -243,7 +245,7 @@ export function MaskHandles({
if (handle.kind === "edge" && handle.edgeAxis === "vertical") { if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
return ( return (
<EdgeHandle <EdgeHandle
key={handle.id} key={key}
edge="bottom" edge="bottom"
screen={screen} screen={screen}
rotation={handle.rotation ?? 0} rotation={handle.rotation ?? 0}
@ -259,7 +261,7 @@ export function MaskHandles({
if (handle.kind === "point" || handle.kind === "tangent") { if (handle.kind === "point" || handle.kind === "tangent") {
return ( return (
<CircleHandle <CircleHandle
key={handle.id} key={key}
screen={screen} screen={screen}
size={ size={
handle.kind === "tangent" handle.kind === "tangent"
@ -279,7 +281,7 @@ export function MaskHandles({
if (handle.kind === "corner") { if (handle.kind === "corner") {
return ( return (
<CornerHandle <CornerHandle
key={handle.id} key={key}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id }) handleMaskPointerDown({ event, handleId: handle.id })
@ -292,7 +294,7 @@ export function MaskHandles({
return ( return (
<CornerHandle <CornerHandle
key={handle.id} key={key}
cursor={handle.cursor} cursor={handle.cursor}
screen={screen} screen={screen}
onPointerDown={(event) => onPointerDown={(event) =>