refactor: make mask renderer dispatch explicit

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-05-02 03:27:23 +02:00
parent 6ea5a156b6
commit ae1292a14b
14 changed files with 669 additions and 568 deletions
@@ -71,7 +71,7 @@ function buildBandPath({
return path; return path;
} }
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
type: "cinematic-bars", type: "cinematic-bars",
name: "Cinematic Bars", name: "Cinematic Bars",
features: { features: {
@@ -94,8 +94,10 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const centerX = width / 2 + params.centerX * width; const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height; const centerY = height / 2 + params.centerY * height;
const maskWidth = Math.max(params.width * width, width); const maskWidth = Math.max(params.width * width, width);
@@ -110,8 +112,11 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
rotationRad, rotationRad,
}); });
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const centerX = width / 2 + params.centerX * width; const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height; const centerY = height / 2 + params.centerY * height;
const rotationRad = (params.rotation * Math.PI) / 180; const rotationRad = (params.rotation * Math.PI) / 180;
@@ -123,7 +128,10 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
return buildBandPath({ return buildBandPath({
centerX, centerX,
centerY, centerY,
halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), halfWidth: Math.max(
Math.max(params.width * width, width) / 2 + offset,
1,
),
halfHeight: Math.max( halfHeight: Math.max(
(Math.max(params.height, 0.01) * height) / 2 + offset, (Math.max(params.height, 0.01) * height) / 2 + offset,
1, 1,
@@ -132,4 +140,5 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
}); });
}, },
}, },
},
}; };
+9 -3
View File
@@ -308,7 +308,7 @@ function computeCustomMaskParamUpdate({
}; };
} }
export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { export const customMaskDefinition: MaskDefinition<"custom"> = {
type: "custom", type: "custom",
name: "Custom", name: "Custom",
features: { features: {
@@ -447,8 +447,10 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
return params.closed; return params.closed;
}, },
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as CustomMaskParams; const params = resolvedParams;
const points = params.path; const points = params.path;
if (!params.closed) { if (!params.closed) {
return new Path2D(); return new Path2D();
@@ -470,8 +472,11 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
closed: true, closed: true,
}); });
}, },
},
stroke: {
kind: "renderStroke",
renderStroke({ resolvedParams, ctx, width, height }) { renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as CustomMaskParams; const params = resolvedParams;
if (!params.closed) { if (!params.closed) {
return; return;
} }
@@ -514,6 +519,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
ctx.restore(); ctx.restore();
}, },
}, },
},
}; };
export function appendPointToCustomMask({ export function appendPointToCustomMask({
+10 -4
View File
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@@ -45,7 +45,7 @@ function buildDiamondPath({
return path; return path;
} }
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
type: "diamond", type: "diamond",
name: "Diamond", name: "Diamond",
features: { features: {
@@ -68,8 +68,10 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
return buildDiamondPath({ return buildDiamondPath({
@@ -80,8 +82,11 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
rotationRad, rotationRad,
}); });
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({ const offset = getStrokeOffset({
@@ -97,4 +102,5 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}); });
}, },
}, },
},
}; };
+10 -4
View File
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@@ -8,7 +8,7 @@ import {
getStrokeOffset, getStrokeOffset,
} from "./box-like"; } from "./box-like";
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
type: "ellipse", type: "ellipse",
name: "Ellipse", name: "Ellipse",
features: { features: {
@@ -35,8 +35,10 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const path = new Path2D(); const path = new Path2D();
@@ -51,8 +53,11 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
); );
return path; return path;
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({ const offset = getStrokeOffset({
@@ -72,4 +77,5 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
return path; return path;
}, },
}, },
},
}; };
+10 -4
View File
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@@ -78,7 +78,7 @@ function buildHeartPath({
return path; return path;
} }
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const heartMaskDefinition: MaskDefinition<"heart"> = {
type: "heart", type: "heart",
name: "Heart", name: "Heart",
features: { features: {
@@ -110,8 +110,10 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
return buildHeartPath({ return buildHeartPath({
@@ -122,8 +124,11 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
rotationRad, rotationRad,
}); });
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({ const offset = getStrokeOffset({
@@ -139,4 +144,5 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}); });
}, },
}, },
},
}; };
+7 -4
View File
@@ -1,5 +1,8 @@
import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; import {
import { masksRegistry, type MaskIconProps } from "../registry"; masksRegistry,
type MaskDefinitionForRegistration,
type MaskIconProps,
} from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom"; import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond"; import { diamondMaskDefinition } from "./diamond";
@@ -20,11 +23,11 @@ import {
TextFontIcon, TextFontIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({ function registerDefaultMask({
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinition<TParams>; definition: MaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}) { }) {
if (masksRegistry.has(definition.type)) { if (masksRegistry.has(definition.type)) {
+10 -4
View File
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@@ -45,7 +45,7 @@ function buildRectanglePath({
return path; return path;
} }
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = {
type: "rectangle", type: "rectangle",
name: "Rectangle", name: "Rectangle",
features: { features: {
@@ -65,8 +65,10 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
return buildRectanglePath({ return buildRectanglePath({
@@ -77,8 +79,11 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
rotationRad, rotationRad,
}); });
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({ const offset = getStrokeOffset({
@@ -94,4 +99,5 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}); });
}, },
}, },
},
}; };
+14 -7
View File
@@ -71,11 +71,11 @@ export function getSplitMaskStrokeSegment({
width, width,
height, height,
}: { }: {
resolvedParams: unknown; resolvedParams: SplitMaskParams;
width: number; width: number;
height: number; height: number;
}): [{ x: number; y: number }, { x: number; y: number }] | null { }): [{ 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({ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX, centerX,
centerY, centerY,
@@ -180,7 +180,7 @@ function computeSplitMaskParamUpdate({
return {}; return {};
} }
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { export const splitMaskDefinition: MaskDefinition<"split"> = {
type: "split", type: "split",
name: "Split", name: "Split",
features: { features: {
@@ -267,9 +267,10 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
}, },
], ],
renderer: { renderer: {
renderMaskHandlesFeather: true, body: {
renderMask({ resolvedParams, ctx, width, height, feather }) { kind: "drawWithFeather",
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX, centerX,
centerY, centerY,
@@ -293,8 +294,9 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
}, },
opaqueFastPath: {
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX, centerX,
centerY, centerY,
@@ -370,6 +372,10 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
path.closePath(); path.closePath();
return path; return path;
}, },
},
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const segment = getSplitMaskStrokeSegment({ const segment = getSplitMaskStrokeSegment({
resolvedParams, resolvedParams,
@@ -387,4 +393,5 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
return path; return path;
}, },
}, },
},
}; };
+10 -4
View File
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@@ -85,7 +85,7 @@ function buildOverlayStarPath({
return `${segments.join(" ")} Z`; return `${segments.join(" ")} Z`;
} }
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const starMaskDefinition: MaskDefinition<"star"> = {
type: "star", type: "star",
name: "Star", name: "Star",
features: { features: {
@@ -108,8 +108,10 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) { buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
return buildStarPath({ return buildStarPath({
@@ -120,8 +122,11 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
rotationRad, rotationRad,
}); });
}, },
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) { buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams; const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height }); getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({ const offset = getStrokeOffset({
@@ -137,4 +142,5 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}); });
}, },
}, },
},
}; };
+10 -4
View File
@@ -205,7 +205,7 @@ function computeTextMaskParamUpdate({
return {}; return {};
} }
export const textMaskDefinition: MaskDefinition<TextMaskParams> = { export const textMaskDefinition: MaskDefinition<"text"> = {
type: "text", type: "text",
name: "Text", name: "Text",
features: { features: {
@@ -370,8 +370,10 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
return params.content.trim().length > 0; return params.content.trim().length > 0;
}, },
renderer: { renderer: {
renderMask({ resolvedParams, ctx, width, height }) { body: {
const params = resolvedParams as TextMaskParams; kind: "drawOpaque",
drawOpaque({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
const { layout } = measureTextMask({ params, height }); const { layout } = measureTextMask({ params, height });
ctx.save(); ctx.save();
@@ -391,8 +393,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
}); });
ctx.restore(); ctx.restore();
}, },
},
stroke: {
kind: "renderStroke",
renderStroke({ resolvedParams, ctx, width, height }) { renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams as TextMaskParams; const params = resolvedParams;
const { layout } = measureTextMask({ params, height }); const { layout } = measureTextMask({ params, height });
ctx.save(); ctx.save();
@@ -435,4 +440,5 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
ctx.restore(); ctx.restore();
}, },
}, },
},
}; };
+33 -1
View File
@@ -5,6 +5,35 @@ import { generateUUID } from "@/utils/id";
export { masksRegistry } from "./registry"; export { masksRegistry } from "./registry";
export { registerDefaultMasks } from "./definitions"; 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({ export function buildDefaultMaskInstance({
maskType, maskType,
elementSize, elementSize,
@@ -14,5 +43,8 @@ export function buildDefaultMaskInstance({
}): Mask { }): Mask {
const definition = masksRegistry.get(maskType); const definition = masksRegistry.get(maskType);
const context: MaskDefaultContext = { elementSize }; const context: MaskDefaultContext = { elementSize };
return { ...definition.buildDefault(context), id: generateUUID() } as Mask; return withMaskId({
mask: definition.buildDefault(context),
id: generateUUID(),
});
} }
+26 -42
View File
@@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather";
import type { ParamDefinition } from "@/params"; import type { ParamDefinition } from "@/params";
import type { import type {
BaseMaskParams, BaseMaskParams,
Mask,
MaskDefaultContext, MaskDefaultContext,
MaskDefinition, MaskDefinition,
MaskInteractionResult, MaskParamUpdateArgs,
MaskSnapArgs, MaskRenderer,
MaskSnapResult,
MaskType, MaskType,
} from "@/masks/types"; } from "@/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react"; import type { HugeiconsIconProps } from "@hugeicons/react";
@@ -17,6 +17,16 @@ export type MaskIconProps = {
strokeWidth?: number; 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< const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
keyof BaseMaskParams & string keyof BaseMaskParams & string
>[] = [ >[] = [
@@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
export interface RegisteredMaskDefinition { export interface RegisteredMaskDefinition {
type: MaskType; type: MaskType;
name: string; name: string;
features: MaskDefinition<BaseMaskParams>["features"]; features: MaskDefinition["features"];
params: ParamDefinition<string>[]; params: ParamDefinition<string>[];
renderer: MaskDefinition<BaseMaskParams>["renderer"]; renderer: MaskRenderer<BaseMaskParams>;
interaction: { interaction: MaskDefinition["interaction"];
getInteraction(args: { isActive?(params: BaseMaskParams): boolean;
params: BaseMaskParams; buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId;
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"]>;
computeParamUpdate( computeParamUpdate(
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], args: MaskParamUpdateArgs<BaseMaskParams>,
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; ): ReturnType<MaskDefinition["computeParamUpdate"]>;
icon: MaskIconProps; icon: MaskIconProps;
} }
@@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry<
super("mask"); super("mask");
} }
registerMask<TParams extends BaseMaskParams>({ registerMask({
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinition<TParams>; definition: MaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}): void { }): void {
const withBaseParams: RegisteredMaskDefinition = { const withBaseParams: RegisteredMaskDefinition = {
@@ -96,23 +93,10 @@ export class MasksRegistry extends DefinitionRegistry<
features: definition.features, features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
renderer: definition.renderer, renderer: definition.renderer,
interaction: { interaction: definition.interaction,
getInteraction(args) { isActive: definition.isActive,
return definition.interaction.getInteraction(args as never); buildDefault: definition.buildDefault,
}, computeParamUpdate: definition.computeParamUpdate,
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);
},
icon, icon,
}; };
this.register({ this.register({
+51 -33
View File
@@ -131,32 +131,50 @@ export type Mask =
| TextMask | TextMask
| CustomMask; | CustomMask;
export interface MaskRenderer { export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>;
buildPath?: (params: { export type MaskParamsByType<TType extends MaskType> =
resolvedParams: unknown; MaskByType<TType>["params"];
type MaskPathArgs<TParams extends BaseMaskParams> = {
resolvedParams: TParams;
width: number; width: number;
height: number; height: number;
}) => Path2D; };
buildStrokePath?: (params: {
resolvedParams: unknown; type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & {
width: number;
height: number;
}) => Path2D;
/** Renders the feathered mask directly onto ctx, bypassing JFA. */
renderMask?: (params: {
resolvedParams: unknown;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number; };
height: number;
feather: number; export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> =
}) => void; | {
renderMaskHandlesFeather?: boolean; kind: "fillPath";
renderStroke?: (params: { buildPath(args: MaskPathArgs<TParams>): Path2D;
resolvedParams: unknown; }
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; | {
width: number; kind: "drawOpaque";
height: number; drawOpaque(args: MaskDrawArgs<TParams>): void;
}) => 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 { export interface MaskFeatures {
@@ -282,17 +300,17 @@ export interface MaskInteractionDefinition<
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
} }
export interface MaskDefinition< export interface MaskDefinition<TType extends MaskType = MaskType> {
TParams extends BaseMaskParams = BaseMaskParams, type: TType;
> {
type: MaskType;
name: string; name: string;
features: MaskFeatures; features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[]; params: ParamDefinition<keyof MaskParamsByType<TType> & string>[];
renderer: MaskRenderer; renderer: MaskRenderer<MaskParamsByType<TType>>;
interaction: MaskInteractionDefinition<TParams>; interaction: MaskInteractionDefinition<MaskParamsByType<TType>>;
/** When defined and returning false, the mask is not applied and the element renders fully visible. */ /** When defined and returning false, the mask is not applied and the element renders fully visible. */
isActive?: (params: TParams) => boolean; isActive?(params: MaskParamsByType<TType>): boolean;
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">;
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; computeParamUpdate(
args: MaskParamUpdateArgs<MaskParamsByType<TType>>,
): Partial<MaskParamsByType<TType>>;
} }
@@ -396,23 +396,16 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null }; return { mask: null, strokeLayer: null };
} }
let feather = mask.params.feather; const { body } = definition.renderer;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); const usesOpaqueFastPath =
const shouldRenderMaskDirectly = body.kind === "drawWithFeather" &&
canRenderMaskDirectly && mask.params.feather === 0 &&
(!definition.renderer.buildPath || Boolean(body.opaqueFastPath);
(mask.params.feather > 0 && const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather;
definition.renderer.renderMaskHandlesFeather));
if (
shouldRenderMaskDirectly &&
definition.renderer.renderMaskHandlesFeather
) {
feather = 0;
}
const maskTextureId = `${path}:mask`; const maskTextureId = `${path}:mask`;
const { width: canvasWidth, height: canvasHeight } = renderer; 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 drawMask: TextureCanvasDrawFn = (ctx) => {
const { canvas: elementMaskCanvas, context: elementMaskCtx } = const { canvas: elementMaskCanvas, context: elementMaskCtx } =
createCanvasSurface({ createCanvasSurface({
@@ -420,16 +413,28 @@ function buildMaskArtifacts({
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
if (shouldRenderMaskDirectly && definition.renderer.renderMask) { switch (body.kind) {
definition.renderer.renderMask({ 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, resolvedParams: mask.params,
ctx: elementMaskCtx, ctx: elementMaskCtx,
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
feather: mask.params.feather,
}); });
} else if (definition.renderer.buildPath) { break;
const path2d = definition.renderer.buildPath({ case "drawWithFeather":
if (usesOpaqueFastPath && body.opaqueFastPath) {
const path2d = body.opaqueFastPath.buildPath({
resolvedParams: mask.params, resolvedParams: mask.params,
width: transform.width, width: transform.width,
height: transform.height, height: transform.height,
@@ -437,7 +442,15 @@ function buildMaskArtifacts({
elementMaskCtx.fillStyle = "white"; elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d); elementMaskCtx.fill(path2d);
} else { } else {
return; 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 }); drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
@@ -451,45 +464,38 @@ function buildMaskArtifacts({
draw: drawMask, draw: drawMask,
}); });
const hasStroke = const stroke = definition.renderer.stroke;
mask.params.strokeWidth > 0 && const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke);
(definition.renderer.renderStroke ||
definition.renderer.buildStrokePath ||
definition.renderer.buildPath);
let strokeLayer: FrameItemDescriptor | null = null; let strokeLayer: FrameItemDescriptor | null = null;
if (hasStroke) { if (hasStroke && stroke) {
const strokeTextureId = `${path}:mask-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 drawStroke: TextureCanvasDrawFn = (ctx) => {
const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
if (definition.renderer.renderStroke) { switch (stroke.kind) {
definition.renderer.renderStroke({ case "renderStroke":
stroke.renderStroke({
resolvedParams: mask.params, resolvedParams: mask.params,
ctx: strokeCtx, ctx: strokeCtx,
width: transform.width, width: transform.width,
height: transform.height, height: transform.height,
}); });
} else { break;
const strokePath = case "strokeFromPath": {
definition.renderer.buildStrokePath?.({ const strokePath = stroke.buildStrokePath({
resolvedParams: mask.params, resolvedParams: mask.params,
width: transform.width, width: transform.width,
height: transform.height, 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.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth; strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath); strokeCtx.stroke(strokePath);
break;
}
} }
drawTransformedCanvas({ ctx, source: strokeCanvas, transform }); drawTransformedCanvas({ ctx, source: strokeCanvas, transform });