refactor: make mask renderer dispatch explicit

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-29 18:27:21 +02:00
parent 6ea5a156b6
commit ae1292a14b
14 changed files with 669 additions and 568 deletions

View File

@ -71,7 +71,7 @@ function buildBandPath({
return path;
}
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
type: "cinematic-bars",
name: "Cinematic Bars",
features: {
@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
},
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;
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
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,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
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

@ -308,7 +308,7 @@ function computeCustomMaskParamUpdate({
};
}
export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
export const customMaskDefinition: MaskDefinition<"custom"> = {
type: "custom",
name: "Custom",
features: {
@ -447,71 +447,77 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
return params.closed;
},
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as CustomMaskParams;
const points = params.path;
if (!params.closed) {
return new Path2D();
}
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
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;
}
stroke: {
kind: "renderStroke",
renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
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,
});
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);
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 === "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();
if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#ffffff";
ctx.fill(path);
}
ctx.restore();
},
},
},
};

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@ -45,7 +45,7 @@ function buildDiamondPath({
return path;
}
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
type: "diamond",
name: "Diamond",
features: {
@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
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,
});
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@ -8,7 +8,7 @@ import {
getStrokeOffset,
} from "./box-like";
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
type: "ellipse",
name: "Ellipse",
features: {
@ -35,41 +35,47 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
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;
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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;
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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;
},
},
},
};

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@ -78,7 +78,7 @@ function buildHeartPath({
return path;
}
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const heartMaskDefinition: MaskDefinition<"heart"> = {
type: "heart",
name: "Heart",
features: {
@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
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,
});
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,5 +1,8 @@
import type { BaseMaskParams, MaskDefinition } from "@/masks/types";
import { masksRegistry, type MaskIconProps } from "../registry";
import {
masksRegistry,
type MaskDefinitionForRegistration,
type MaskIconProps,
} from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond";
@ -20,11 +23,11 @@ import {
TextFontIcon,
} from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({
function registerDefaultMask({
definition,
icon,
}: {
definition: MaskDefinition<TParams>;
definition: MaskDefinitionForRegistration;
icon: MaskIconProps;
}) {
if (masksRegistry.has(definition.type)) {

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@ -45,7 +45,7 @@ function buildRectanglePath({
return path;
}
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = {
type: "rectangle",
name: "Rectangle",
features: {
@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
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,
});
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
},
},
},
};

View File

@ -71,11 +71,11 @@ export function getSplitMaskStrokeSegment({
width,
height,
}: {
resolvedParams: unknown;
resolvedParams: SplitMaskParams;
width: number;
height: number;
}): [{ x: number; y: number }, { x: number; y: number }] | null {
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX,
centerY,
@ -180,7 +180,7 @@ function computeSplitMaskParamUpdate({
return {};
}
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
export const splitMaskDefinition: MaskDefinition<"split"> = {
type: "split",
name: "Split",
features: {
@ -267,124 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
},
],
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,
});
body: {
kind: "drawWithFeather",
drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
const { centerX, centerY, rotation } = resolvedParams;
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");
// 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);
},
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,
y,
}: {
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({ x: x1, y: y1 });
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
if (isVertex1Inside && isVertex2Inside) {
vertices.push([x2, y2]);
} else if (isVertex1Inside && !isVertex2Inside) {
const hit = lineEdgeIntersection({
lineX,
lineY,
normalX,
normalY,
x1,
y1,
x2,
y2,
opaqueFastPath: {
buildPath({ resolvedParams, width, height }) {
const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX,
centerY,
rotation,
width,
height,
});
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]);
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,
y,
}: {
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({ x: x1, y: y1 });
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: 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();
}
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;
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();
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const segment = getSplitMaskStrokeSegment({
resolvedParams,
width,
height,
});
const path = new Path2D();
if (!segment) {
if (!segment) {
return path;
}
path.moveTo(segment[0].x, segment[0].y);
path.lineTo(segment[1].x, segment[1].y);
return path;
}
path.moveTo(segment[0].x, segment[0].y);
path.lineTo(segment[1].x, segment[1].y);
return path;
},
},
},
};

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@ -85,7 +85,7 @@ function buildOverlayStarPath({
return `${segments.join(" ")} Z`;
}
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const starMaskDefinition: MaskDefinition<"star"> = {
type: "star",
name: "Star",
features: {
@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
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,
});
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
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,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
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

@ -205,7 +205,7 @@ function computeTextMaskParamUpdate({
return {};
}
export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
export const textMaskDefinition: MaskDefinition<"text"> = {
type: "text",
name: "Text",
features: {
@ -370,69 +370,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
return params.content.trim().length > 0;
},
renderer: {
renderMask({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as TextMaskParams;
const { layout } = measureTextMask({ params, height });
body: {
kind: "drawOpaque",
drawOpaque({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
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();
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 });
stroke: {
kind: "renderStroke",
renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
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);
}
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({
strokeMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
strokeColor: params.strokeColor,
strokeWidth: params.strokeWidth,
});
}
if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-in";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
ctx.restore();
if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
ctx.restore();
},
},
},
};

View File

@ -1,18 +1,50 @@
import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
import { masksRegistry } from "./registry";
import { generateUUID } from "@/utils/id";
export { masksRegistry } from "./registry";
export { registerDefaultMasks } from "./definitions";
export function buildDefaultMaskInstance({
maskType,
elementSize,
}: {
maskType: MaskType;
elementSize?: { width: number; height: number };
}): Mask {
const definition = masksRegistry.get(maskType);
const context: MaskDefaultContext = { elementSize };
return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
}
import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
import { masksRegistry } from "./registry";
import { generateUUID } from "@/utils/id";
export { masksRegistry } from "./registry";
export { registerDefaultMasks } from "./definitions";
type MaskWithoutId = Mask extends infer TMask
? TMask extends Mask
? Omit<TMask, "id">
: never
: never;
function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask {
switch (mask.type) {
case "split":
return { ...mask, id };
case "cinematic-bars":
return { ...mask, id };
case "rectangle":
return { ...mask, id };
case "ellipse":
return { ...mask, id };
case "heart":
return { ...mask, id };
case "diamond":
return { ...mask, id };
case "star":
return { ...mask, id };
case "text":
return { ...mask, id };
case "custom":
return { ...mask, id };
}
}
export function buildDefaultMaskInstance({
maskType,
elementSize,
}: {
maskType: MaskType;
elementSize?: { width: number; height: number };
}): Mask {
const definition = masksRegistry.get(maskType);
const context: MaskDefaultContext = { elementSize };
return withMaskId({
mask: definition.buildDefault(context),
id: generateUUID(),
});
}

View File

@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather";
import type { ParamDefinition } from "@/params";
import type {
BaseMaskParams,
Mask,
MaskDefaultContext,
MaskDefinition,
MaskInteractionResult,
MaskSnapArgs,
MaskSnapResult,
MaskParamUpdateArgs,
MaskRenderer,
MaskType,
} from "@/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react";
@ -17,6 +17,16 @@ export type MaskIconProps = {
strokeWidth?: number;
};
type RegisteredMaskWithoutId = Mask extends infer TMask
? TMask extends Mask
? Omit<TMask, "id">
: never
: never;
export type MaskDefinitionForRegistration = {
[TType in MaskType]: MaskDefinition<TType>;
}[MaskType];
const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
keyof BaseMaskParams & string
>[] = [
@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
export interface RegisteredMaskDefinition {
type: MaskType;
name: string;
features: MaskDefinition<BaseMaskParams>["features"];
features: MaskDefinition["features"];
params: ParamDefinition<string>[];
renderer: MaskDefinition<BaseMaskParams>["renderer"];
interaction: {
getInteraction(args: {
params: BaseMaskParams;
bounds: Parameters<
MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
>[0]["bounds"];
displayScale: number;
scaleX: number;
scaleY: number;
}): MaskInteractionResult;
snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
};
isActive?: (params: BaseMaskParams) => boolean;
buildDefault(
context: MaskDefaultContext,
): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
renderer: MaskRenderer<BaseMaskParams>;
interaction: MaskDefinition["interaction"];
isActive?(params: BaseMaskParams): boolean;
buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId;
computeParamUpdate(
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
args: MaskParamUpdateArgs<BaseMaskParams>,
): ReturnType<MaskDefinition["computeParamUpdate"]>;
icon: MaskIconProps;
}
@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry<
super("mask");
}
registerMask<TParams extends BaseMaskParams>({
registerMask({
definition,
icon,
}: {
definition: MaskDefinition<TParams>;
definition: MaskDefinitionForRegistration;
icon: MaskIconProps;
}): void {
const withBaseParams: RegisteredMaskDefinition = {
@ -96,23 +93,10 @@ export class MasksRegistry extends DefinitionRegistry<
features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
renderer: definition.renderer,
interaction: {
getInteraction(args) {
return definition.interaction.getInteraction(args as never);
},
snap: definition.interaction.snap
? (args) => definition.interaction.snap?.(args as never) as never
: undefined,
},
isActive: definition.isActive
? (params) => definition.isActive?.(params as TParams) ?? true
: undefined,
buildDefault(context) {
return definition.buildDefault(context);
},
computeParamUpdate(args) {
return definition.computeParamUpdate(args as never);
},
interaction: definition.interaction,
isActive: definition.isActive,
buildDefault: definition.buildDefault,
computeParamUpdate: definition.computeParamUpdate,
icon,
};
this.register({

View File

@ -131,32 +131,50 @@ export type Mask =
| TextMask
| CustomMask;
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;
renderMaskHandlesFeather?: boolean;
renderStroke?: (params: {
resolvedParams: unknown;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
}) => void;
export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>;
export type MaskParamsByType<TType extends MaskType> =
MaskByType<TType>["params"];
type MaskPathArgs<TParams extends BaseMaskParams> = {
resolvedParams: TParams;
width: number;
height: number;
};
type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
};
export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> =
| {
kind: "fillPath";
buildPath(args: MaskPathArgs<TParams>): Path2D;
}
| {
kind: "drawOpaque";
drawOpaque(args: MaskDrawArgs<TParams>): void;
}
| {
kind: "drawWithFeather";
drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void;
opaqueFastPath?: {
buildPath(args: MaskPathArgs<TParams>): Path2D;
};
};
export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> =
| {
kind: "strokeFromPath";
buildStrokePath(args: MaskPathArgs<TParams>): Path2D;
}
| {
kind: "renderStroke";
renderStroke(args: MaskDrawArgs<TParams>): void;
};
export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> {
body: MaskBody<TParams>;
stroke?: MaskStroke<TParams>;
}
export interface MaskFeatures {
@ -282,17 +300,17 @@ export interface MaskInteractionDefinition<
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
}
export interface MaskDefinition<
TParams extends BaseMaskParams = BaseMaskParams,
> {
type: MaskType;
export interface MaskDefinition<TType extends MaskType = MaskType> {
type: TType;
name: string;
features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[];
renderer: MaskRenderer;
interaction: MaskInteractionDefinition<TParams>;
params: ParamDefinition<keyof MaskParamsByType<TType> & string>[];
renderer: MaskRenderer<MaskParamsByType<TType>>;
interaction: MaskInteractionDefinition<MaskParamsByType<TType>>;
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
isActive?: (params: TParams) => boolean;
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
isActive?(params: MaskParamsByType<TType>): boolean;
buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">;
computeParamUpdate(
args: MaskParamUpdateArgs<MaskParamsByType<TType>>,
): Partial<MaskParamsByType<TType>>;
}

View File

@ -396,23 +396,16 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null };
}
let feather = mask.params.feather;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
const shouldRenderMaskDirectly =
canRenderMaskDirectly &&
(!definition.renderer.buildPath ||
(mask.params.feather > 0 &&
definition.renderer.renderMaskHandlesFeather));
if (
shouldRenderMaskDirectly &&
definition.renderer.renderMaskHandlesFeather
) {
feather = 0;
}
const { body } = definition.renderer;
const usesOpaqueFastPath =
body.kind === "drawWithFeather" &&
mask.params.feather === 0 &&
Boolean(body.opaqueFastPath);
const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather;
const maskTextureId = `${path}:mask`;
const { width: canvasWidth, height: canvasHeight } = renderer;
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:body=${body.kind}:fastPath=${usesOpaqueFastPath}`;
const drawMask: TextureCanvasDrawFn = (ctx) => {
const { canvas: elementMaskCanvas, context: elementMaskCtx } =
createCanvasSurface({
@ -420,24 +413,44 @@ function buildMaskArtifacts({
height: Math.round(transform.height),
});
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
} else if (definition.renderer.buildPath) {
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
} else {
return;
switch (body.kind) {
case "fillPath": {
const path2d = body.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
break;
}
case "drawOpaque":
body.drawOpaque({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
});
break;
case "drawWithFeather":
if (usesOpaqueFastPath && body.opaqueFastPath) {
const path2d = body.opaqueFastPath.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
} else {
body.drawWithFeather({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
}
break;
}
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
@ -451,45 +464,38 @@ function buildMaskArtifacts({
draw: drawMask,
});
const hasStroke =
mask.params.strokeWidth > 0 &&
(definition.renderer.renderStroke ||
definition.renderer.buildStrokePath ||
definition.renderer.buildPath);
const stroke = definition.renderer.stroke;
const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke);
let strokeLayer: FrameItemDescriptor | null = null;
if (hasStroke) {
if (hasStroke && stroke) {
const strokeTextureId = `${path}:mask-stroke`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:stroke=${stroke.kind}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => {
const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
if (definition.renderer.renderStroke) {
definition.renderer.renderStroke({
resolvedParams: mask.params,
ctx: strokeCtx,
width: transform.width,
height: transform.height,
});
} else {
const strokePath =
definition.renderer.buildStrokePath?.({
switch (stroke.kind) {
case "renderStroke":
stroke.renderStroke({
resolvedParams: mask.params,
ctx: strokeCtx,
width: transform.width,
height: transform.height,
});
break;
case "strokeFromPath": {
const strokePath = stroke.buildStrokePath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
definition.renderer.buildPath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ??
null;
if (!strokePath) return;
strokeCtx.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath);
});
strokeCtx.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath);
break;
}
}
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });