refactor: make mask renderer dispatch explicit
Made-with: Cursor
This commit is contained in:
parent
6ea5a156b6
commit
ae1292a14b
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue