summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/masks/definitions
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/masks/definitions')
-rw-r--r--apps/web/src/lib/masks/definitions/box-like.ts313
-rw-r--r--apps/web/src/lib/masks/definitions/cinematic-bars.ts135
-rw-r--r--apps/web/src/lib/masks/definitions/custom.ts652
-rw-r--r--apps/web/src/lib/masks/definitions/diamond.ts100
-rw-r--r--apps/web/src/lib/masks/definitions/ellipse.ts75
-rw-r--r--apps/web/src/lib/masks/definitions/heart.ts142
-rw-r--r--apps/web/src/lib/masks/definitions/index.ts74
-rw-r--r--apps/web/src/lib/masks/definitions/rectangle.ts97
-rw-r--r--apps/web/src/lib/masks/definitions/split.ts382
-rw-r--r--apps/web/src/lib/masks/definitions/star.ts140
-rw-r--r--apps/web/src/lib/masks/definitions/text.ts438
11 files changed, 0 insertions, 2548 deletions
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();
- },
- },
-};