From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- apps/web/src/lib/masks/definitions/box-like.ts | 583 ++++++++-------- .../src/lib/masks/definitions/cinematic-bars.ts | 11 +- apps/web/src/lib/masks/definitions/custom.ts | 652 ++++++++++++++++++ apps/web/src/lib/masks/definitions/diamond.ts | 11 +- apps/web/src/lib/masks/definitions/ellipse.ts | 147 +++-- apps/web/src/lib/masks/definitions/heart.ts | 41 +- apps/web/src/lib/masks/definitions/index.ts | 11 + apps/web/src/lib/masks/definitions/rectangle.ts | 191 +++--- apps/web/src/lib/masks/definitions/split.ts | 729 +++++++++++---------- apps/web/src/lib/masks/definitions/star.ts | 11 +- apps/web/src/lib/masks/definitions/text.ts | 438 +++++++++++++ 11 files changed, 2011 insertions(+), 814 deletions(-) create mode 100644 apps/web/src/lib/masks/definitions/custom.ts create mode 100644 apps/web/src/lib/masks/definitions/text.ts (limited to 'apps/web/src/lib/masks/definitions') diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts index cf42a9f0..fe0f1a85 100644 --- a/apps/web/src/lib/masks/definitions/box-like.ts +++ b/apps/web/src/lib/masks/definitions/box-like.ts @@ -1,270 +1,313 @@ -import { - DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, - MIN_MASK_DIMENSION, -} from "@/lib/masks/dimensions"; -import { computeFeatherUpdate } from "../param-update"; -import type { - BaseMaskParams, - MaskDefaultContext, - MaskParamUpdateArgs, - RectangleMaskParams, -} from "@/lib/masks/types"; -import type { - NumberParamDefinition, - ParamDefinition, - ParamValues, -} from "@/lib/params"; - -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): 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 computeBoxMaskParamUpdate({ - handleId, - startParams, - deltaX, - deltaY, - bounds, -}: MaskParamUpdateArgs): ParamValues { - 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, - }; -} +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): 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 { + 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): Partial { + 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 index 1eb65e71..ef9c6e00 100644 --- a/apps/web/src/lib/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/lib/masks/definitions/cinematic-bars.ts @@ -5,6 +5,7 @@ import type { } from "@/lib/masks/types"; import { BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, computeBoxMaskParamUpdate, getDefaultBaseMaskParams, getStrokeOffset, @@ -73,16 +74,18 @@ function buildBandPath({ export const cinematicBarsMaskDefinition: MaskDefinition = { type: "cinematic-bars", name: "Cinematic Bars", - overlayShape: "box", - buildOverlayPath({ width, height }) { - return `M 0,0 H ${width} V ${height} H 0 Z`; - }, 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", diff --git a/apps/web/src/lib/masks/definitions/custom.ts b/apps/web/src/lib/masks/definitions/custom.ts new file mode 100644 index 00000000..0fc69929 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/custom.ts @@ -0,0 +1,652 @@ +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[] = [ + { + 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): Partial { + 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 = { + 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 { + 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 index 8af3144f..56930678 100644 --- a/apps/web/src/lib/masks/definitions/diamond.ts +++ b/apps/web/src/lib/masks/definitions/diamond.ts @@ -1,6 +1,7 @@ import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; import { BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, computeBoxMaskParamUpdate, getBoxLikeGeometry, getDefaultSquareMaskParams, @@ -47,16 +48,18 @@ function buildDiamondPath({ export const diamondMaskDefinition: MaskDefinition = { type: "diamond", name: "Diamond", - overlayShape: "box", - buildOverlayPath({ width, height }) { - return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; - }, 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", diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts index 1fd0301c..2eb85f0c 100644 --- a/apps/web/src/lib/masks/definitions/ellipse.ts +++ b/apps/web/src/lib/masks/definitions/ellipse.ts @@ -1,72 +1,75 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, -} from "./box-like"; - -export const ellipseMaskDefinition: MaskDefinition = { - type: "ellipse", - name: "Ellipse", - overlayShape: "box", - 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`; - }, - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - 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; - }, - }, -}; +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 = { + 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 index 2c53367a..429c5d98 100644 --- a/apps/web/src/lib/masks/definitions/heart.ts +++ b/apps/web/src/lib/masks/definitions/heart.ts @@ -1,6 +1,7 @@ import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; import { BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, computeBoxMaskParamUpdate, getBoxLikeGeometry, getDefaultSquareMaskParams, @@ -36,23 +37,23 @@ function buildHeartPath({ rotationRad, }); - const start = toPoint({ localX: 0, localY: -halfHeight * 0.2 }); + const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 }); const rightControl1 = toPoint({ localX: halfWidth, - localY: -halfHeight * 0.95, + localY: -halfHeight * 1.225, }); const rightControl2 = toPoint({ localX: halfWidth, - localY: halfHeight * 0.15, + localY: -halfHeight * 0.125, }); - const bottom = toPoint({ localX: 0, localY: halfHeight }); + const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 }); const leftControl1 = toPoint({ localX: -halfWidth, - localY: halfHeight * 0.15, + localY: -halfHeight * 0.125, }); const leftControl2 = toPoint({ localX: -halfWidth, - localY: -halfHeight * 0.95, + localY: -halfHeight * 1.225, }); const path = new Path2D(); @@ -80,25 +81,27 @@ function buildHeartPath({ export const heartMaskDefinition: MaskDefinition = { type: "heart", name: "Heart", - overlayShape: "box", - 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.2}`, - `C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`, - `C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`, - "Z", - ].join(" "); - }, 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", diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts index c078ef60..fd00cab1 100644 --- a/apps/web/src/lib/masks/definitions/index.ts +++ b/apps/web/src/lib/masks/definitions/index.ts @@ -1,12 +1,14 @@ 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, @@ -15,6 +17,7 @@ import { FavouriteIcon, DiamondIcon, StarsIcon, + TextFontIcon, } from "@hugeicons/core-free-icons"; function registerDefaultMask({ @@ -60,4 +63,12 @@ export function registerDefaultMasks(): void { 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 index 08f969ea..832b8307 100644 --- a/apps/web/src/lib/masks/definitions/rectangle.ts +++ b/apps/web/src/lib/masks/definitions/rectangle.ts @@ -1,94 +1,97 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - 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 = { - type: "rectangle", - name: "Rectangle", - overlayShape: "box", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - 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, - }); - }, - }, -}; +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 = { + 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 index 91cd3001..c1a14d90 100644 --- a/apps/web/src/lib/masks/definitions/split.ts +++ b/apps/web/src/lib/masks/definitions/split.ts @@ -1,347 +1,382 @@ -import { computeFeatherUpdate } from "../param-update"; -import type { ParamValues } from "@/lib/params"; -import type { - MaskDefinition, - MaskParamUpdateArgs, - SplitMaskParams, -} from "@/lib/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; - -// 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): ParamValues { - 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 = { - type: "split", - name: "Split", - overlayShape: "line", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "none", - }, - 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: { - 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; - }, - }, -}; +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): Partial { + 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 = { + 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 index cdab2855..d311ab80 100644 --- a/apps/web/src/lib/masks/definitions/star.ts +++ b/apps/web/src/lib/masks/definitions/star.ts @@ -1,6 +1,7 @@ import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; import { BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, computeBoxMaskParamUpdate, getBoxLikeGeometry, getDefaultSquareMaskParams, @@ -87,16 +88,18 @@ function buildOverlayStarPath({ export const starMaskDefinition: MaskDefinition = { type: "star", name: "Star", - overlayShape: "box", - buildOverlayPath({ width, height }) { - return buildOverlayStarPath({ width, height }); - }, 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", diff --git a/apps/web/src/lib/masks/definitions/text.ts b/apps/web/src/lib/masks/definitions/text.ts new file mode 100644 index 00000000..b774ed28 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/text.ts @@ -0,0 +1,438 @@ +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[] = [ + { + 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): Partial { + 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 = { + 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 { + 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(); + }, + }, +}; -- cgit v1.3.1