refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther
2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions
+313
View File
@@ -0,0 +1,313 @@
import {
DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
MIN_MASK_DIMENSION,
} from "@/masks/dimensions";
import { computeFeatherUpdate } from "../param-update";
import type {
BaseMaskParams,
MaskDefaultContext,
MaskFeatures,
MaskInteractionDefinition,
MaskParamUpdateArgs,
RectangleMaskParams,
} from "@/masks/types";
import type { NumberParamDefinition, ParamDefinition } from "@/params";
import {
getBoxMaskHandlePositions,
getBoxMaskOverlays,
} from "@/masks/handle-positions";
import { snapMaskInteraction } from "@/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,
};
}
@@ -0,0 +1,135 @@
import type {
MaskDefaultContext,
MaskDefinition,
RectangleMaskParams,
} from "@/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,
});
},
},
};
+652
View File
@@ -0,0 +1,652 @@
import { generateUUID } from "@/utils/id";
import type { ParamDefinition } from "@/params";
import { PEN_CURSOR } from "@/preview/components/cursors";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
CustomMask,
CustomMaskParams,
MaskDefinition,
MaskHandlePosition,
MaskOverlay,
MaskParamUpdateArgs,
} from "@/masks/types";
import {
buildCustomMaskPath2D,
buildCustomMaskSvgPath,
customMaskCanvasPointToLocal,
findClosestPointOnCustomMaskSegment,
getCustomMaskCanvasSegments,
getCustomMaskCanvasGeometry,
getCustomMaskLocalBounds,
getCustomMaskSegmentCount,
insertPointIntoCustomMaskSegment,
parseCustomMaskHandleId,
recenterCustomMaskPath,
type CustomMaskPathPoint,
} from "@/masks/custom-path";
import { getBoxMaskHandlePositions } from "@/masks/handle-positions";
import { computeFeatherUpdate } from "@/masks/param-update";
import {
setMaskLocalCenter,
toGlobalMaskSnapLines,
} from "@/masks/geometry";
import {
snapPosition,
snapRotation,
snapScale,
} from "@/preview/preview-snap";
const PERCENTAGE_DISPLAY = {
displayMultiplier: 100,
step: 1,
} as const;
const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
{
key: "centerX",
label: "X",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "centerY",
label: "Y",
type: "number",
default: 0,
min: -100,
max: 100,
...PERCENTAGE_DISPLAY,
},
{
key: "rotation",
label: "Rotation",
type: "number",
default: 0,
min: 0,
max: 360,
step: 1,
},
{
key: "scale",
label: "Scale",
type: "number",
default: 1,
min: 1,
max: 500,
...PERCENTAGE_DISPLAY,
},
];
function getCustomMaskDisplayHandles({
params,
displayScale,
bounds,
}: {
params: CustomMaskParams;
displayScale: number;
bounds: ElementBounds;
}): {
handles: MaskHandlePosition[];
overlays: MaskOverlay[];
} {
const points = params.path;
const geometry = getCustomMaskCanvasGeometry({
points,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
});
const handles: MaskHandlePosition[] = [];
const overlays: MaskOverlay[] = [];
if (points.length > 0) {
overlays.push({
id: "path",
type: "canvas-path",
pathData: buildCustomMaskSvgPath({
points,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
closed: params.closed,
}),
coordinateSpace: "canvas",
});
}
if (params.closed) {
const segmentStrokeWidth = 12;
overlays.push(
...getCustomMaskCanvasSegments({
points,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
closed: true,
}).map((segment) => ({
id: `segment:${segment.index}`,
type: "canvas-path" as const,
pathData: segment.pathData,
coordinateSpace: "canvas" as const,
handleId: `segment:${segment.index}`,
cursor: PEN_CURSOR,
strokeOpacity: 0,
strokeWidth: segmentStrokeWidth,
})),
);
}
const localBounds = getCustomMaskLocalBounds({ points, bounds });
if (params.closed && localBounds) {
handles.push(
...getBoxMaskHandlePositions({
centerX: params.centerX,
centerY: params.centerY,
width: (localBounds.width * params.scale) / bounds.width,
height: (localBounds.height * params.scale) / bounds.height,
rotation: params.rotation,
feather: params.feather,
sizeMode: "uniform",
showScaleHandle: false,
bounds,
displayScale,
}),
);
}
geometry.anchors.forEach((point) => {
handles.push({
id: `point:${point.id}:anchor`,
x: point.anchor.x,
y: point.anchor.y,
cursor: params.closed ? "move" : "pointer",
kind: "point",
});
});
return {
handles,
overlays,
};
}
function updateCustomMaskPoint({
points,
pointId,
updater,
}: {
points: CustomMaskPathPoint[];
pointId: string;
updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
}) {
return points.map((point) => (point.id === pointId ? updater(point) : point));
}
function computeCustomMaskParamUpdate({
handleId,
startParams,
deltaX,
deltaY,
startCanvasX,
startCanvasY,
bounds,
}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
if (handleId === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
};
}
const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height;
if (handleId === "rotation") {
const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI;
const currentAngle =
(Math.atan2(
startCanvasY + deltaY - pivotY,
startCanvasX + deltaX - pivotX,
) *
180) /
Math.PI;
let deltaAngle = currentAngle - startAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
return {
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
};
}
if (handleId === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
deltaX,
deltaY,
directionX: -Math.sin(angleRad),
directionY: Math.cos(angleRad),
});
}
if (handleId === "scale") {
const startDistance = Math.hypot(
startCanvasX - pivotX,
startCanvasY - pivotY,
);
const currentDistance = Math.hypot(
startCanvasX + deltaX - pivotX,
startCanvasY + deltaY - pivotY,
);
const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
return {
scale: Math.max(0.01, startParams.scale * scaleFactor),
};
}
const parsedHandle = parseCustomMaskHandleId({ handleId });
if (!parsedHandle) {
return {};
}
const points = startParams.path;
const currentPoint = {
x: startCanvasX + deltaX,
y: startCanvasY + deltaY,
};
const localPoint = customMaskCanvasPointToLocal({
point: currentPoint,
centerX: startParams.centerX,
centerY: startParams.centerY,
rotation: startParams.rotation,
scale: startParams.scale,
bounds,
});
return {
path: updateCustomMaskPoint({
points,
pointId: parsedHandle.pointId,
updater: (point) => {
if (parsedHandle.part === "anchor") {
return {
...point,
x: localPoint.x,
y: localPoint.y,
};
}
if (parsedHandle.part === "in") {
return {
...point,
inX: localPoint.x - point.x,
inY: localPoint.y - point.y,
};
}
return {
...point,
outX: localPoint.x - point.x,
outY: localPoint.y - point.y,
};
},
}),
};
}
export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
type: "custom",
name: "Custom",
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "uniform",
},
params: CUSTOM_MASK_PARAMS,
interaction: {
getInteraction({
params,
bounds,
displayScale,
scaleX: _scaleX,
scaleY: _scaleY,
}) {
return getCustomMaskDisplayHandles({ params, bounds, displayScale });
},
snap({
handleId,
proposedParams,
startParams,
bounds,
canvasSize,
snapThreshold,
}) {
const points = startParams.path;
const localBounds = getCustomMaskLocalBounds({ points, bounds });
if (!startParams.closed || !localBounds) {
return {
params: proposedParams,
activeLines: [],
};
}
const position = {
x: proposedParams.centerX * bounds.width,
y: proposedParams.centerY * bounds.height,
};
if (handleId === "position") {
const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position,
canvasSize: bounds,
elementSize: {
width: localBounds.width * proposedParams.scale,
height: localBounds.height * proposedParams.scale,
},
rotation: proposedParams.rotation,
snapThreshold,
});
return {
params: {
...proposedParams,
...setMaskLocalCenter({
center: snappedPosition,
bounds,
}),
},
activeLines: toGlobalMaskSnapLines({
lines: activeLines,
bounds,
canvasSize,
}),
};
}
if (handleId === "rotation") {
const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
});
return {
params: {
...proposedParams,
rotation: snappedRotation,
},
activeLines: [],
};
}
if (handleId === "scale") {
const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale,
position,
baseWidth: localBounds.width,
baseHeight: localBounds.height,
rotation: proposedParams.rotation,
canvasSize: bounds,
snapThreshold,
preferredEdges: {
right: true,
bottom: true,
},
});
return {
params: {
...proposedParams,
scale: Math.max(0.01, snappedScale),
},
activeLines: toGlobalMaskSnapLines({
lines: activeLines,
bounds,
canvasSize,
}),
};
}
return {
params: proposedParams,
activeLines: [],
};
},
},
buildDefault(): Omit<CustomMask, "id"> {
return {
type: "custom",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 0,
strokeAlign: "center",
path: [],
closed: false,
centerX: 0,
centerY: 0,
rotation: 0,
scale: 1,
},
};
},
computeParamUpdate: computeCustomMaskParamUpdate,
isActive(params) {
return params.closed;
},
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as CustomMaskParams;
const points = params.path;
if (!params.closed) {
return new Path2D();
}
return buildCustomMaskPath2D({
points,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds: {
cx: width / 2,
cy: height / 2,
width,
height,
rotation: 0,
},
closed: true,
});
},
renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as CustomMaskParams;
if (!params.closed) {
return;
}
const points = params.path;
const path = buildCustomMaskPath2D({
points,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds: {
cx: width / 2,
cy: height / 2,
width,
height,
rotation: 0,
},
closed: true,
});
ctx.save();
ctx.strokeStyle = params.strokeColor;
ctx.lineWidth = params.strokeWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.stroke(path);
if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = "#ffffff";
ctx.fill(path);
}
if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#ffffff";
ctx.fill(path);
}
ctx.restore();
},
},
};
export function appendPointToCustomMask({
params,
canvasPoint,
bounds,
}: {
params: CustomMaskParams;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): CustomMaskParams {
const points = params.path;
if (points.length === 0) {
return {
...params,
centerX:
bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
centerY:
bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
rotation: 0,
scale: 1,
path: [
{
id: generateUUID(),
x: 0,
y: 0,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
},
],
};
}
const localPoint = customMaskCanvasPointToLocal({
point: canvasPoint,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
});
const nextPoints = [
...points,
{
id: generateUUID(),
x: localPoint.x,
y: localPoint.y,
inX: 0,
inY: 0,
outX: 0,
outY: 0,
},
];
const recentered = recenterCustomMaskPath({
points: nextPoints,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
});
return {
...params,
centerX: recentered.centerX,
centerY: recentered.centerY,
path: recentered.points,
};
}
export function insertPointOnCustomMaskSegment({
params,
segmentIndex,
canvasPoint,
bounds,
pointId = generateUUID(),
}: {
params: CustomMaskParams;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
pointId?: string;
}): { params: CustomMaskParams; pointId: string } | null {
const points = params.path;
if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
return null;
}
const closestPoint = findClosestPointOnCustomMaskSegment({
points,
segmentIndex,
canvasPoint,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
closed: params.closed,
});
if (!closestPoint) {
return null;
}
const nextPoints = insertPointIntoCustomMaskSegment({
points,
segmentIndex,
pointId,
t: closestPoint.t,
closed: params.closed,
});
if (nextPoints.length === points.length) {
return null;
}
const recentered = recenterCustomMaskPath({
points: nextPoints,
centerX: params.centerX,
centerY: params.centerY,
rotation: params.rotation,
scale: params.scale,
bounds,
});
return {
pointId,
params: {
...params,
centerX: recentered.centerX,
centerY: recentered.centerY,
path: recentered.points,
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import type { MaskDefinition, RectangleMaskParams } from "@/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,
});
},
},
};
+75
View File
@@ -0,0 +1,75 @@
import type { MaskDefinition, RectangleMaskParams } from "@/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;
},
},
};
+142
View File
@@ -0,0 +1,142 @@
import type { MaskDefinition, RectangleMaskParams } from "@/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,
});
},
},
};
+74
View File
@@ -0,0 +1,74 @@
import type { BaseMaskParams, MaskDefinition } from "@/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 },
});
}
@@ -0,0 +1,97 @@
import type { MaskDefinition, RectangleMaskParams } from "@/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,
});
},
},
};
+382
View File
@@ -0,0 +1,382 @@
import { computeFeatherUpdate } from "../param-update";
import type {
MaskDefinition,
MaskParamUpdateArgs,
SplitMaskParams,
} from "@/masks/types";
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
import {
getLineMaskHandlePositions,
getLineMaskOverlay,
} from "@/masks/handle-positions";
import { snapMaskInteraction } from "@/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;
},
},
};
+140
View File
@@ -0,0 +1,140 @@
import type { MaskDefinition, RectangleMaskParams } from "@/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,
});
},
},
};
+438
View File
@@ -0,0 +1,438 @@
import type { ParamDefinition } from "@/params";
import type {
MaskDefinition,
MaskParamUpdateArgs,
TextMask,
TextMaskParams,
} from "@/masks/types";
import { DEFAULTS } from "@/timeline/defaults";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography";
import {
drawMeasuredTextLayout,
measureTextLayout,
strokeMeasuredTextLayout,
} from "@/text/primitives";
import { getTextMeasurementContext } from "@/text/measure-element";
import { getTextVisualRect } from "@/text/layout";
import {
getBoxMaskHandlePositions,
getBoxMaskRectOverlay,
} from "@/masks/handle-positions";
import { computeFeatherUpdate } from "@/masks/param-update";
import {
setMaskLocalCenter,
toGlobalMaskSnapLines,
} from "@/masks/geometry";
import {
snapPosition,
snapRotation,
snapScale,
type ScaleEdgePreference,
} from "@/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();
},
},
};