feat: cinematic bars, diamond, heart, and star masks

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-13 04:40:48 +02:00
parent 108d3f4eca
commit 5a2aaf3601
7 changed files with 759 additions and 154 deletions

View File

@ -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

View File

@ -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,
});
},
},
};

View File

@ -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,
});
},
},
};

View File

@ -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,
});
},
},
};

View File

@ -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 },
});
}

View File

@ -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,
});
},
},
};

View File

@ -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;
}