summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/masks/__tests__/snap.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/masks/__tests__/snap.test.ts')
-rw-r--r--apps/web/src/lib/masks/__tests__/snap.test.ts252
1 files changed, 251 insertions, 1 deletions
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
index 72b2ca80..e2f9cafb 100644
--- a/apps/web/src/lib/masks/__tests__/snap.test.ts
+++ b/apps/web/src/lib/masks/__tests__/snap.test.ts
@@ -1,9 +1,26 @@
import { describe, expect, test } from "bun:test";
+import {
+ findClosestPointOnCustomMaskSegment,
+ getCustomMaskClosedStateAfterPointRemoval,
+ insertPointIntoCustomMaskSegment,
+ removeCustomMaskPoints,
+} from "@/lib/masks/custom-path";
+import {
+ appendPointToCustomMask,
+ customMaskDefinition,
+ insertPointOnCustomMaskSegment,
+} from "@/lib/masks/definitions/custom";
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
+import { textMaskDefinition } from "@/lib/masks/definitions/text";
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
import { snapMaskInteraction } from "@/lib/masks/snap";
import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
+import type {
+ CustomMaskParams,
+ RectangleMaskParams,
+ SplitMaskParams,
+ TextMaskParams,
+} from "@/lib/masks/types";
const bounds: ElementBounds = {
cx: 200,
@@ -58,6 +75,78 @@ function buildRectangleParams(
};
}
+function buildTextMaskParams(
+ overrides: Partial<TextMaskParams> = {},
+): TextMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ content: "Mask",
+ fontSize: 15,
+ fontFamily: "Arial",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: 0,
+ lineHeight: 1.2,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function buildCustomMaskParams(
+ overrides: Partial<CustomMaskParams> = {},
+): CustomMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [
+ {
+ id: "a",
+ x: -0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "b",
+ x: 0.2,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ {
+ id: "c",
+ x: 0,
+ y: 0.2,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ },
+ ],
+ closed: true,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
@@ -234,4 +323,165 @@ describe("mask snapping", () => {
expect(result.params.height).toBe(0.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
+
+ test("snaps text mask movement using intrinsic text bounds", () => {
+ const params = buildTextMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = textMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps custom mask movement using path geometry bounds", () => {
+ const params = buildCustomMaskParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ });
+ const result = customMaskDefinition.interaction.snap?.({
+ handleId: "position",
+ startParams: params,
+ proposedParams: params,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result?.params.centerX).toBe(0);
+ expect(result?.params.centerY).toBe(0);
+ expect(result?.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("marks blank text masks inactive", () => {
+ expect(
+ textMaskDefinition.isActive?.(
+ buildTextMaskParams({
+ content: " ",
+ }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask creation", () => {
+ test("anchors the first point at the click position", () => {
+ const params = buildCustomMaskParams({
+ path: [],
+ closed: false,
+ });
+ const next = appendPointToCustomMask({
+ params,
+ canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
+ bounds,
+ });
+
+ expect(next.centerX).toBeCloseTo(0.1);
+ expect(next.centerY).toBeCloseTo(-0.1);
+ expect(next.rotation).toBe(0);
+ expect(next.scale).toBe(1);
+ expect(next.path).toHaveLength(1);
+ });
+});
+
+describe("custom mask point deletion", () => {
+ test("removes the selected points by id", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["b"],
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
+ });
+
+ test("reopens a closed path once fewer than three points remain", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = removeCustomMaskPoints({
+ points,
+ pointIds: ["c"],
+ });
+
+ expect(
+ getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed: true,
+ remainingPointCount: nextPoints.length,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("custom mask point insertion", () => {
+ test("finds the closest point on the clicked segment", () => {
+ const params = buildCustomMaskParams();
+ const points = params.path;
+ const closestPoint = findClosestPointOnCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: params.closed,
+ });
+
+ expect(closestPoint).not.toBeNull();
+ expect(closestPoint?.t).toBeCloseTo(0.5, 1);
+ expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
+ expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
+ });
+
+ test("splits a segment into two segments at the insertion point", () => {
+ const points = buildCustomMaskParams().path;
+ const nextPoints = insertPointIntoCustomMaskSegment({
+ points,
+ segmentIndex: 0,
+ pointId: "new",
+ t: 0.5,
+ closed: true,
+ });
+
+ expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
+ expect(nextPoints[1]).toMatchObject({
+ id: "new",
+ x: 0,
+ y: -0.1,
+ inX: 0,
+ inY: 0,
+ outX: 0,
+ outY: 0,
+ });
+ });
+
+ test("builds updated custom mask params for a clicked segment", () => {
+ const result = insertPointOnCustomMaskSegment({
+ params: buildCustomMaskParams(),
+ segmentIndex: 0,
+ canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
+ bounds,
+ pointId: "new",
+ });
+
+ expect(result).not.toBeNull();
+ const nextPoints = result?.params.path ?? [];
+ expect(nextPoints).toHaveLength(4);
+ expect(nextPoints.some((point) => point.id === "new")).toBe(true);
+ });
});