feat: cinematic bars, diamond, heart, and star masks
Made-with: Cursor
This commit is contained in:
parent
108d3f4eca
commit
5a2aaf3601
|
|
@ -450,6 +450,44 @@ function MaskParamsFields({
|
|||
</SectionField>
|
||||
)}
|
||||
|
||||
{definition.features.sizeMode === "height-only" &&
|
||||
"height" in mask.params && (
|
||||
<SectionField label="Height">
|
||||
<MaskNumberField
|
||||
icon="H"
|
||||
param={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "height",
|
||||
})}
|
||||
value={getMaskNumber({
|
||||
params: mask.params,
|
||||
key: "height",
|
||||
})}
|
||||
onPreview={previewNumberParam("height")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</SectionField>
|
||||
)}
|
||||
|
||||
{definition.features.sizeMode === "width-only" &&
|
||||
"width" in mask.params && (
|
||||
<SectionField label="Width">
|
||||
<MaskNumberField
|
||||
icon="W"
|
||||
param={getNumberParamDefinition({
|
||||
definition,
|
||||
key: "width",
|
||||
})}
|
||||
value={getMaskNumber({
|
||||
params: mask.params,
|
||||
key: "width",
|
||||
})}
|
||||
onPreview={previewNumberParam("width")}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</SectionField>
|
||||
)}
|
||||
|
||||
{definition.features.sizeMode === "uniform" && "scale" in mask.params && (
|
||||
<SectionField label="Scale">
|
||||
<MaskNumberField
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import type {
|
||||
MaskDefaultContext,
|
||||
MaskDefinition,
|
||||
RectangleMaskParams,
|
||||
} from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
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",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M 0,0 H ${width} V ${height} H 0 Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "height-only",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
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,97 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
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",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
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,139 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
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.2 });
|
||||
const rightControl1 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
});
|
||||
const rightControl2 = toPoint({
|
||||
localX: halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
});
|
||||
const bottom = toPoint({ localX: 0, localY: halfHeight });
|
||||
const leftControl1 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: halfHeight * 0.15,
|
||||
});
|
||||
const leftControl2 = toPoint({
|
||||
localX: -halfWidth,
|
||||
localY: -halfHeight * 0.95,
|
||||
});
|
||||
|
||||
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",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
`M ${cx},${cy - halfHeight * 0.2}`,
|
||||
`C ${cx + halfWidth},${cy - halfHeight * 0.95} ${cx + halfWidth},${cy + halfHeight * 0.15} ${cx},${cy + halfHeight}`,
|
||||
`C ${cx - halfWidth},${cy + halfHeight * 0.15} ${cx - halfWidth},${cy - halfHeight * 0.95} ${cx},${cy - halfHeight * 0.2}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,39 +1,63 @@
|
|||
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||
import { ellipseMaskDefinition } from "./ellipse";
|
||||
import { rectangleMaskDefinition } from "./rectangle";
|
||||
import { splitMaskDefinition } from "./split";
|
||||
import {
|
||||
PanelRightDashedIcon,
|
||||
SquareIcon,
|
||||
CircleIcon,
|
||||
} 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: rectangleMaskDefinition,
|
||||
icon: { icon: SquareIcon },
|
||||
});
|
||||
registerDefaultMask({
|
||||
definition: ellipseMaskDefinition,
|
||||
icon: { icon: CircleIcon },
|
||||
});
|
||||
}
|
||||
import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
|
||||
import { masksRegistry, type MaskIconProps } from "../registry";
|
||||
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
|
||||
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 {
|
||||
MinusSignIcon,
|
||||
PanelRightDashedIcon,
|
||||
SquareIcon,
|
||||
CircleIcon,
|
||||
FavouriteIcon,
|
||||
DiamondIcon,
|
||||
StarsIcon,
|
||||
} 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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
|
||||
import {
|
||||
BOX_LIKE_MASK_PARAMS,
|
||||
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",
|
||||
overlayShape: "box",
|
||||
buildOverlayPath({ width, height }) {
|
||||
return buildOverlayStarPath({ width, height });
|
||||
},
|
||||
features: {
|
||||
hasPosition: true,
|
||||
hasRotation: true,
|
||||
sizeMode: "width-height",
|
||||
},
|
||||
params: BOX_LIKE_MASK_PARAMS,
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,115 +1,153 @@
|
|||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
|
||||
export type MaskType = "split" | "rectangle" | "ellipse";
|
||||
|
||||
export interface BaseMaskParams extends ParamValues {
|
||||
feather: number;
|
||||
inverted: boolean;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
strokeAlign: "inside" | "center" | "outside";
|
||||
}
|
||||
|
||||
export interface SplitMaskParams extends BaseMaskParams {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface RectangleMaskParams extends BaseMaskParams {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SplitMask {
|
||||
id: string;
|
||||
type: "split";
|
||||
params: SplitMaskParams;
|
||||
}
|
||||
|
||||
export interface RectangleMask {
|
||||
id: string;
|
||||
type: "rectangle";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface EllipseMask {
|
||||
id: string;
|
||||
type: "ellipse";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export type Mask = SplitMask | RectangleMask | EllipseMask;
|
||||
|
||||
export interface MaskRenderer {
|
||||
buildPath(params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Path2D;
|
||||
buildStrokePath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => Path2D;
|
||||
/** Renders the feathered mask directly onto ctx, bypassing JFA. */
|
||||
renderMask?: (params: {
|
||||
resolvedParams: unknown;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
feather: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export type MaskOverlayShape = "line" | "box";
|
||||
|
||||
export interface MaskFeatures {
|
||||
hasPosition: boolean;
|
||||
hasRotation: boolean;
|
||||
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
||||
}
|
||||
|
||||
export interface MaskHandlePosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface MaskLinePoints {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface MaskDefaultContext {
|
||||
elementSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamDefinition<keyof TParams & string>[];
|
||||
renderer: MaskRenderer;
|
||||
buildOverlayPath?: (params: { width: number; height: number }) => string;
|
||||
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
|
||||
}
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { ParamDefinition, ParamValues } from "@/lib/params";
|
||||
|
||||
export type MaskType =
|
||||
| "split"
|
||||
| "cinematic-bars"
|
||||
| "rectangle"
|
||||
| "ellipse"
|
||||
| "heart"
|
||||
| "diamond"
|
||||
| "star";
|
||||
|
||||
export interface BaseMaskParams extends ParamValues {
|
||||
feather: number;
|
||||
inverted: boolean;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
strokeAlign: "inside" | "center" | "outside";
|
||||
}
|
||||
|
||||
export interface SplitMaskParams extends BaseMaskParams {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface RectangleMaskParams extends BaseMaskParams {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SplitMask {
|
||||
id: string;
|
||||
type: "split";
|
||||
params: SplitMaskParams;
|
||||
}
|
||||
|
||||
export interface CinematicBarsMask {
|
||||
id: string;
|
||||
type: "cinematic-bars";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface RectangleMask {
|
||||
id: string;
|
||||
type: "rectangle";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface EllipseMask {
|
||||
id: string;
|
||||
type: "ellipse";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface HeartMask {
|
||||
id: string;
|
||||
type: "heart";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface DiamondMask {
|
||||
id: string;
|
||||
type: "diamond";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export interface StarMask {
|
||||
id: string;
|
||||
type: "star";
|
||||
params: RectangleMaskParams;
|
||||
}
|
||||
|
||||
export type Mask =
|
||||
| SplitMask
|
||||
| CinematicBarsMask
|
||||
| RectangleMask
|
||||
| EllipseMask
|
||||
| HeartMask
|
||||
| DiamondMask
|
||||
| StarMask;
|
||||
|
||||
export interface MaskRenderer {
|
||||
buildPath(params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}): Path2D;
|
||||
buildStrokePath?: (params: {
|
||||
resolvedParams: unknown;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => Path2D;
|
||||
/** Renders the feathered mask directly onto ctx, bypassing JFA. */
|
||||
renderMask?: (params: {
|
||||
resolvedParams: unknown;
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
feather: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export type MaskOverlayShape = "line" | "box";
|
||||
|
||||
export interface MaskFeatures {
|
||||
hasPosition: boolean;
|
||||
hasRotation: boolean;
|
||||
sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
|
||||
}
|
||||
|
||||
export interface MaskHandlePosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface MaskLinePoints {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface MaskDefaultContext {
|
||||
elementSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
handleId: string;
|
||||
startParams: TParams;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
startCanvasX: number;
|
||||
startCanvasY: number;
|
||||
bounds: ElementBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
|
||||
type: MaskType;
|
||||
name: string;
|
||||
overlayShape: MaskOverlayShape;
|
||||
features: MaskFeatures;
|
||||
params: ParamDefinition<keyof TParams & string>[];
|
||||
renderer: MaskRenderer;
|
||||
buildOverlayPath?: (params: { width: number; height: number }) => string;
|
||||
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
|
||||
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue