mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: restructure files to their domains, new preview overlay system, and dep graph
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user