diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/masks | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/masks')
23 files changed, 0 insertions, 5363 deletions
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts deleted file mode 100644 index e2f9cafb..00000000 --- a/apps/web/src/lib/masks/__tests__/snap.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -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 { - CustomMaskParams, - RectangleMaskParams, - SplitMaskParams, - TextMaskParams, -} from "@/lib/masks/types"; - -const bounds: ElementBounds = { - cx: 200, - cy: 150, - width: 200, - height: 100, - rotation: 0, -}; - -const canvasSize = { - width: 400, - height: 300, -}; - -const snapThreshold = { - x: 8, - y: 8, -}; - -function buildSplitParams( - overrides: Partial<SplitMaskParams> = {}, -): SplitMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - centerX: 0, - centerY: 0, - rotation: 0, - ...overrides, - }; -} - -function buildRectangleParams( - overrides: Partial<RectangleMaskParams> = {}, -): RectangleMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - centerX: 0, - centerY: 0, - width: 0.4, - height: 0.2, - rotation: 0, - scale: 1, - ...overrides, - }; -} - -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 }] { - 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 }, - ]; -} - -describe("mask geometry", () => { - test("resolves split mask center from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildSplitParams({ - centerX: 0.25, - centerY: -0.5, - rotation: 45, - }), - bounds, - }), - ).toEqual({ - position: { x: 50, y: -50 }, - size: { width: 0, height: 0 }, - rotation: 45, - }); - }); - - test("resolves box mask center and size from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildRectangleParams({ - centerX: -0.25, - centerY: 0.5, - width: 0.5, - height: 0.6, - rotation: 30, - }), - bounds, - }), - ).toEqual({ - position: { x: -50, y: 50 }, - size: { width: 100, height: 60 }, - rotation: 30, - }); - }); - - test("returns a vertical split stroke segment for rotation 0", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams(), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 0"); - } - expect(sortSegment(segment)).toEqual([ - { x: bounds.width / 2, y: 0 }, - { x: bounds.width / 2, y: bounds.height }, - ]); - }); - - test("returns a horizontal split stroke segment for rotation 90", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams({ rotation: 90 }), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 90"); - } - expect(sortSegment(segment)).toEqual([ - { x: 0, y: bounds.height / 2 }, - { x: bounds.width, y: bounds.height / 2 }, - ]); - }); -}); - -describe("mask snapping", () => { - test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - proposedParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - 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 box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - centerX: 0.29, - centerY: 0.03, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBeCloseTo(0.3); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 100 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - rotation: 88, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.rotation).toBe(90); - expect(result.activeLines).toEqual([]); - }); - - test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.98, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); - - test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.99, - height: 0.495, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - 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); - }); -}); diff --git a/apps/web/src/lib/masks/custom-path.ts b/apps/web/src/lib/masks/custom-path.ts deleted file mode 100644 index 94a9043c..00000000 --- a/apps/web/src/lib/masks/custom-path.ts +++ /dev/null @@ -1,842 +0,0 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; - -export interface CustomMaskPathPoint { - id: string; - x: number; - y: number; - inX: number; - inY: number; - outX: number; - 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<string, unknown>; - 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" - ); -} - -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, -}: { - path: string; -}): CustomMaskPathPoint[] { - if (!path) { - return []; - } - - try { - const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; - } catch { - return []; - } -} - -export function serializeCustomMaskPath({ - points, -}: { - points: CustomMaskPathPoint[]; -}): string { - return JSON.stringify(points); -} - -export function removeCustomMaskPoints({ - points, - pointIds, -}: { - points: CustomMaskPathPoint[]; - pointIds: string[]; -}): CustomMaskPathPoint[] { - if (pointIds.length === 0) { - return points; - } - const pointIdsToRemove = new Set(pointIds); - return points.filter((point) => !pointIdsToRemove.has(point.id)); -} - -export function getCustomMaskClosedStateAfterPointRemoval({ - wasClosed, - remainingPointCount, -}: { - wasClosed: boolean; - remainingPointCount: number; -}): boolean { - return wasClosed && remainingPointCount >= 3; -} - -function rotatePoint({ - x, - y, - rotationDegrees, -}: { - x: number; - y: number; - rotationDegrees: number; -}): { x: number; y: number } { - const angleRad = (rotationDegrees * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - return { - x: x * cos - y * sin, - y: x * sin + y * cos, - }; -} - -export function getCustomMaskCenterCanvasPoint({ - centerX, - centerY, - bounds, -}: { - centerX: number; - centerY: number; - bounds: ElementBounds; -}): { x: number; y: number } { - return { - x: bounds.cx + centerX * bounds.width, - y: bounds.cy + centerY * bounds.height, - }; -} - -export function customMaskLocalPointToCanvas({ - point, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - point: { x: number; y: number }; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); - const scaledLocal = { - x: point.x * bounds.width * scale, - y: point.y * bounds.height * scale, - }; - const rotated = rotatePoint({ - x: scaledLocal.x, - y: scaledLocal.y, - rotationDegrees: rotation, - }); - - return { - x: center.x + rotated.x, - y: center.y + rotated.y, - }; -} - -export function customMaskCanvasPointToLocal({ - point, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - point: { x: number; y: number }; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); - const translated = { - x: point.x - center.x, - y: point.y - center.y, - }; - const rotated = rotatePoint({ - x: translated.x, - y: translated.y, - rotationDegrees: -rotation, - }); - - return { - x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale), - y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale), - }; -} - -export function getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - const anchors = points.map((point) => ({ - id: point.id, - anchor: customMaskLocalPointToCanvas({ - point: { x: point.x, y: point.y }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - inHandle: customMaskLocalPointToCanvas({ - point: { x: point.x + point.inX, y: point.y + point.inY }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - outHandle: customMaskLocalPointToCanvas({ - point: { x: point.x + point.outX, y: point.y + point.outY }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - })); - - const geometryPoints = anchors.flatMap((point) => [ - point.anchor, - point.inHandle, - point.outHandle, - ]); - if (geometryPoints.length === 0) { - return { - anchors, - bounds: null, - }; - } - - const geometryBounds = getCanvasPointBounds({ - points: geometryPoints, - }); - - return { - anchors, - bounds: geometryBounds, - }; -} - -export interface CanvasPoint { - x: number; - y: number; -} - -function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { - minX: number; - maxX: number; - minY: number; - maxY: number; - width: number; - height: number; - centerX: number; - centerY: number; -} | null { - if (points.length === 0) { - return null; - } - - const [firstPoint, ...restPoints] = points; - let minX = firstPoint.x; - let maxX = firstPoint.x; - let minY = firstPoint.y; - let maxY = firstPoint.y; - - for (const point of restPoints) { - minX = Math.min(minX, point.x); - maxX = Math.max(maxX, point.x); - minY = Math.min(minY, point.y); - maxY = Math.max(maxY, point.y); - } - - return { - minX, - maxX, - minY, - maxY, - width: Math.max(1, maxX - minX), - height: Math.max(1, maxY - minY), - centerX: (minX + maxX) / 2, - centerY: (minY + maxY) / 2, - }; -} - -export interface CustomMaskCanvasSegment { - index: number; - startPointId: string; - endPointId: string; - start: CanvasPoint; - startOut: CanvasPoint; - endIn: CanvasPoint; - end: CanvasPoint; - pathData: string; -} - -function clampUnit(value: number): number { - return Math.min(1, Math.max(0, value)); -} - -function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number { - const dx = a.x - b.x; - const dy = a.y - b.y; - return dx * dx + dy * dy; -} - -function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint { - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - }; -} - -function evaluateCubicBezier({ - p0, - p1, - p2, - p3, - t, -}: { - p0: CanvasPoint; - p1: CanvasPoint; - p2: CanvasPoint; - p3: CanvasPoint; - t: number; -}): CanvasPoint { - const oneMinusT = 1 - t; - return { - x: - oneMinusT ** 3 * p0.x + - 3 * oneMinusT ** 2 * t * p1.x + - 3 * oneMinusT * t ** 2 * p2.x + - t ** 3 * p3.x, - y: - oneMinusT ** 3 * p0.y + - 3 * oneMinusT ** 2 * t * p1.y + - 3 * oneMinusT * t ** 2 * p2.y + - t ** 3 * p3.y, - }; -} - -function getCustomMaskSegmentIndices({ - points, - segmentIndex, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - closed: boolean; -}): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); - if (segmentIndex < 0 || segmentIndex >= segmentCount) { - return null; - } - - return { - startIndex: segmentIndex, - endIndex: (segmentIndex + 1) % points.length, - }; -} - -export function getCustomMaskSegmentCount({ - points, - closed, -}: { - points: CustomMaskPathPoint[]; - closed: boolean; -}): number { - if (points.length < 2) { - return 0; - } - return closed ? points.length : points.length - 1; -} - -export function getCustomMaskCanvasSegments({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const segmentCount = getCustomMaskSegmentCount({ points, closed }); - - return Array.from({ length: segmentCount }, (_, segmentIndex) => { - const start = geometry.anchors[segmentIndex]; - const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length]; - return { - index: segmentIndex, - startPointId: start.id, - endPointId: end.id, - start: start.anchor, - startOut: start.outHandle, - endIn: end.inHandle, - end: end.anchor, - pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`, - }; - }); -} - -export function findClosestPointOnCustomMaskSegment({ - points, - segmentIndex, - canvasPoint, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - canvasPoint: CanvasPoint; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, - }).find((candidate) => candidate.index === segmentIndex); - if (!segment) { - return null; - } - - const sampleCount = 24; - let bestT = 0; - let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); - - for (let step = 0; step <= sampleCount; step++) { - const t = step / sampleCount; - const point = evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t, - }); - const distanceSquared = getDistanceSquared(canvasPoint, point); - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestT = t; - } - } - - let searchStep = 1 / sampleCount; - for (let iteration = 0; iteration < 8; iteration++) { - const candidates = [bestT - searchStep, bestT, bestT + searchStep] - .map(clampUnit) - .map((t) => ({ - t, - point: evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t, - }), - })); - for (const candidate of candidates) { - const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); - if (distanceSquared < bestDistanceSquared) { - bestDistanceSquared = distanceSquared; - bestT = candidate.t; - } - } - searchStep /= 2; - } - - const clampedT = Math.min(0.999, Math.max(0.001, bestT)); - return { - t: clampedT, - point: evaluateCubicBezier({ - p0: segment.start, - p1: segment.startOut, - p2: segment.endIn, - p3: segment.end, - t: clampedT, - }), - }; -} - -export function insertPointIntoCustomMaskSegment({ - points, - segmentIndex, - pointId, - t, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - pointId: string; - t: number; - closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ - points, - segmentIndex, - closed, - }); - if (!indices) { - return points; - } - - const startPoint = points[indices.startIndex]; - const endPoint = points[indices.endIndex]; - const clampedT = Math.min(0.999, Math.max(0.001, t)); - const p0 = { x: startPoint.x, y: startPoint.y }; - const p1 = { - x: startPoint.x + startPoint.outX, - y: startPoint.y + startPoint.outY, - }; - const p2 = { - x: endPoint.x + endPoint.inX, - y: endPoint.y + endPoint.inY, - }; - const p3 = { x: endPoint.x, y: endPoint.y }; - const p01 = lerpPoint(p0, p1, clampedT); - const p12 = lerpPoint(p1, p2, clampedT); - const p23 = lerpPoint(p2, p3, clampedT); - const p012 = lerpPoint(p01, p12, clampedT); - const p123 = lerpPoint(p12, p23, clampedT); - const splitPoint = lerpPoint(p012, p123, clampedT); - - const nextPoints = [...points]; - nextPoints[indices.startIndex] = { - ...startPoint, - outX: p01.x - startPoint.x, - outY: p01.y - startPoint.y, - }; - nextPoints[indices.endIndex] = { - ...endPoint, - inX: p23.x - endPoint.x, - inY: p23.y - endPoint.y, - }; - nextPoints.splice(indices.endIndex, 0, { - id: pointId, - x: splitPoint.x, - y: splitPoint.y, - inX: p012.x - splitPoint.x, - inY: p012.y - splitPoint.y, - outX: p123.x - splitPoint.x, - outY: p123.y - splitPoint.y, - }); - return nextPoints; -} - -export function getCustomMaskLocalBounds({ - points, - bounds, -}: { - points: CustomMaskPathPoint[]; - bounds: ElementBounds; -}) { - if (points.length === 0) { - return null; - } - - const values = points.flatMap((point) => [ - { x: point.x * bounds.width, y: point.y * bounds.height }, - { - x: (point.x + point.inX) * bounds.width, - y: (point.y + point.inY) * bounds.height, - }, - { - x: (point.x + point.outX) * bounds.width, - y: (point.y + point.outY) * bounds.height, - }, - ]); - - const localBounds = getCanvasPointBounds({ - points: values, - }); - if (!localBounds) { - return null; - } - - return { - width: localBounds.width, - height: localBounds.height, - }; -} - -export function recenterCustomMaskPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - if (points.length === 0) { - return { centerX, centerY, points }; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - if (!geometry.bounds) { - return { centerX, centerY, points }; - } - - const nextCenterCanvas = { - x: geometry.bounds.centerX, - y: geometry.bounds.centerY, - }; - const nextCenterLocal = { - x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width, - y: - bounds.height === 0 - ? 0 - : (nextCenterCanvas.y - bounds.cy) / bounds.height, - }; - - const nextPoints = geometry.anchors.map((point) => { - const anchor = customMaskCanvasPointToLocal({ - point: point.anchor, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const inHandle = customMaskCanvasPointToLocal({ - point: point.inHandle, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const outHandle = customMaskCanvasPointToLocal({ - point: point.outHandle, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - - return { - id: point.id, - x: anchor.x, - y: anchor.y, - inX: inHandle.x - anchor.x, - inY: inHandle.y - anchor.y, - outX: outHandle.x - anchor.x, - outY: outHandle.y - anchor.y, - }; - }); - - return { - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - points: nextPoints, - }; -} - -export function buildCustomMaskPath2D({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): Path2D { - const path = new Path2D(); - if (points.length === 0) { - return path; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const anchors = geometry.anchors; - path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y); - - for (let index = 1; index < anchors.length; index++) { - const previous = anchors[index - 1]; - const current = anchors[index]; - path.bezierCurveTo( - previous.outHandle.x, - previous.outHandle.y, - current.inHandle.x, - current.inHandle.y, - current.anchor.x, - current.anchor.y, - ); - } - - if (closed && anchors.length > 1) { - const last = anchors[anchors.length - 1]; - const first = anchors[0]; - path.bezierCurveTo( - last.outHandle.x, - last.outHandle.y, - first.inHandle.x, - first.inHandle.y, - first.anchor.x, - first.anchor.y, - ); - path.closePath(); - } - - return path; -} - -export function buildCustomMaskSvgPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): string { - if (points.length === 0) { - return ""; - } - - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const anchors = geometry.anchors; - const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`]; - - for (let index = 1; index < anchors.length; index++) { - const previous = anchors[index - 1]; - const current = anchors[index]; - segments.push( - `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`, - ); - } - - if (closed && anchors.length > 1) { - const last = anchors[anchors.length - 1]; - const first = anchors[0]; - segments.push( - `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`, - ); - segments.push("Z"); - } - - return segments.join(" "); -} diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts deleted file mode 100644 index fe0f1a85..00000000 --- a/apps/web/src/lib/masks/definitions/box-like.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, - MIN_MASK_DIMENSION, -} from "@/lib/masks/dimensions"; -import { computeFeatherUpdate } from "../param-update"; -import type { - BaseMaskParams, - MaskDefaultContext, - MaskFeatures, - MaskInteractionDefinition, - MaskParamUpdateArgs, - RectangleMaskParams, -} from "@/lib/masks/types"; -import type { NumberParamDefinition, ParamDefinition } from "@/lib/params"; -import { - getBoxMaskHandlePositions, - getBoxMaskOverlays, -} from "@/lib/masks/handle-positions"; -import { snapMaskInteraction } from "@/lib/masks/snap"; - -const PERCENTAGE_DISPLAY: Pick< - NumberParamDefinition, - "displayMultiplier" | "step" -> = { - displayMultiplier: 100, - step: 1, -}; - -export const BOX_LIKE_MASK_PARAMS: ParamDefinition< - keyof RectangleMaskParams & 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: "width", - label: "Width", - type: "number", - default: 0.6, - min: 1, - ...PERCENTAGE_DISPLAY, - }, - { - key: "height", - label: "Height", - type: "number", - default: 0.6, - min: 1, - ...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, - }, - { - key: "strokeAlign", - label: "Stroke Align", - type: "select", - default: "center", - options: [ - { value: "inside", label: "Inside" }, - { value: "center", label: "Center" }, - { value: "outside", label: "Outside" }, - ], - }, -]; - -export function getDefaultBaseMaskParams(): BaseMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - }; -} - -export function getStrokeOffset({ - strokeAlign, - strokeWidth, -}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number { - if (strokeAlign === "inside") { - return -(strokeWidth / 2); - } - - if (strokeAlign === "outside") { - return strokeWidth / 2; - } - - return 0; -} - -export function getDefaultSquareMaskParams({ - elementSize, -}: MaskDefaultContext): RectangleMaskParams { - const absWidth = Math.abs(elementSize?.width ?? 0); - const absHeight = Math.abs(elementSize?.height ?? 0); - const shortSide = Math.min(absWidth, absHeight); - const squareSide = - shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0; - const width = - absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; - const height = - absHeight > 0 - ? squareSide / absHeight - : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO; - - return { - ...getDefaultBaseMaskParams(), - centerX: 0, - centerY: 0, - width, - height, - rotation: 0, - scale: 1, - }; -} - -export function getBoxLikeGeometry({ - params, - width, - height, -}: { - params: RectangleMaskParams; - width: number; - height: number; -}) { - return { - centerX: width / 2 + params.centerX * width, - centerY: height / 2 + params.centerY * height, - maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width, - maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height, - rotationRad: (params.rotation * Math.PI) / 180, - }; -} - -export function buildBoxMaskInteraction({ - sizeMode, - buildOverlayPath, - showBoundingBox = true, -}: { - sizeMode: MaskFeatures["sizeMode"]; - buildOverlayPath?: (args: { width: number; height: number }) => string; - showBoundingBox?: boolean; -}): MaskInteractionDefinition<RectangleMaskParams> { - return { - getInteraction({ params, bounds, displayScale, scaleX, scaleY }) { - return { - handles: getBoxMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - width: params.width, - height: params.height, - rotation: params.rotation, - feather: params.feather, - sizeMode, - bounds, - displayScale, - }), - overlays: getBoxMaskOverlays({ - params, - bounds, - pathData: buildOverlayPath?.({ - width: params.width * bounds.width * scaleX, - height: params.height * bounds.height * scaleY, - }), - showBoundingBox, - }), - }; - }, - snap(args) { - return snapMaskInteraction(args); - }, - }; -} - -export function computeBoxMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - bounds, -}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> { - if (handleId === "position") { - return { - centerX: startParams.centerX + deltaX / bounds.width, - centerY: startParams.centerY + deltaY / bounds.height, - }; - } - - if (handleId === "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") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.sin(angleRad), - directionY: Math.cos(angleRad), - }); - } - - const halfWidth = startParams.width * bounds.width; - const halfHeight = startParams.height * bounds.height; - - if (handleId === "right" || handleId === "left") { - const sign = handleId === "right" ? 1 : -1; - return { - width: Math.max( - MIN_MASK_DIMENSION, - startParams.width + (sign * deltaX * 2) / bounds.width, - ), - }; - } - - if (handleId === "bottom" || handleId === "top") { - const sign = handleId === "bottom" ? 1 : -1; - return { - height: Math.max( - MIN_MASK_DIMENSION, - startParams.height + (sign * deltaY * 2) / bounds.height, - ), - }; - } - - 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; - const distance = Math.sqrt( - (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, - ); - const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); - const scale = originalDistance > 0 ? distance / originalDistance : 1; - return { - width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale), - height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale), - }; - } - - if (handleId === "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; - return { - scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale), - }; - } - - return {}; -} - -export function rotatePoint({ - x, - y, - centerX, - centerY, - rotationRad, -}: { - x: number; - y: number; - centerX: number; - centerY: number; - rotationRad: number; -}) { - const dx = x - centerX; - const dy = y - centerY; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - - return { - x: centerX + dx * cos - dy * sin, - y: centerY + dx * sin + dy * cos, - }; -} diff --git a/apps/web/src/lib/masks/definitions/cinematic-bars.ts b/apps/web/src/lib/masks/definitions/cinematic-bars.ts deleted file mode 100644 index ef9c6e00..00000000 --- a/apps/web/src/lib/masks/definitions/cinematic-bars.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - MaskDefaultContext, - MaskDefinition, - RectangleMaskParams, -} from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getDefaultBaseMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function getDefaultCinematicBarsMaskParams({ - elementSize, -}: MaskDefaultContext): RectangleMaskParams { - const absWidth = Math.abs(elementSize?.width ?? 0); - const absHeight = Math.abs(elementSize?.height ?? 0); - const diagonal = - absWidth > 0 && absHeight > 0 - ? Math.sqrt(absWidth ** 2 + absHeight ** 2) - : 0; - const fullSpanWidth = - absWidth > 0 ? diagonal / absWidth : Math.SQRT2; - - return { - ...getDefaultBaseMaskParams(), - centerX: 0, - centerY: 0, - width: Math.max(fullSpanWidth, 1), - height: 0.6, - rotation: 0, - scale: 1, - }; -} - -function buildBandPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const corners = [ - { x: centerX - halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY + halfHeight }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(corners[0].x, corners[0].y); - for (const corner of corners.slice(1)) { - path.lineTo(corner.x, corner.y); - } - path.closePath(); - return path; -} - -export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "cinematic-bars", - name: "Cinematic Bars", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "height-only", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "height-only", - buildOverlayPath({ width, height }) { - return `M 0,0 H ${width} V ${height} H 0 Z`; - }, - }), - buildDefault(context) { - return { - type: "cinematic-bars", - params: getDefaultCinematicBarsMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const maskWidth = Math.max(params.width * width, width); - const maskHeight = Math.max(params.height, 0.01) * height; - const rotationRad = (params.rotation * Math.PI) / 180; - - return buildBandPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const rotationRad = (params.rotation * Math.PI) / 180; - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - - return buildBandPath({ - centerX, - centerY, - halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), - halfHeight: Math.max( - (Math.max(params.height, 0.01) * height) / 2 + offset, - 1, - ), - rotationRad, - }); - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/custom.ts b/apps/web/src/lib/masks/definitions/custom.ts deleted file mode 100644 index 0fc69929..00000000 --- a/apps/web/src/lib/masks/definitions/custom.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { generateUUID } from "@/utils/id"; -import type { ParamDefinition } from "@/lib/params"; -import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { - CustomMask, - CustomMaskParams, - MaskDefinition, - MaskHandlePosition, - MaskOverlay, - MaskParamUpdateArgs, -} from "@/lib/masks/types"; -import { - buildCustomMaskPath2D, - buildCustomMaskSvgPath, - customMaskCanvasPointToLocal, - findClosestPointOnCustomMaskSegment, - getCustomMaskCanvasSegments, - getCustomMaskCanvasGeometry, - getCustomMaskLocalBounds, - getCustomMaskSegmentCount, - insertPointIntoCustomMaskSegment, - parseCustomMaskHandleId, - recenterCustomMaskPath, - type CustomMaskPathPoint, -} from "@/lib/masks/custom-path"; -import { getBoxMaskHandlePositions } from "@/lib/masks/handle-positions"; -import { computeFeatherUpdate } from "@/lib/masks/param-update"; -import { - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "@/lib/masks/geometry"; -import { - snapPosition, - snapRotation, - snapScale, -} from "@/lib/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, - }, - }; -} diff --git a/apps/web/src/lib/masks/definitions/diamond.ts b/apps/web/src/lib/masks/definitions/diamond.ts deleted file mode 100644 index 56930678..00000000 --- a/apps/web/src/lib/masks/definitions/diamond.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildDiamondPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const points = [ - { x: centerX, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY }, - { x: centerX, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) { - path.lineTo(point.x, point.y); - } - path.closePath(); - return path; -} - -export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "diamond", - name: "Diamond", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; - }, - }), - buildDefault(context) { - return { - type: "diamond", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts deleted file mode 100644 index 2eb85f0c..00000000 --- a/apps/web/src/lib/masks/definitions/ellipse.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, -} from "./box-like"; - -export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "ellipse", - name: "Ellipse", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const rx = Math.max((width - 1) / 2, 0); - const ry = Math.max((height - 1) / 2, 0); - const cx = width / 2; - const cy = height / 2; - return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; - }, - }), - buildDefault(context) { - return { - type: "ellipse", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - maskWidth / 2, - maskHeight / 2, - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - Math.max(1, maskWidth / 2 + offset), - Math.max(1, maskHeight / 2 + offset), - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/heart.ts b/apps/web/src/lib/masks/definitions/heart.ts deleted file mode 100644 index 429c5d98..00000000 --- a/apps/web/src/lib/masks/definitions/heart.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildHeartPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const toPoint = ({ - localX, - localY, - }: { - localX: number; - localY: number; - }) => - rotatePoint({ - x: centerX + localX, - y: centerY + localY, - centerX, - centerY, - rotationRad, - }); - - const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 }); - const rightControl1 = toPoint({ - localX: halfWidth, - localY: -halfHeight * 1.225, - }); - const rightControl2 = toPoint({ - localX: halfWidth, - localY: -halfHeight * 0.125, - }); - const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 }); - const leftControl1 = toPoint({ - localX: -halfWidth, - localY: -halfHeight * 0.125, - }); - const leftControl2 = toPoint({ - localX: -halfWidth, - localY: -halfHeight * 1.225, - }); - - const path = new Path2D(); - path.moveTo(start.x, start.y); - path.bezierCurveTo( - rightControl1.x, - rightControl1.y, - rightControl2.x, - rightControl2.y, - bottom.x, - bottom.y, - ); - path.bezierCurveTo( - leftControl1.x, - leftControl1.y, - leftControl2.x, - leftControl2.y, - start.x, - start.y, - ); - path.closePath(); - return path; -} - -export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "heart", - name: "Heart", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const cx = width / 2; - const cy = height / 2; - const halfWidth = width / 2; - const halfHeight = height / 2; - return [ - `M ${cx},${cy - halfHeight * 0.475}`, - `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`, - `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`, - "Z", - ].join(" "); - }, - }), - buildDefault(context) { - return { - type: "heart", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts deleted file mode 100644 index fd00cab1..00000000 --- a/apps/web/src/lib/masks/definitions/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; -import { cinematicBarsMaskDefinition } from "./cinematic-bars"; -import { customMaskDefinition } from "./custom"; -import { diamondMaskDefinition } from "./diamond"; -import { ellipseMaskDefinition } from "./ellipse"; -import { heartMaskDefinition } from "./heart"; -import { rectangleMaskDefinition } from "./rectangle"; -import { splitMaskDefinition } from "./split"; -import { starMaskDefinition } from "./star"; -import { textMaskDefinition } from "./text"; -import { - MinusSignIcon, - PanelRightDashedIcon, - SquareIcon, - CircleIcon, - FavouriteIcon, - DiamondIcon, - StarsIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; - -function registerDefaultMask<TParams extends BaseMaskParams>({ - definition, - icon, -}: { - definition: MaskDefinition<TParams>; - icon: MaskIconProps; -}) { - if (masksRegistry.has(definition.type)) { - return; - } - - masksRegistry.registerMask({ definition, icon }); -} - -export function registerDefaultMasks(): void { - registerDefaultMask({ - definition: splitMaskDefinition, - icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, - }); - registerDefaultMask({ - definition: cinematicBarsMaskDefinition, - icon: { icon: MinusSignIcon }, - }); - registerDefaultMask({ - definition: rectangleMaskDefinition, - icon: { icon: SquareIcon }, - }); - registerDefaultMask({ - definition: ellipseMaskDefinition, - icon: { icon: CircleIcon }, - }); - registerDefaultMask({ - definition: heartMaskDefinition, - icon: { icon: FavouriteIcon }, - }); - registerDefaultMask({ - definition: diamondMaskDefinition, - icon: { icon: DiamondIcon }, - }); - registerDefaultMask({ - definition: starMaskDefinition, - icon: { icon: StarsIcon }, - }); - registerDefaultMask({ - definition: textMaskDefinition, - icon: { icon: TextFontIcon }, - }); - registerDefaultMask({ - definition: customMaskDefinition, - icon: { icon: SquareIcon }, - }); -} diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts deleted file mode 100644 index 832b8307..00000000 --- a/apps/web/src/lib/masks/definitions/rectangle.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -function buildRectanglePath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const corners = [ - { x: centerX - halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY - halfHeight }, - { x: centerX + halfWidth, y: centerY + halfHeight }, - { x: centerX - halfWidth, y: centerY + halfHeight }, - ].map((point) => - rotatePoint({ - ...point, - centerX, - centerY, - rotationRad, - }), - ); - - const path = new Path2D(); - path.moveTo(corners[0].x, corners[0].y); - for (const corner of corners.slice(1)) { - path.lineTo(corner.x, corner.y); - } - path.closePath(); - return path; -} - -export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "rectangle", - name: "Rectangle", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - }), - buildDefault(context) { - return { - type: "rectangle", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: Math.max(1, maskWidth / 2 + offset), - halfHeight: Math.max(1, maskHeight / 2 + offset), - rotationRad, - }); - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/split.ts b/apps/web/src/lib/masks/definitions/split.ts deleted file mode 100644 index c1a14d90..00000000 --- a/apps/web/src/lib/masks/definitions/split.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { computeFeatherUpdate } from "../param-update"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - SplitMaskParams, -} from "@/lib/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; -import { - getLineMaskHandlePositions, - getLineMaskOverlay, -} from "@/lib/masks/handle-positions"; -import { snapMaskInteraction } from "@/lib/masks/snap"; - -// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped -// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie -// exactly on the split line, which produces spurious midpoint vertices. -const NORMAL_SNAP_EPSILON = 1e-10; - -// Guards against collinear vertices from float noise at canvas edges. -const MIN_POLYGON_AREA_PX = 0.5; -const INTERSECTION_EPSILON = 1e-6; - -function polygonArea({ vertices }: { vertices: [number, number][] }): number { - let area = 0; - for (let i = 0; i < vertices.length; i++) { - const [x1, y1] = vertices[i]; - const [x2, y2] = vertices[(i + 1) % vertices.length]; - area += x1 * y2 - x2 * y1; - } - return Math.abs(area) * 0.5; -} - -function splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, -}: { - centerX: number; - centerY: number; - rotation: number; - width: number; - height: number; -}): { normalX: number; normalY: number; lineX: number; lineY: number } { - const angleRad = (rotation * Math.PI) / 180; - const normalX = - Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); - const normalY = - Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); - const lineX = width / 2 + centerX * width; - const lineY = height / 2 + centerY * height; - return { normalX, normalY, lineX, lineY }; -} - -function pointsEqual( - a: { x: number; y: number }, - b: { x: number; y: number }, -): boolean { - return ( - Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && - Math.abs(a.y - b.y) <= INTERSECTION_EPSILON - ); -} - -export function getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, -}: { - resolvedParams: unknown; - width: number; - height: number; -}): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - const intersections: { x: number; y: number }[] = []; - - for (const [x1, y1, x2, y2] of edges) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - - if (!hit || intersections.some((point) => pointsEqual(point, hit))) { - continue; - } - - intersections.push(hit); - } - - if (intersections.length !== 2) { - return null; - } - - return [intersections[0], intersections[1]]; -} - -function computeSplitMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, - canvasSize, -}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> { - if (handleId === "position") { - const rawX = startParams.centerX + deltaX / bounds.width; - const rawY = startParams.centerY + deltaY / bounds.height; - - const minX = -bounds.cx / bounds.width; - const maxX = (canvasSize.width - bounds.cx) / bounds.width; - const minY = -bounds.cy / bounds.height; - const maxY = (canvasSize.height - bounds.cy) / bounds.height; - - return { - centerX: Math.max(minX, Math.min(maxX, rawX)), - centerY: Math.max(minY, Math.min(maxY, rawY)), - }; - } - - if (handleId === "feather") { - const angleRad = (startParams.rotation * Math.PI) / 180; - return computeFeatherUpdate({ - startFeather: startParams.feather, - deltaX, - deltaY, - directionX: -Math.cos(angleRad), - directionY: -Math.sin(angleRad), - }); - } - - if (handleId === "rotation") { - const pivotX = bounds.cx + startParams.centerX * bounds.width; - const pivotY = bounds.cy + startParams.centerY * bounds.height; - 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, - }; - } - - return {}; -} - -export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { - type: "split", - name: "Split", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "none", - }, - interaction: { - getInteraction({ - params, - bounds, - displayScale, - scaleX: _scaleX, - scaleY: _scaleY, - }) { - return { - handles: getLineMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - feather: params.feather, - bounds, - displayScale, - }), - overlays: [ - getLineMaskOverlay({ - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - bounds, - }), - ], - }; - }, - snap(args) { - return snapMaskInteraction(args); - }, - }, - buildDefault() { - return { - type: "split", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - centerX: 0, - centerY: 0, - rotation: 0, - }, - }; - }, - computeParamUpdate: computeSplitMaskParamUpdate, - params: [ - { - key: "centerX", - label: "X", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "centerY", - label: "Y", - type: "number", - default: 0, - min: -100, - max: 100, - step: 1, - displayMultiplier: 100, - }, - { - key: "rotation", - label: "Rotation", - type: "number", - default: 0, - min: 0, - max: 360, - step: 1, - }, - ], - renderer: { - renderMaskHandlesFeather: true, - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. - const featherHalf = feather / 2; - const gradient = ctx.createLinearGradient( - lineX - normalX * featherHalf, - lineY - normalY * featherHalf, - lineX + normalX * featherHalf, - lineY + normalY * featherHalf, - ); - gradient.addColorStop(0, "rgba(255,255,255,0)"); - gradient.addColorStop(1, "white"); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - }, - - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); - - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; - - const isInsideHalfPlane = (x: number, y: number) => - halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; - - const vertices: [number, number][] = []; - for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane(x1, y1); - const isVertex2Inside = isInsideHalfPlane(x2, y2); - - if (isVertex1Inside && isVertex2Inside) { - vertices.push([x2, y2]); - } else if (isVertex1Inside && !isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) vertices.push([hit.x, hit.y]); - } else if (!isVertex1Inside && isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) { - vertices.push([hit.x, hit.y]); - vertices.push([x2, y2]); - } - } - } - - if ( - vertices.length < 3 || - polygonArea({ vertices }) < MIN_POLYGON_AREA_PX - ) { - return new Path2D(); - } - - const path = new Path2D(); - path.moveTo(vertices[0][0], vertices[0][1]); - for (let i = 1; i < vertices.length; i++) { - path.lineTo(vertices[i][0], vertices[i][1]); - } - path.closePath(); - return path; - }, - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); - - if (!segment) { - return path; - } - - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/star.ts b/apps/web/src/lib/masks/definitions/star.ts deleted file mode 100644 index d311ab80..00000000 --- a/apps/web/src/lib/masks/definitions/star.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, - rotatePoint, -} from "./box-like"; - -const STAR_INNER_RADIUS_RATIO = 0.45; -const STAR_VERTEX_COUNT = 10; - -function buildStarPath({ - centerX, - centerY, - halfWidth, - halfHeight, - rotationRad, -}: { - centerX: number; - centerY: number; - halfWidth: number; - halfHeight: number; - rotationRad: number; -}): Path2D { - const path = new Path2D(); - - for (let index = 0; index < STAR_VERTEX_COUNT; index++) { - const isOuterVertex = index % 2 === 0; - const radiusX = isOuterVertex - ? halfWidth - : halfWidth * STAR_INNER_RADIUS_RATIO; - const radiusY = isOuterVertex - ? halfHeight - : halfHeight * STAR_INNER_RADIUS_RATIO; - const angle = (index * Math.PI) / 5 - Math.PI / 2; - const point = rotatePoint({ - x: centerX + radiusX * Math.cos(angle), - y: centerY + radiusY * Math.sin(angle), - centerX, - centerY, - rotationRad, - }); - - if (index === 0) { - path.moveTo(point.x, point.y); - } else { - path.lineTo(point.x, point.y); - } - } - - path.closePath(); - return path; -} - -function buildOverlayStarPath({ - width, - height, -}: { - width: number; - height: number; -}): string { - const centerX = width / 2; - const centerY = height / 2; - const halfWidth = width / 2; - const halfHeight = height / 2; - const segments: string[] = []; - - for (let index = 0; index < STAR_VERTEX_COUNT; index++) { - const isOuterVertex = index % 2 === 0; - const radiusX = isOuterVertex - ? halfWidth - : halfWidth * STAR_INNER_RADIUS_RATIO; - const radiusY = isOuterVertex - ? halfHeight - : halfHeight * STAR_INNER_RADIUS_RATIO; - const angle = (index * Math.PI) / 5 - Math.PI / 2; - const x = centerX + radiusX * Math.cos(angle); - const y = centerY + radiusY * Math.sin(angle); - segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`); - } - - return `${segments.join(" ")} Z`; -} - -export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "star", - name: "Star", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - return buildOverlayStarPath({ width, height }); - }, - }), - buildDefault(context) { - return { - type: "star", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildStarPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildStarPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); - }, - }, -}; diff --git a/apps/web/src/lib/masks/definitions/text.ts b/apps/web/src/lib/masks/definitions/text.ts deleted file mode 100644 index b774ed28..00000000 --- a/apps/web/src/lib/masks/definitions/text.ts +++ /dev/null @@ -1,438 +0,0 @@ -import type { ParamDefinition } from "@/lib/params"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - TextMask, - TextMaskParams, -} from "@/lib/masks/types"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography"; -import { - drawMeasuredTextLayout, - measureTextLayout, - strokeMeasuredTextLayout, -} from "@/lib/text/primitives"; -import { getTextMeasurementContext } from "@/lib/text/measure-element"; -import { getTextVisualRect } from "@/lib/text/layout"; -import { - getBoxMaskHandlePositions, - getBoxMaskRectOverlay, -} from "@/lib/masks/handle-positions"; -import { computeFeatherUpdate } from "@/lib/masks/param-update"; -import { - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "@/lib/masks/geometry"; -import { - snapPosition, - snapRotation, - snapScale, - type ScaleEdgePreference, -} from "@/lib/preview/preview-snap"; - -const PERCENTAGE_DISPLAY = { - displayMultiplier: 100, - step: 1, -} as const; - -const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; - -const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & 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: "fontSize", - label: "Size", - type: "number", - default: DEFAULTS.text.element.fontSize, - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - step: 1, - }, - { - 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 measureTextMask({ - params, - height, -}: { - params: TextMaskParams; - height: number; -}) { - const layout = measureTextLayout({ - text: { - content: params.content, - fontSize: params.fontSize, - fontFamily: params.fontFamily, - fontWeight: params.fontWeight, - fontStyle: params.fontStyle, - textAlign: TEXT_MASK_ALIGNMENT, - textDecoration: params.textDecoration, - letterSpacing: params.letterSpacing, - lineHeight: params.lineHeight, - }, - canvasHeight: height, - ctx: getTextMeasurementContext(), - }); - const visualRect = getTextVisualRect({ - textAlign: layout.textAlign, - block: layout.block, - background: { enabled: false, color: "transparent" }, - fontSizeRatio: layout.fontSizeRatio, - }); - - return { - layout, - intrinsicWidth: Math.max(1, visualRect.width), - intrinsicHeight: Math.max(1, visualRect.height), - }; -} - -function getScalePreferredEdges({ - handleId, -}: { - handleId: string; -}): ScaleEdgePreference | undefined { - if (handleId !== "scale") { - return undefined; - } - - return { - right: true, - bottom: true, - }; -} - -function computeTextMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - startCanvasX, - startCanvasY, - bounds, -}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> { - 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), - }; - } - - return {}; -} - -export const textMaskDefinition: MaskDefinition<TextMaskParams> = { - type: "text", - name: "Text", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "uniform", - }, - params: TEXT_MASK_PARAMS, - interaction: { - getInteraction({ - params, - bounds, - displayScale, - scaleX: _scaleX, - scaleY: _scaleY, - }) { - const { intrinsicWidth, intrinsicHeight } = measureTextMask({ - params, - height: bounds.height, - }); - const width = (intrinsicWidth * params.scale) / bounds.width; - const height = (intrinsicHeight * params.scale) / bounds.height; - return { - handles: getBoxMaskHandlePositions({ - centerX: params.centerX, - centerY: params.centerY, - width, - height, - rotation: params.rotation, - feather: params.feather, - sizeMode: "uniform", - bounds, - displayScale, - }), - overlays: [ - getBoxMaskRectOverlay({ - centerX: params.centerX, - centerY: params.centerY, - width, - height, - rotation: params.rotation, - bounds, - }), - ], - }; - }, - snap({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, - }) { - const { intrinsicWidth, intrinsicHeight } = measureTextMask({ - params: startParams, - height: bounds.height, - }); - 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: intrinsicWidth * proposedParams.scale, - height: intrinsicHeight * 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: intrinsicWidth, - baseHeight: intrinsicHeight, - rotation: proposedParams.rotation, - canvasSize: bounds, - snapThreshold, - preferredEdges: getScalePreferredEdges({ handleId }), - }); - - return { - params: { - ...proposedParams, - scale: Math.max(0.01, snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - return { - params: proposedParams, - activeLines: [], - }; - }, - }, - buildDefault(): Omit<TextMask, "id"> { - return { - type: "text", - params: { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - strokeAlign: "center", - content: "Mask", - fontSize: DEFAULTS.text.element.fontSize, - fontFamily: DEFAULTS.text.element.fontFamily, - fontWeight: DEFAULTS.text.element.fontWeight, - fontStyle: DEFAULTS.text.element.fontStyle, - textDecoration: DEFAULTS.text.element.textDecoration, - letterSpacing: DEFAULTS.text.letterSpacing, - lineHeight: DEFAULTS.text.lineHeight, - centerX: 0, - centerY: 0, - rotation: 0, - scale: 1, - }, - }; - }, - computeParamUpdate: computeTextMaskParamUpdate, - isActive(params) { - return params.content.trim().length > 0; - }, - renderer: { - renderMask({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - ctx.restore(); - }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - } - - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - } - - ctx.restore(); - }, - }, -}; diff --git a/apps/web/src/lib/masks/dimensions.ts b/apps/web/src/lib/masks/dimensions.ts deleted file mode 100644 index dcfcdc0c..00000000 --- a/apps/web/src/lib/masks/dimensions.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6; -export const MIN_MASK_DIMENSION = 0.01; diff --git a/apps/web/src/lib/masks/feather.ts b/apps/web/src/lib/masks/feather.ts deleted file mode 100644 index 83e80a0c..00000000 --- a/apps/web/src/lib/masks/feather.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const MAX_FEATHER = 1000; -export const FEATHER_HANDLE_SCALE = 0.11; diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts deleted file mode 100644 index a5b05030..00000000 --- a/apps/web/src/lib/masks/geometry.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions"; -import type { RectangleMaskParams } from "@/lib/masks/types"; - -type CenterMaskParams = { - centerX: number; - centerY: number; -}; - -type SnapGeometryParams = CenterMaskParams & { - rotation?: number; - width?: number; - height?: number; - scale?: number; -}; - -export function hasCenterParams( - params: Partial<CenterMaskParams>, -): params is CenterMaskParams { - return ( - typeof params.centerX === "number" && typeof params.centerY === "number" - ); -} - -export function isRectangleMaskParams( - params: SnapGeometryParams, -): params is RectangleMaskParams { - return ( - hasCenterParams(params) && - typeof params.width === "number" && - typeof params.height === "number" && - typeof params.rotation === "number" && - typeof params.scale === "number" - ); -} - -export function getMaskLocalCenter({ - params, - bounds, -}: { - params: CenterMaskParams; - bounds: ElementBounds; -}): { x: number; y: number } | null { - if (!hasCenterParams(params)) return null; - - return { - x: params.centerX * bounds.width, - y: params.centerY * bounds.height, - }; -} - -export function setMaskLocalCenter({ - center, - bounds, -}: { - center: { x: number; y: number }; - bounds: ElementBounds; -}): { centerX: number; centerY: number } { - return { - centerX: bounds.width === 0 ? 0 : center.x / bounds.width, - centerY: bounds.height === 0 ? 0 : center.y / bounds.height, - }; -} - -export function getMaskSnapGeometry({ - params, - bounds, -}: { - params: SnapGeometryParams; - bounds: ElementBounds; -}): { - position: { x: number; y: number }; - size: { width: number; height: number }; - rotation: number; -} | null { - const position = getMaskLocalCenter({ params, bounds }); - if (!position) return null; - - if (isRectangleMaskParams(params)) { - return { - position, - size: { - width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width, - height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height, - }, - rotation: params.rotation, - }; - } - - return { - position, - size: { width: 0, height: 0 }, - rotation: typeof params.rotation === "number" ? params.rotation : 0, - }; -} - -export function toGlobalMaskSnapLines({ - lines, - bounds, - canvasSize, -}: { - lines: SnapLine[]; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -}): SnapLine[] { - const centerX = bounds.cx - canvasSize.width / 2; - const centerY = bounds.cy - canvasSize.height / 2; - - return lines.map((line) => - line.type === "vertical" - ? { - type: "vertical" as const, - position: centerX + line.position, - } - : { - type: "horizontal" as const, - position: centerY + line.position, - }, - ); -} diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts deleted file mode 100644 index d32b8424..00000000 --- a/apps/web/src/lib/masks/handle-positions.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { - MaskFeatures, - MaskHandlePosition, - MaskLineOverlay, - MaskOverlay, - MaskRectOverlay, - RectangleMaskParams, - MaskShapeOverlay, -} from "@/lib/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; -} diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts deleted file mode 100644 index 43871b44..00000000 --- a/apps/web/src/lib/masks/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/lib/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts deleted file mode 100644 index 4f8693eb..00000000 --- a/apps/web/src/lib/masks/param-update.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather"; - -export function computeFeatherUpdate({ - startFeather, - deltaX, - deltaY, - directionX, - directionY, -}: { - startFeather: number; - deltaX: number; - deltaY: number; - directionX: number; - directionY: number; -}): { feather: number } { - const projection = deltaX * directionX + deltaY * directionY; - return { - feather: Math.max( - 0, - Math.min( - MAX_FEATHER, - Math.round(startFeather + projection / FEATHER_HANDLE_SCALE), - ), - ), - }; -} diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts deleted file mode 100644 index ae8ddd20..00000000 --- a/apps/web/src/lib/masks/registry.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { MAX_FEATHER } from "@/lib/masks/feather"; -import type { ParamDefinition } from "@/lib/params"; -import type { - BaseMaskParams, - MaskDefaultContext, - MaskDefinition, - MaskInteractionResult, - MaskSnapArgs, - MaskSnapResult, - MaskType, -} from "@/lib/masks/types"; -import type { HugeiconsIconProps } from "@hugeicons/react"; -import { DefinitionRegistry } from "@/lib/registry"; - -export type MaskIconProps = { - icon: HugeiconsIconProps["icon"]; - strokeWidth?: number; -}; - -const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< - keyof BaseMaskParams & string ->[] = [ - { - key: "feather", - label: "Feather", - type: "number", - default: 0, - min: 0, - max: MAX_FEATHER, - step: 1, - unit: "percent", - }, - { - key: "strokeWidth", - label: "Stroke width", - type: "number", - default: 0, - min: 0, - max: 100, - step: 1, - }, - { - key: "strokeColor", - label: "Stroke color", - type: "color", - default: "#ffffff", - }, -]; - -export interface RegisteredMaskDefinition { - type: MaskType; - name: string; - features: MaskDefinition<BaseMaskParams>["features"]; - params: ParamDefinition<string>[]; - renderer: MaskDefinition<BaseMaskParams>["renderer"]; - interaction: { - getInteraction(args: { - params: BaseMaskParams; - bounds: Parameters< - MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"] - >[0]["bounds"]; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>; - }; - isActive?: (params: BaseMaskParams) => boolean; - buildDefault( - context: MaskDefaultContext, - ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>; - computeParamUpdate( - args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], - ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; - icon: MaskIconProps; -} - -export class MasksRegistry extends DefinitionRegistry< - MaskType, - RegisteredMaskDefinition -> { - constructor() { - super("mask"); - } - - registerMask<TParams extends BaseMaskParams>({ - definition, - icon, - }: { - definition: MaskDefinition<TParams>; - icon: MaskIconProps; - }): void { - const withBaseParams: RegisteredMaskDefinition = { - type: definition.type, - name: definition.name, - features: definition.features, - params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], - renderer: definition.renderer, - interaction: { - getInteraction(args) { - return definition.interaction.getInteraction(args as never); - }, - snap: definition.interaction.snap - ? (args) => definition.interaction.snap?.(args as never) as never - : undefined, - }, - isActive: definition.isActive - ? (params) => definition.isActive?.(params as TParams) ?? true - : undefined, - buildDefault(context) { - return definition.buildDefault(context); - }, - computeParamUpdate(args) { - return definition.computeParamUpdate(args as never); - }, - icon, - }; - this.register(definition.type, withBaseParams); - } -} - -export const masksRegistry = new MasksRegistry(); diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts deleted file mode 100644 index 7396dd82..00000000 --- a/apps/web/src/lib/masks/snap.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions"; -import { - snapPosition, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; -import { - isRectangleMaskParams, - getMaskSnapGeometry, - setMaskLocalCenter, - toGlobalMaskSnapLines, -} from "./geometry"; - -type SharedMaskParams = SplitMaskParams | RectangleMaskParams; - -type MaskSnapResult<TParams extends SharedMaskParams> = { - params: TParams; - activeLines: SnapLine[]; -}; - -const CORNER_SIZE_HANDLES = new Set([ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]); - -function getClampedRatio({ - next, - base, -}: { - next: number; - base: number; -}): number { - return ( - Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION) - ); -} - -function getPreferredEdges({ - handleId, -}: { - handleId: string; -}): ScaleEdgePreference | undefined { - 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", - } satisfies ScaleEdgePreference; - - return Object.values(preferredEdges).some(Boolean) - ? preferredEdges - : undefined; -} - -function snapMaskPosition({ - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - proposedParams: SharedMaskParams; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { - const geometry = getMaskSnapGeometry({ - params: proposedParams, - bounds, - }); - if (!geometry) { - return { params: proposedParams, activeLines: [] }; - } - - const { snappedPosition, activeLines } = snapPosition({ - proposedPosition: geometry.position, - canvasSize: bounds, - elementSize: geometry.size, - rotation: geometry.rotation, - snapThreshold, - }); - - return { - params: { - ...proposedParams, - ...setMaskLocalCenter({ - center: snappedPosition, - bounds, - }), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; -} - -function snapMaskRotation({ - proposedParams, -}: { - proposedParams: SharedMaskParams; -}): MaskSnapResult<SharedMaskParams> { - if (typeof proposedParams.rotation !== "number") { - return { params: proposedParams, activeLines: [] }; - } - - const { snappedRotation } = snapRotation({ - proposedRotation: proposedParams.rotation, - }); - - return { - params: { - ...proposedParams, - rotation: snappedRotation, - }, - activeLines: [], - }; -} - -function snapBoxMaskSize({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - handleId: string; - startParams: SharedMaskParams; - proposedParams: SharedMaskParams; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { - if ( - !isRectangleMaskParams(startParams) || - !isRectangleMaskParams(proposedParams) - ) { - return { params: proposedParams, activeLines: [] }; - } - - const geometry = getMaskSnapGeometry({ - params: proposedParams, - bounds, - }); - if (!geometry) { - return { params: proposedParams, activeLines: [] }; - } - - const localCanvasSize = bounds; - const baseWidth = - Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width; - const baseHeight = - Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; - const preferredEdges = getPreferredEdges({ handleId }); - - if (handleId === "right" || handleId === "left") { - const proposedScaleX = getClampedRatio({ - next: proposedParams.width, - base: startParams.width, - }); - const { x } = snapScaleAxes({ - proposedScaleX, - proposedScaleY: 1, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: x.activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId === "top" || handleId === "bottom") { - const proposedScaleY = getClampedRatio({ - next: proposedParams.height, - base: startParams.height, - }); - const { y } = snapScaleAxes({ - proposedScaleX: 1, - proposedScaleY, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - height: Math.max( - MIN_MASK_DIMENSION, - startParams.height * y.snappedScale, - ), - }, - activeLines: toGlobalMaskSnapLines({ - lines: y.activeLines, - bounds, - canvasSize, - }), - }; - } - - if (handleId === "scale") { - const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); - const proposedScale = getClampedRatio({ - next: proposedParams.scale, - base: startParams.scale, - }); - const { snappedScale, activeLines } = snapScale({ - proposedScale, - position: geometry.position, - baseWidth: baseWidth * baseScale, - baseHeight: baseHeight * baseScale, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - if (CORNER_SIZE_HANDLES.has(handleId)) { - const proposedScale = getClampedRatio({ - next: proposedParams.width, - base: startParams.width, - }); - const { snappedScale, activeLines } = snapScale({ - proposedScale, - position: geometry.position, - baseWidth, - baseHeight, - rotation: proposedParams.rotation, - canvasSize: localCanvasSize, - snapThreshold, - preferredEdges, - }); - - return { - params: { - ...proposedParams, - width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale), - height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale), - }, - activeLines: toGlobalMaskSnapLines({ - lines: activeLines, - bounds, - canvasSize, - }), - }; - } - - return { params: proposedParams, activeLines: [] }; -} - -export function snapMaskInteraction<TParams extends SharedMaskParams>({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, -}: { - handleId: string; - startParams: TParams; - proposedParams: TParams; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -}): MaskSnapResult<TParams> { - if (handleId === "position") { - return snapMaskPosition({ - proposedParams, - bounds, - canvasSize, - snapThreshold, - }) as MaskSnapResult<TParams>; - } - - if (handleId === "rotation") { - return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>; - } - - return snapBoxMaskSize({ - handleId, - startParams, - proposedParams, - bounds, - canvasSize, - snapThreshold, - }) as MaskSnapResult<TParams>; -} diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts deleted file mode 100644 index 5a0d126d..00000000 --- a/apps/web/src/lib/masks/types.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import type { ParamDefinition } from "@/lib/params"; -import type { CustomMaskPathPoint } from "@/lib/masks/custom-path"; -import type { - TextDecoration, - TextFontStyle, - TextFontWeight, -} from "@/lib/text/primitives"; - -export type MaskType = - | "split" - | "cinematic-bars" - | "rectangle" - | "ellipse" - | "heart" - | "diamond" - | "star" - | "text" - | "custom"; - -export interface BaseMaskParams { - feather: number; - inverted: boolean; - strokeColor: string; - strokeWidth: number; - strokeAlign: "inside" | "center" | "outside"; -} - -export interface SplitMaskParams extends BaseMaskParams { - centerX: number; - centerY: number; - rotation: number; -} - -export interface RectangleMaskParams extends BaseMaskParams { - centerX: number; - centerY: number; - width: number; - height: number; - rotation: number; - scale: number; -} - -export interface TextMaskParams extends BaseMaskParams { - content: string; - fontSize: number; - fontFamily: string; - fontWeight: TextFontWeight; - fontStyle: TextFontStyle; - textDecoration: TextDecoration; - letterSpacing: number; - lineHeight: number; - centerX: number; - centerY: number; - rotation: number; - scale: number; -} - -export interface CustomMaskParams extends BaseMaskParams { - path: CustomMaskPathPoint[]; - closed: boolean; - centerX: number; - centerY: number; - rotation: number; - scale: number; -} - -export interface SplitMask { - id: string; - type: "split"; - params: SplitMaskParams; -} - -export interface CinematicBarsMask { - id: string; - type: "cinematic-bars"; - params: RectangleMaskParams; -} - -export interface RectangleMask { - id: string; - type: "rectangle"; - params: RectangleMaskParams; -} - -export interface EllipseMask { - id: string; - type: "ellipse"; - params: RectangleMaskParams; -} - -export interface HeartMask { - id: string; - type: "heart"; - params: RectangleMaskParams; -} - -export interface DiamondMask { - id: string; - type: "diamond"; - params: RectangleMaskParams; -} - -export interface StarMask { - id: string; - type: "star"; - params: RectangleMaskParams; -} - -export interface TextMask { - id: string; - type: "text"; - params: TextMaskParams; -} - -export interface CustomMask { - id: string; - type: "custom"; - params: CustomMaskParams; -} - -export type Mask = - | SplitMask - | CinematicBarsMask - | RectangleMask - | EllipseMask - | HeartMask - | DiamondMask - | StarMask - | TextMask - | CustomMask; - -export interface MaskRenderer { - buildPath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; - renderMaskHandlesFeather?: boolean; - renderStroke?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - }) => void; -} - -export interface MaskFeatures { - hasPosition: boolean; - hasRotation: boolean; - sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; -} - -export type MaskHandleIcon = "rotate" | "feather"; - -export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent"; - -export interface MaskHandlePosition { - id: string; - x: number; - y: number; - cursor: string; - kind: MaskHandleKind; - isSelected?: boolean; - edgeAxis?: "horizontal" | "vertical"; - rotation?: number; - icon?: MaskHandleIcon; -} - -export interface MaskLineOverlay { - id: string; - type: "line"; - start: { x: number; y: number }; - end: { x: number; y: number }; - cursor?: string; - handleId?: string; -} - -export interface MaskRectOverlay { - id: string; - type: "rect"; - center: { x: number; y: number }; - width: number; - height: number; - rotation: number; - dashed?: boolean; - cursor?: string; - handleId?: string; -} - -export interface MaskShapeOverlay { - id: string; - type: "shape"; - center: { x: number; y: number }; - width: number; - height: number; - rotation: number; - pathData: string; - cursor?: string; - handleId?: string; -} - -export interface MaskCanvasPathOverlay { - id: string; - type: "canvas-path"; - pathData: string; - coordinateSpace?: "canvas" | "overlay"; - cursor?: string; - handleId?: string; - strokeWidth?: number; - strokeOpacity?: number; -} - -export type MaskOverlay = - | MaskLineOverlay - | MaskRectOverlay - | MaskShapeOverlay - | MaskCanvasPathOverlay; - -export interface MaskDefaultContext { - elementSize?: { width: number; height: number }; -} - -export interface MaskParamUpdateArgs< - TParams extends BaseMaskParams = BaseMaskParams, -> { - handleId: string; - startParams: TParams; - deltaX: number; - deltaY: number; - startCanvasX: number; - startCanvasY: number; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; -} - -export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> { - handleId: string; - startParams: TParams; - proposedParams: TParams; - bounds: ElementBounds; - canvasSize: { width: number; height: number }; - snapThreshold: { x: number; y: number }; -} - -export interface MaskSnapResult< - TParams extends BaseMaskParams = BaseMaskParams, -> { - params: TParams; - activeLines: SnapLine[]; -} - -export interface MaskInteractionResult { - handles: MaskHandlePosition[]; - overlays: MaskOverlay[]; -} - -export interface MaskInteractionDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - getInteraction(args: { - params: TParams; - bounds: ElementBounds; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; -} - -export interface MaskDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - type: MaskType; - name: string; - features: MaskFeatures; - params: ParamDefinition<keyof TParams & string>[]; - renderer: MaskRenderer; - interaction: MaskInteractionDefinition<TParams>; - /** When defined and returning false, the mask is not applied and the element renders fully visible. */ - isActive?: (params: TParams) => boolean; - buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; - computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; -} diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts deleted file mode 100644 index b1ac2c39..00000000 --- a/apps/web/src/lib/masks/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -const LINE_PARALLEL_EPSILON = 1e-10;
-
-export function lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
-}: {
- lineX: number;
- lineY: number;
- normalX: number;
- normalY: number;
- x1: number;
- y1: number;
- x2: number;
- y2: number;
-}): { x: number; y: number } | null {
- const distance1 = halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x: x1,
- y: y1,
- });
- const distance2 = halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x: x2,
- y: y2,
- });
- const denom = distance1 - distance2;
- if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
- const t = distance1 / denom;
- if (t < 0 || t > 1) return null;
- return {
- x: x1 + (x2 - x1) * t,
- y: y1 + (y2 - y1) * t,
- };
-}
-
-export function halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x,
- y,
-}: {
- lineX: number;
- lineY: number;
- normalX: number;
- normalY: number;
- x: number;
- y: number;
-}): number {
- return (x - lineX) * normalX + (y - lineY) * normalY;
-}
|
