From 97f8d1968f186028e41f83b04c0897130e6c69f4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 18:31:24 +0200 Subject: [PATCH] refactor: type mask handle identifiers Made-with: Cursor --- apps/web/src/masks/__tests__/snap.test.ts | 22 ++--- apps/web/src/masks/custom-path.ts | 55 +++--------- apps/web/src/masks/definitions/box-like.ts | 33 +++---- apps/web/src/masks/definitions/custom.ts | 30 +++---- apps/web/src/masks/definitions/split.ts | 6 +- apps/web/src/masks/definitions/text.ts | 19 ++-- apps/web/src/masks/handle-positions.ts | 71 +++++++++------ apps/web/src/masks/snap.ts | 72 +++++++++------- apps/web/src/masks/types.ts | 49 +++++++++-- apps/web/src/masks/use-mask-handles.ts | 86 ++++++++++--------- .../src/preview/components/mask-handles.tsx | 18 ++-- 11 files changed, 252 insertions(+), 209 deletions(-) diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts index 510c6a27..152b04f9 100644 --- a/apps/web/src/masks/__tests__/snap.test.ts +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -150,10 +150,10 @@ function buildCustomMaskParams( function sortSegment( segment: [{ 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 [ - { x: number; y: number }, - { x: number; y: number }, - ]; + const [first, second] = segment; + return first.x < second.x || (first.x === second.x && first.y <= second.y) + ? [first, second] + : [second, first]; } describe("mask geometry", () => { @@ -231,7 +231,7 @@ describe("mask geometry", () => { describe("mask snapping", () => { test("snaps split mask movement using the shared position pipeline", () => { const result = snapSplitMaskInteraction({ - handleId: "position", + handleId: { kind: "position" }, startParams: buildSplitParams({ centerX: 0.03, centerY: -0.04, @@ -255,7 +255,7 @@ describe("mask snapping", () => { test("snaps box mask movement against element center and edges", () => { const result = snapBoxMaskInteraction({ - handleId: "position", + handleId: { kind: "position" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ centerX: 0.29, @@ -276,7 +276,7 @@ describe("mask snapping", () => { test("snaps mask rotation through the shared rotation path", () => { const result = snapBoxMaskInteraction({ - handleId: "rotation", + handleId: { kind: "rotation" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ rotation: 88, @@ -292,7 +292,7 @@ describe("mask snapping", () => { test("snaps edge resize for box masks", () => { const result = snapBoxMaskInteraction({ - handleId: "right", + handleId: { kind: "edge", side: "right" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.98, @@ -308,7 +308,7 @@ describe("mask snapping", () => { test("snaps corner resize for box masks", () => { const result = snapBoxMaskInteraction({ - handleId: "bottom-right", + handleId: { kind: "corner", corner: { x: "right", y: "bottom" } }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.99, @@ -330,7 +330,7 @@ describe("mask snapping", () => { centerY: -0.04, }); const result = textMaskDefinition.interaction.snap?.({ - handleId: "position", + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, @@ -352,7 +352,7 @@ describe("mask snapping", () => { centerY: -0.04, }); const result = customMaskDefinition.interaction.snap?.({ - handleId: "position", + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/custom-path.ts index b35702f6..363bb3bd 100644 --- a/apps/web/src/masks/custom-path.ts +++ b/apps/web/src/masks/custom-path.ts @@ -10,56 +10,29 @@ export interface CustomMaskPathPoint { outY: number; } -export type CustomMaskHandlePart = "anchor" | "in" | "out"; - function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { if (!value || typeof value !== "object") { return false; } - const candidate = value as Record; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + "id" in value && + typeof value.id === "string" && + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "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({ path, }: { diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/definitions/box-like.ts index 5462c14f..37706755 100644 --- a/apps/web/src/masks/definitions/box-like.ts +++ b/apps/web/src/masks/definitions/box-like.ts @@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({ deltaY, bounds, }: MaskParamUpdateArgs): Partial { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; const newRotation = (startParams.rotation + currentAngle) % 360; return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({ const halfWidth = startParams.width * bounds.width; const halfHeight = startParams.height * bounds.height; - if (handleId === "right" || handleId === "left") { - const sign = handleId === "right" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { + const sign = handleId.side === "right" ? 1 : -1; return { width: Math.max( MIN_MASK_DIMENSION, @@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({ }; } - if (handleId === "bottom" || handleId === "top") { - const sign = handleId === "bottom" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "bottom" || handleId.side === "top") + ) { + const sign = handleId.side === "bottom" ? 1 : -1; return { height: Math.max( MIN_MASK_DIMENSION, @@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({ }; } - if ( - handleId === "top-left" || - handleId === "top-right" || - handleId === "bottom-left" || - handleId === "bottom-right" - ) { - const signX = handleId.includes("right") ? 1 : -1; - const signY = handleId.includes("bottom") ? 1 : -1; + if (handleId.kind === "corner") { + const signX = handleId.corner.x === "right" ? 1 : -1; + const signY = handleId.corner.y === "bottom" ? 1 : -1; const distance = Math.sqrt( (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 originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/definitions/custom.ts index 6e0f7af6..08155aae 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/definitions/custom.ts @@ -20,7 +20,6 @@ import { getCustomMaskLocalBounds, getCustomMaskSegmentCount, insertPointIntoCustomMaskSegment, - parseCustomMaskHandleId, recenterCustomMaskPath, type CustomMaskPathPoint, } from "@/masks/custom-path"; @@ -133,12 +132,12 @@ function getCustomMaskDisplayHandles({ scale: params.scale, bounds, closed: true, - }).map((segment) => ({ + }).map((segment): MaskOverlay => ({ id: `segment:${segment.index}`, type: "canvas-path" as const, pathData: segment.pathData, coordinateSpace: "canvas" as const, - handleId: `segment:${segment.index}`, + handleId: { kind: "segment", index: segment.index }, cursor: PEN_CURSOR, strokeOpacity: 0, strokeWidth: segmentStrokeWidth, @@ -166,7 +165,7 @@ function getCustomMaskDisplayHandles({ geometry.anchors.forEach((point) => { handles.push({ - id: `point:${point.id}:anchor`, + id: { kind: "anchor", pointId: point.id }, x: point.anchor.x, y: point.anchor.y, cursor: params.closed ? "move" : "pointer", @@ -201,7 +200,7 @@ function computeCustomMaskParamUpdate({ startCanvasY, bounds, }: MaskParamUpdateArgs): Partial { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -211,7 +210,7 @@ function computeCustomMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -232,7 +231,7 @@ function computeCustomMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -243,7 +242,7 @@ function computeCustomMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -258,8 +257,7 @@ function computeCustomMaskParamUpdate({ }; } - const parsedHandle = parseCustomMaskHandleId({ handleId }); - if (!parsedHandle) { + if (handleId.kind !== "anchor" && handleId.kind !== "tangent") { return {}; } @@ -280,9 +278,9 @@ function computeCustomMaskParamUpdate({ return { path: updateCustomMaskPoint({ points, - pointId: parsedHandle.pointId, + pointId: handleId.pointId, updater: (point) => { - if (parsedHandle.part === "anchor") { + if (handleId.kind === "anchor") { return { ...point, x: localPoint.x, @@ -290,7 +288,7 @@ function computeCustomMaskParamUpdate({ }; } - if (parsedHandle.part === "in") { + if (handleId.side === "in") { return { ...point, inX: localPoint.x - point.x, @@ -349,7 +347,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -377,7 +375,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -390,7 +388,7 @@ export const customMaskDefinition: MaskDefinition<"custom"> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts index de67b876..138db91d 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/definitions/split.ts @@ -128,7 +128,7 @@ function computeSplitMaskParamUpdate({ bounds, canvasSize, }: MaskParamUpdateArgs): Partial { - if (handleId === "position") { + if (handleId.kind === "position") { const rawX = startParams.centerX + deltaX / bounds.width; 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; return computeFeatherUpdate({ 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 pivotY = bounds.cy + startParams.centerY * bounds.height; const startAngle = diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts index aa786e78..db2af7e0 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/definitions/text.ts @@ -4,6 +4,7 @@ import type { MaskParamUpdateArgs, TextMask, TextMaskParams, + MaskHandleId, } from "@/masks/types"; import { DEFAULTS } from "@/timeline/defaults"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; @@ -124,9 +125,9 @@ function measureTextMask({ function getScalePreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): ScaleEdgePreference | undefined { - if (handleId !== "scale") { + if (handleId.kind !== "scale") { return undefined; } @@ -145,7 +146,7 @@ function computeTextMaskParamUpdate({ startCanvasY, bounds, }: MaskParamUpdateArgs): Partial { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -155,7 +156,7 @@ function computeTextMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -176,7 +177,7 @@ function computeTextMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -187,7 +188,7 @@ function computeTextMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -269,7 +270,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -297,7 +298,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -310,7 +311,7 @@ export const textMaskDefinition: MaskDefinition<"text"> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts index 75668959..70039d9b 100644 --- a/apps/web/src/masks/handle-positions.ts +++ b/apps/web/src/masks/handle-positions.ts @@ -2,6 +2,7 @@ import { FEATHER_HANDLE_SCALE } from "@/masks/feather"; import type { ElementBounds } from "@/preview/element-bounds"; import type { MaskFeatures, + MaskHandleId, MaskHandlePosition, MaskLineOverlay, MaskOverlay, @@ -68,14 +69,14 @@ export function getLineMaskOverlay({ centerY, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; centerY: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskLineOverlay { const { start, end } = getLineMaskLinePoints({ @@ -122,7 +123,7 @@ export function getLineMaskHandlePositions({ return [ { - id: "rotation", + id: { kind: "rotation" }, x: cx + normalX * iconOffsetCanvas, y: cy + normalY * iconOffsetCanvas, cursor: CURSOR.rotate, @@ -130,7 +131,7 @@ export function getLineMaskHandlePositions({ icon: "rotate", }, { - id: "feather", + id: { kind: "feather" }, x: cx - normalX * featherOffset, y: cy - normalY * featherOffset, cursor: CURSOR.resizeHorizontal, @@ -201,7 +202,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "rotation", + id: { kind: "rotation" }, x: rotHandle.x, y: rotHandle.y, cursor: CURSOR.rotate, @@ -217,7 +218,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "feather", + id: { kind: "feather" }, x: featherHandle.x, y: featherHandle.y, cursor: CURSOR.resizeVertical, @@ -226,16 +227,36 @@ export function getBoxMaskHandlePositions({ }); if (sizeMode === "width-height") { - const corners = [ - { localX: -halfWidth, localY: -halfHeight, id: "top-left" }, - { localX: halfWidth, localY: -halfHeight, id: "top-right" }, - { localX: halfWidth, localY: halfHeight, id: "bottom-right" }, - { localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, + const corners: { + localX: number; + localY: number; + corner: Extract["corner"]; + }[] = [ + { + 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 }); handles.push({ - id, + id: { kind: "corner", corner }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -264,7 +285,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -273,7 +294,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -282,7 +303,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -306,7 +327,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "top", + id: { kind: "edge", side: "top" }, x: top.x, y: top.y, cursor: CURSOR.resizeVertical, @@ -315,7 +336,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -339,7 +360,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -348,7 +369,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -365,7 +386,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "scale", + id: { kind: "scale" }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -383,7 +404,7 @@ export function getBoxMaskRectOverlay({ height, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", dashed = false, }: { @@ -393,7 +414,7 @@ export function getBoxMaskRectOverlay({ height: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; dashed?: boolean; }): MaskRectOverlay { @@ -421,7 +442,7 @@ export function getBoxMaskShapeOverlay({ rotation, bounds, pathData, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; @@ -431,7 +452,7 @@ export function getBoxMaskShapeOverlay({ rotation: number; bounds: ElementBounds; pathData: string; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskShapeOverlay { return { diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts index 67a0225d..85949823 100644 --- a/apps/web/src/masks/snap.ts +++ b/apps/web/src/masks/snap.ts @@ -8,6 +8,7 @@ import { type ScaleEdgePreference, } from "@/preview/preview-snap"; import type { + MaskHandleId, MaskSnapArgs, MaskSnapResult, RectangleMaskParams, @@ -19,13 +20,6 @@ import { toGlobalMaskSnapLines, } from "./geometry"; -const CORNER_SIZE_HANDLES = new Set([ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]); - function getClampedRatio({ next, base, @@ -41,23 +35,31 @@ function getClampedRatio({ function getPreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): 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 = { - left: - handleId === "left" || - handleId === "top-left" || - handleId === "bottom-left", - 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", + left: false, + right: false, + top: false, + bottom: false, } satisfies ScaleEdgePreference; return Object.values(preferredEdges).some(Boolean) @@ -166,7 +168,7 @@ function snapBoxMaskSize({ canvasSize, snapThreshold, }: { - handleId: string; + handleId: MaskHandleId; startParams: RectangleMaskParams; proposedParams: RectangleMaskParams; bounds: ElementBounds; @@ -188,7 +190,10 @@ function snapBoxMaskSize({ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; const preferredEdges = getPreferredEdges({ handleId }); - if (handleId === "right" || handleId === "left") { + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { const proposedScaleX = getClampedRatio({ next: proposedParams.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({ next: proposedParams.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 proposedScale = getClampedRatio({ next: proposedParams.scale, @@ -281,7 +289,7 @@ function snapBoxMaskSize({ }; } - if (CORNER_SIZE_HANDLES.has(handleId)) { + if (handleId.kind === "corner") { const proposedScale = getClampedRatio({ next: proposedParams.width, base: startParams.width, @@ -322,14 +330,14 @@ export function snapBoxMaskInteraction({ canvasSize, snapThreshold, }: { - handleId: string; + handleId: MaskHandleId; startParams: RectangleMaskParams; proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; }): MaskSnapResult { - if (handleId === "position") { + if (handleId.kind === "position") { return snapMaskPosition({ proposedParams, bounds, @@ -338,7 +346,7 @@ export function snapBoxMaskInteraction({ }); } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { return snapMaskRotation({ proposedParams }); } @@ -359,7 +367,7 @@ export function snapSplitMaskInteraction({ canvasSize, snapThreshold, }: MaskSnapArgs): MaskSnapResult { - if (handleId === "position") { + if (handleId.kind === "position") { return snapMaskPosition({ proposedParams, bounds, @@ -368,7 +376,7 @@ export function snapSplitMaskInteraction({ }); } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { return snapMaskRotation({ proposedParams }); } diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index d35ca889..261edcaf 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -187,8 +187,43 @@ export type MaskHandleIcon = "rotate" | "feather"; 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 { - id: string; + id: MaskHandleId; x: number; y: number; cursor: string; @@ -205,7 +240,7 @@ export interface MaskLineOverlay { start: { x: number; y: number }; end: { x: number; y: number }; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskRectOverlay { @@ -217,7 +252,7 @@ export interface MaskRectOverlay { rotation: number; dashed?: boolean; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskShapeOverlay { @@ -229,7 +264,7 @@ export interface MaskShapeOverlay { rotation: number; pathData: string; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskCanvasPathOverlay { @@ -238,7 +273,7 @@ export interface MaskCanvasPathOverlay { pathData: string; coordinateSpace?: "canvas" | "overlay"; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; strokeWidth?: number; strokeOpacity?: number; } @@ -256,7 +291,7 @@ export interface MaskDefaultContext { export interface MaskParamUpdateArgs< TParams extends BaseMaskParams = BaseMaskParams, > { - handleId: string; + handleId: MaskHandleId; startParams: TParams; deltaX: number; deltaY: number; @@ -267,7 +302,7 @@ export interface MaskParamUpdateArgs< } export interface MaskSnapArgs { - handleId: string; + handleId: MaskHandleId; startParams: TParams; proposedParams: TParams; bounds: ElementBounds; diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts index b81de4f4..00ab0de8 100644 --- a/apps/web/src/masks/use-mask-handles.ts +++ b/apps/web/src/masks/use-mask-handles.ts @@ -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 { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { masksRegistry } from "@/masks"; -import { - parseCustomMaskHandleId, - parseCustomMaskSegmentHandleId, -} from "@/masks/custom-path"; import { appendPointToCustomMask } from "@/masks/definitions/custom"; import { getVisibleElementsWithBounds, @@ -17,14 +13,15 @@ import { type SnapLine, } from "@/preview/preview-snap"; 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 { isMaskableElement } from "@/timeline/element-utils"; import { registerCanceller } from "@/editor/cancel-interaction"; interface DragState { trackId: string; elementId: string; - handleId: string; + handleId: MaskHandleId; startCanvasX: number; startCanvasY: number; startParams: Mask["params"]; @@ -99,12 +96,12 @@ export function useMaskHandles({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [activeHandleId, setActiveHandleId] = useState(null); + const [activeHandleId, setActiveHandleId] = useState(null); const dragStateRef = useRef(null); const pendingSegmentInsertRef = useRef( null, ); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + const captureRef = useRef<{ element: Element; pointerId: number } | null>( null, ); @@ -137,7 +134,8 @@ export function useMaskHandles({ item.trackId === sel.trackId && item.elementId === sel.elementId, ); if (!entry) return null; - const element = entry.element as MaskableElement; + if (!isMaskableElement(entry.element)) return null; + const element = entry.element; const masks = element.masks ?? []; if (masks.length === 0) return null; const activeMaskId = masks.some((mask) => @@ -186,28 +184,27 @@ export function useMaskHandles({ ); const handlePositions = baseHandlePositions.map((handle) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); - if (!parsedHandle || parsedHandle.part !== "anchor") { + if (handle.id.kind !== "anchor") { return handle; } return { ...handle, - isSelected: selectedPointIds.has(parsedHandle.pointId), + isSelected: selectedPointIds.has(handle.id.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 customMaskPointIds = useMemo( + () => + selectedWithMask?.mask.type === "custom" + ? handlePositions + .filter((h) => h.kind === "point") + .map((h) => { + return h.id.kind === "anchor" ? h.id.pointId : null; + }) + .filter((id): id is string => id !== null) + : [], + [handlePositions, selectedWithMask?.mask.type], + ); const isCreatingCustomMask = selectedWithMask?.mask.type === "custom" && (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); @@ -339,23 +336,30 @@ export function useMaskHandles({ ]); const handlePointerDown = useCallback( - ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + ({ + event, + handleId, + }: { + event: React.PointerEvent; + handleId: MaskHandleId; + }) => { if (!selectedWithMask) return; if (event.button !== 0) return; event.stopPropagation(); - const parsedHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskHandleId({ handleId }) + const anchorHandle = + selectedWithMask.mask.type === "custom" && handleId.kind === "anchor" + ? handleId : null; - const parsedSegmentHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskSegmentHandleId({ handleId }) + const segmentHandle = + selectedWithMask.mask.type === "custom" && handleId.kind === "segment" + ? handleId : null; if (isCreatingCustomMask) { const firstPointId = customMaskPointIds[0]; if ( firstPointId && - handleId === `point:${firstPointId}:anchor` && + handleId.kind === "anchor" && + handleId.pointId === firstPointId && customMaskPointIds.length >= 3 && selectedWithMask.mask.type === "custom" ) { @@ -390,13 +394,13 @@ export function useMaskHandles({ }); if (!pos) return; - if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { + if (segmentHandle && selectedWithMask.mask.type === "custom") { setActiveHandleId(handleId); pendingSegmentInsertRef.current = { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, - segmentIndex: parsedSegmentHandle.segmentIndex, + segmentIndex: segmentHandle.index, startClientX: event.clientX, startClientY: event.clientY, startCanvasX: pos.x, @@ -404,7 +408,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, bounds: selectedWithMask.bounds, }; - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -413,9 +417,9 @@ export function useMaskHandles({ return; } - if (parsedHandle?.part === "anchor") { + if (anchorHandle) { updateCustomMaskPointSelection({ - pointId: parsedHandle.pointId, + pointId: anchorHandle.pointId, toggleSelection: event.shiftKey, }); if (event.shiftKey) { @@ -434,7 +438,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, }; setActiveHandleId(handleId); - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -513,13 +517,13 @@ export function useMaskHandles({ dragStateRef.current = { trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, - handleId: "position", + handleId: { kind: "position" }, startCanvasX: pendingSegmentInsert.startCanvasX, startCanvasY: pendingSegmentInsert.startCanvasY, startParams: pendingSegmentInsert.startParams, }; pendingSegmentInsertRef.current = null; - setActiveHandleId("position"); + setActiveHandleId({ kind: "position" }); } } diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx index 340cab0a..e19df45f 100644 --- a/apps/web/src/preview/components/mask-handles.tsx +++ b/apps/web/src/preview/components/mask-handles.tsx @@ -3,6 +3,7 @@ import { PEN_CURSOR } from "@/preview/components/cursors"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useMaskHandles } from "@/masks/use-mask-handles"; +import { maskHandleIdKey, type MaskHandleId } from "@/masks/types"; import type { SnapLine } from "@/preview/preview-snap"; import { CornerHandle, @@ -72,7 +73,7 @@ export function MaskHandles({ handleId, }: { event: React.PointerEvent; - handleId: string; + handleId: MaskHandleId; }) => { if (viewport.handlePanPointerDown({ event })) { return; @@ -193,11 +194,12 @@ export function MaskHandles({ })} {handlePositions.map((handle) => { const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + const key = maskHandleIdKey({ id: handle.id }); if (handle.kind === "icon" && handle.icon === "rotate") { return ( @@ -212,7 +214,7 @@ export function MaskHandles({ if (handle.kind === "icon" && handle.icon === "feather") { return ( @@ -227,7 +229,7 @@ export function MaskHandles({ if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { return ( handleMaskPointerDown({ event, handleId: handle.id }) @@ -292,7 +294,7 @@ export function MaskHandles({ return (