summaryrefslogtreecommitdiff
path: root/apps/web/src/masks/definitions/custom.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/masks/definitions/custom.ts')
-rw-r--r--apps/web/src/masks/definitions/custom.ts652
1 files changed, 652 insertions, 0 deletions
diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/definitions/custom.ts
new file mode 100644
index 00000000..cd6e2055
--- /dev/null
+++ b/apps/web/src/masks/definitions/custom.ts
@@ -0,0 +1,652 @@
+import { generateUUID } from "@/utils/id";
+import type { ParamDefinition } from "@/params";
+import { PEN_CURSOR } from "@/preview/components/cursors";
+import type { ElementBounds } from "@/preview/element-bounds";
+import type {
+ CustomMask,
+ CustomMaskParams,
+ MaskDefinition,
+ MaskHandlePosition,
+ MaskOverlay,
+ MaskParamUpdateArgs,
+} from "@/masks/types";
+import {
+ buildCustomMaskPath2D,
+ buildCustomMaskSvgPath,
+ customMaskCanvasPointToLocal,
+ findClosestPointOnCustomMaskSegment,
+ getCustomMaskCanvasSegments,
+ getCustomMaskCanvasGeometry,
+ getCustomMaskLocalBounds,
+ getCustomMaskSegmentCount,
+ insertPointIntoCustomMaskSegment,
+ parseCustomMaskHandleId,
+ recenterCustomMaskPath,
+ type CustomMaskPathPoint,
+} from "@/masks/custom-path";
+import { getBoxMaskHandlePositions } from "@/masks/handle-positions";
+import { computeFeatherUpdate } from "@/masks/param-update";
+import {
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "@/masks/geometry";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+} from "@/preview/preview-snap";
+
+const PERCENTAGE_DISPLAY = {
+ displayMultiplier: 100,
+ step: 1,
+} as const;
+
+const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+];
+
+function getCustomMaskDisplayHandles({
+ params,
+ displayScale,
+ bounds,
+}: {
+ params: CustomMaskParams;
+ displayScale: number;
+ bounds: ElementBounds;
+}): {
+ handles: MaskHandlePosition[];
+ overlays: MaskOverlay[];
+} {
+ const points = params.path;
+ const geometry = getCustomMaskCanvasGeometry({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ const handles: MaskHandlePosition[] = [];
+ const overlays: MaskOverlay[] = [];
+
+ if (points.length > 0) {
+ overlays.push({
+ id: "path",
+ type: "canvas-path",
+ pathData: buildCustomMaskSvgPath({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ }),
+ coordinateSpace: "canvas",
+ });
+ }
+
+ if (params.closed) {
+ const segmentStrokeWidth = 12;
+ overlays.push(
+ ...getCustomMaskCanvasSegments({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: true,
+ }).map((segment) => ({
+ id: `segment:${segment.index}`,
+ type: "canvas-path" as const,
+ pathData: segment.pathData,
+ coordinateSpace: "canvas" as const,
+ handleId: `segment:${segment.index}`,
+ cursor: PEN_CURSOR,
+ strokeOpacity: 0,
+ strokeWidth: segmentStrokeWidth,
+ })),
+ );
+ }
+
+ const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ if (params.closed && localBounds) {
+ handles.push(
+ ...getBoxMaskHandlePositions({
+ centerX: params.centerX,
+ centerY: params.centerY,
+ width: (localBounds.width * params.scale) / bounds.width,
+ height: (localBounds.height * params.scale) / bounds.height,
+ rotation: params.rotation,
+ feather: params.feather,
+ sizeMode: "uniform",
+ showScaleHandle: false,
+ bounds,
+ displayScale,
+ }),
+ );
+ }
+
+ geometry.anchors.forEach((point) => {
+ handles.push({
+ id: `point:${point.id}:anchor`,
+ x: point.anchor.x,
+ y: point.anchor.y,
+ cursor: params.closed ? "move" : "pointer",
+ kind: "point",
+ });
+ });
+
+ return {
+ handles,
+ overlays,
+ };
+}
+
+function updateCustomMaskPoint({
+ points,
+ pointId,
+ updater,
+}: {
+ points: CustomMaskPathPoint[];
+ pointId: string;
+ updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
+}) {
+ return points.map((point) => (point.id === pointId ? updater(point) : point));
+}
+
+function computeCustomMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+
+ if (handleId === "rotation") {
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ if (handleId === "scale") {
+ const startDistance = Math.hypot(
+ startCanvasX - pivotX,
+ startCanvasY - pivotY,
+ );
+ const currentDistance = Math.hypot(
+ startCanvasX + deltaX - pivotX,
+ startCanvasY + deltaY - pivotY,
+ );
+ const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
+ return {
+ scale: Math.max(0.01, startParams.scale * scaleFactor),
+ };
+ }
+
+ const parsedHandle = parseCustomMaskHandleId({ handleId });
+ if (!parsedHandle) {
+ return {};
+ }
+
+ const points = startParams.path;
+ const currentPoint = {
+ x: startCanvasX + deltaX,
+ y: startCanvasY + deltaY,
+ };
+ const localPoint = customMaskCanvasPointToLocal({
+ point: currentPoint,
+ centerX: startParams.centerX,
+ centerY: startParams.centerY,
+ rotation: startParams.rotation,
+ scale: startParams.scale,
+ bounds,
+ });
+
+ return {
+ path: updateCustomMaskPoint({
+ points,
+ pointId: parsedHandle.pointId,
+ updater: (point) => {
+ if (parsedHandle.part === "anchor") {
+ return {
+ ...point,
+ x: localPoint.x,
+ y: localPoint.y,
+ };
+ }
+
+ if (parsedHandle.part === "in") {
+ return {
+ ...point,
+ inX: localPoint.x - point.x,
+ inY: localPoint.y - point.y,
+ };
+ }
+
+ return {
+ ...point,
+ outX: localPoint.x - point.x,
+ outY: localPoint.y - point.y,
+ };
+ },
+ }),
+ };
+}
+
+export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
+ type: "custom",
+ name: "Custom",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "uniform",
+ },
+ params: CUSTOM_MASK_PARAMS,
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ return getCustomMaskDisplayHandles({ params, bounds, displayScale });
+ },
+ snap({
+ handleId,
+ proposedParams,
+ startParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) {
+ const points = startParams.path;
+ const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ if (!startParams.closed || !localBounds) {
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ }
+
+ const position = {
+ x: proposedParams.centerX * bounds.width,
+ y: proposedParams.centerY * bounds.height,
+ };
+
+ if (handleId === "position") {
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: position,
+ canvasSize: bounds,
+ elementSize: {
+ width: localBounds.width * proposedParams.scale,
+ height: localBounds.height * proposedParams.scale,
+ },
+ rotation: proposedParams.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId === "scale") {
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale: proposedParams.scale,
+ position,
+ baseWidth: localBounds.width,
+ baseHeight: localBounds.height,
+ rotation: proposedParams.rotation,
+ canvasSize: bounds,
+ snapThreshold,
+ preferredEdges: {
+ right: true,
+ bottom: true,
+ },
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(0.01, snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ },
+ },
+ buildDefault(): Omit<CustomMask, "id"> {
+ return {
+ type: "custom",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [],
+ closed: false,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ },
+ };
+ },
+ computeParamUpdate: computeCustomMaskParamUpdate,
+ isActive(params) {
+ return params.closed;
+ },
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ const points = params.path;
+ if (!params.closed) {
+ return new Path2D();
+ }
+
+ return buildCustomMaskPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
+ },
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams as CustomMaskParams;
+ if (!params.closed) {
+ return;
+ }
+
+ const points = params.path;
+ const path = buildCustomMaskPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
+
+ ctx.save();
+ ctx.strokeStyle = params.strokeColor;
+ ctx.lineWidth = params.strokeWidth;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.stroke(path);
+
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
+
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
+ ctx.restore();
+ },
+ },
+};
+
+export function appendPointToCustomMask({
+ params,
+ canvasPoint,
+ bounds,
+}: {
+ params: CustomMaskParams;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): CustomMaskParams {
+ const points = params.path;
+
+ if (points.length === 0) {
+ return {
+ ...params,
+ centerX:
+ bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
+ centerY:
+ bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
+ rotation: 0,
+ scale: 1,
+ path: [
+ {
+ id: generateUUID(),
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ],
+ };
+ }
+
+ const localPoint = customMaskCanvasPointToLocal({
+ point: canvasPoint,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+ const nextPoints = [
+ ...points,
+ {
+ id: generateUUID(),
+ x: localPoint.x,
+ y: localPoint.y,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ];
+ const recentered = recenterCustomMaskPath({
+ points: nextPoints,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ return {
+ ...params,
+ centerX: recentered.centerX,
+ centerY: recentered.centerY,
+ path: recentered.points,
+ };
+}
+
+export function insertPointOnCustomMaskSegment({
+ params,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ pointId = generateUUID(),
+}: {
+ params: CustomMaskParams;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ pointId?: string;
+}): { params: CustomMaskParams; pointId: string } | null {
+ const points = params.path;
+ if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
+ return null;
+ }
+
+ const closestPoint = findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex,
+ canvasPoint,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ });
+ if (!closestPoint) {
+ return null;
+ }
+
+ const nextPoints = insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex,
+ pointId,
+ t: closestPoint.t,
+ closed: params.closed,
+ });
+ if (nextPoints.length === points.length) {
+ return null;
+ }
+
+ const recentered = recenterCustomMaskPath({
+ points: nextPoints,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ });
+
+ return {
+ pointId,
+ params: {
+ ...params,
+ centerX: recentered.centerX,
+ centerY: recentered.centerY,
+ path: recentered.points,
+ },
+ };
+}