summaryrefslogtreecommitdiff
path: root/apps/web/src/masks/handle-positions.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/masks/handle-positions.ts')
-rw-r--r--apps/web/src/masks/handle-positions.ts497
1 files changed, 497 insertions, 0 deletions
diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts
new file mode 100644
index 00000000..75668959
--- /dev/null
+++ b/apps/web/src/masks/handle-positions.ts
@@ -0,0 +1,497 @@
+import { FEATHER_HANDLE_SCALE } from "@/masks/feather";
+import type { ElementBounds } from "@/preview/element-bounds";
+import type {
+ MaskFeatures,
+ MaskHandlePosition,
+ MaskLineOverlay,
+ MaskOverlay,
+ MaskRectOverlay,
+ RectangleMaskParams,
+ MaskShapeOverlay,
+} from "@/masks/types";
+
+const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
+const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
+const LINE_EXTENT_MULTIPLIER = 50;
+
+const CURSOR = {
+ rotate: "crosshair",
+ resizeDiagonal: "nwse-resize",
+ resizeHorizontal: "ew-resize",
+ resizeVertical: "ns-resize",
+} as const;
+
+/**
+ * The renderer defines the split line as:
+ * - normal direction: (cos(rotation), sin(rotation))
+ * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
+ * - reference point: (centerX * width, centerY * height) from element centre
+ *
+ * So rotation=0 → normal points right → line runs vertically.
+ */
+export function getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+}): { start: { x: number; y: number }; end: { x: number; y: number } } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+ const lineDirX = -normalY;
+ const lineDirY = normalX;
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
+
+ return {
+ start: {
+ x: cx - lineDirX * extent,
+ y: cy - lineDirY * extent,
+ },
+ end: {
+ x: cx + lineDirX * extent,
+ y: cy + lineDirY * extent,
+ },
+ };
+}
+
+export function getLineMaskOverlay({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+}): MaskLineOverlay {
+ const { start, end } = getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+ });
+
+ return {
+ id: "line",
+ type: "line",
+ start,
+ end,
+ handleId,
+ cursor,
+ };
+}
+
+export function getLineMaskHandlePositions({
+ centerX,
+ centerY,
+ rotation,
+ feather,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ feather: number;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
+ const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
+
+ return [
+ {
+ id: "rotation",
+ x: cx + normalX * iconOffsetCanvas,
+ y: cy + normalY * iconOffsetCanvas,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ },
+ {
+ id: "feather",
+ x: cx - normalX * featherOffset,
+ y: cy - normalY * featherOffset,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "icon",
+ icon: "feather",
+ },
+ ];
+}
+
+function rotatePoint({
+ localX,
+ localY,
+ cx,
+ cy,
+ angleRad,
+}: {
+ localX: number;
+ localY: number;
+ cx: number;
+ cy: number;
+ angleRad: number;
+}): { x: number; y: number } {
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: cx + localX * cos - localY * sin,
+ y: cy + localX * sin + localY * cos,
+ };
+}
+
+export function getBoxMaskHandlePositions({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ feather,
+ sizeMode,
+ showScaleHandle = true,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ feather: number;
+ sizeMode: MaskFeatures["sizeMode"];
+ showScaleHandle?: boolean;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+ const angleRad = (rotation * Math.PI) / 180;
+ const halfWidth = (width * bounds.width) / 2;
+ const halfHeight = (height * bounds.height) / 2;
+
+ const handles: MaskHandlePosition[] = [];
+ const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
+
+ const rotHandle = rotatePoint({
+ localX: 0,
+ localY: -halfHeight - handleOffsetCanvas,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "rotation",
+ x: rotHandle.x,
+ y: rotHandle.y,
+ cursor: CURSOR.rotate,
+ kind: "icon",
+ icon: "rotate",
+ });
+
+ const featherHandle = rotatePoint({
+ localX: 0,
+ localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "feather",
+ x: featherHandle.x,
+ y: featherHandle.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "icon",
+ icon: "feather",
+ });
+
+ 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" },
+ ];
+ for (const { localX, localY, id } of corners) {
+ const point = rotatePoint({ localX, localY, cx, cy, angleRad });
+ handles.push({
+ id,
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "height-only") {
+ const top = rotatePoint({
+ localX: 0,
+ localY: -halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "top",
+ x: top.x,
+ y: top.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ kind: "edge",
+ edgeAxis: "vertical",
+ rotation,
+ });
+ } else if (sizeMode === "width-only") {
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ kind: "edge",
+ edgeAxis: "horizontal",
+ rotation,
+ });
+ } else if (sizeMode === "uniform" && showScaleHandle) {
+ const point = rotatePoint({
+ localX: halfWidth,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "scale",
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ kind: "corner",
+ });
+ }
+
+ return handles;
+}
+
+export function getBoxMaskRectOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ handleId = "position",
+ cursor = "move",
+ dashed = false,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ handleId?: string;
+ cursor?: string;
+ dashed?: boolean;
+}): MaskRectOverlay {
+ return {
+ id: "bounding-box",
+ type: "rect",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ handleId,
+ cursor,
+ dashed,
+ };
+}
+
+export function getBoxMaskShapeOverlay({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ bounds,
+ pathData,
+ handleId = "position",
+ cursor = "move",
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ bounds: ElementBounds;
+ pathData: string;
+ handleId?: string;
+ cursor?: string;
+}): MaskShapeOverlay {
+ return {
+ id: "shape-outline",
+ type: "shape",
+ center: {
+ x: bounds.cx + centerX * bounds.width,
+ y: bounds.cy + centerY * bounds.height,
+ },
+ width: width * bounds.width,
+ height: height * bounds.height,
+ rotation,
+ pathData,
+ handleId,
+ cursor,
+ };
+}
+
+export function getBoxMaskOverlays({
+ params,
+ bounds,
+ pathData,
+ showBoundingBox = true,
+}: {
+ params: Pick<
+ RectangleMaskParams,
+ "centerX" | "centerY" | "width" | "height" | "rotation"
+ >;
+ bounds: ElementBounds;
+ pathData?: string;
+ showBoundingBox?: boolean;
+}): MaskOverlay[] {
+ const overlays: MaskOverlay[] = [];
+ if (showBoundingBox) {
+ overlays.push(
+ getBoxMaskRectOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ dashed: Boolean(pathData),
+ }),
+ );
+ }
+
+ if (pathData) {
+ overlays.push(
+ getBoxMaskShapeOverlay({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: params.width,
+ height: params.height,
+ rotation: params.rotation,
+ bounds,
+ pathData,
+ }),
+ );
+ }
+
+ return overlays;
+}