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;
|
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,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const centerX = width / 2 + params.centerX * width;
|
buildPath({ resolvedParams, width, height }) {
|
||||||
const centerY = height / 2 + params.centerY * height;
|
const params = resolvedParams;
|
||||||
const maskWidth = Math.max(params.width * width, width);
|
const centerX = width / 2 + params.centerX * width;
|
||||||
const maskHeight = Math.max(params.height, 0.01) * height;
|
const centerY = height / 2 + params.centerY * height;
|
||||||
const rotationRad = (params.rotation * Math.PI) / 180;
|
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({
|
return buildBandPath({
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
halfWidth: maskWidth / 2,
|
halfWidth: maskWidth / 2,
|
||||||
halfHeight: maskHeight / 2,
|
halfHeight: maskHeight / 2,
|
||||||
rotationRad,
|
rotationRad,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const centerX = width / 2 + params.centerX * width;
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
const centerY = height / 2 + params.centerY * height;
|
const params = resolvedParams;
|
||||||
const rotationRad = (params.rotation * Math.PI) / 180;
|
const centerX = width / 2 + params.centerX * width;
|
||||||
const offset = getStrokeOffset({
|
const centerY = height / 2 + params.centerY * height;
|
||||||
strokeAlign: params.strokeAlign,
|
const rotationRad = (params.rotation * Math.PI) / 180;
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
|
strokeWidth: params.strokeWidth,
|
||||||
|
});
|
||||||
|
|
||||||
return buildBandPath({
|
return buildBandPath({
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1),
|
halfWidth: Math.max(
|
||||||
halfHeight: Math.max(
|
Math.max(params.width * width, width) / 2 + offset,
|
||||||
(Math.max(params.height, 0.01) * height) / 2 + offset,
|
1,
|
||||||
1,
|
),
|
||||||
),
|
halfHeight: Math.max(
|
||||||
rotationRad,
|
(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",
|
type: "custom",
|
||||||
name: "Custom",
|
name: "Custom",
|
||||||
features: {
|
features: {
|
||||||
|
|
@ -447,71 +447,77 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
|
||||||
return params.closed;
|
return params.closed;
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as CustomMaskParams;
|
kind: "fillPath",
|
||||||
const points = params.path;
|
buildPath({ resolvedParams, width, height }) {
|
||||||
if (!params.closed) {
|
const params = resolvedParams;
|
||||||
return new Path2D();
|
const points = params.path;
|
||||||
}
|
if (!params.closed) {
|
||||||
|
return new Path2D();
|
||||||
|
}
|
||||||
|
|
||||||
return buildCustomMaskPath2D({
|
return buildCustomMaskPath2D({
|
||||||
points,
|
points,
|
||||||
centerX: params.centerX,
|
centerX: params.centerX,
|
||||||
centerY: params.centerY,
|
centerY: params.centerY,
|
||||||
rotation: params.rotation,
|
rotation: params.rotation,
|
||||||
scale: params.scale,
|
scale: params.scale,
|
||||||
bounds: {
|
bounds: {
|
||||||
cx: width / 2,
|
cx: width / 2,
|
||||||
cy: height / 2,
|
cy: height / 2,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
closed: true,
|
closed: true,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
renderStroke({ resolvedParams, ctx, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as CustomMaskParams;
|
kind: "renderStroke",
|
||||||
if (!params.closed) {
|
renderStroke({ resolvedParams, ctx, width, height }) {
|
||||||
return;
|
const params = resolvedParams;
|
||||||
}
|
if (!params.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const points = params.path;
|
const points = params.path;
|
||||||
const path = buildCustomMaskPath2D({
|
const path = buildCustomMaskPath2D({
|
||||||
points,
|
points,
|
||||||
centerX: params.centerX,
|
centerX: params.centerX,
|
||||||
centerY: params.centerY,
|
centerY: params.centerY,
|
||||||
rotation: params.rotation,
|
rotation: params.rotation,
|
||||||
scale: params.scale,
|
scale: params.scale,
|
||||||
bounds: {
|
bounds: {
|
||||||
cx: width / 2,
|
cx: width / 2,
|
||||||
cy: height / 2,
|
cy: height / 2,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
},
|
},
|
||||||
closed: true,
|
closed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = params.strokeColor;
|
ctx.strokeStyle = params.strokeColor;
|
||||||
ctx.lineWidth = params.strokeWidth;
|
ctx.lineWidth = params.strokeWidth;
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.stroke(path);
|
ctx.stroke(path);
|
||||||
|
|
||||||
if (params.strokeAlign === "inside") {
|
if (params.strokeAlign === "inside") {
|
||||||
ctx.globalCompositeOperation = "destination-in";
|
ctx.globalCompositeOperation = "destination-in";
|
||||||
ctx.fillStyle = "#ffffff";
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.fill(path);
|
ctx.fill(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.strokeAlign === "outside") {
|
if (params.strokeAlign === "outside") {
|
||||||
ctx.globalCompositeOperation = "destination-out";
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
ctx.fillStyle = "#ffffff";
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.fill(path);
|
ctx.fill(path);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildPath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
return buildDiamondPath({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerX,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerY,
|
return buildDiamondPath({
|
||||||
halfWidth: maskWidth / 2,
|
centerX,
|
||||||
halfHeight: maskHeight / 2,
|
centerY,
|
||||||
rotationRad,
|
halfWidth: maskWidth / 2,
|
||||||
});
|
halfHeight: maskHeight / 2,
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const offset = getStrokeOffset({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeAlign: params.strokeAlign,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
return buildDiamondPath({
|
strokeWidth: params.strokeWidth,
|
||||||
centerX,
|
});
|
||||||
centerY,
|
return buildDiamondPath({
|
||||||
halfWidth: Math.max(maskWidth / 2 + offset, 1),
|
centerX,
|
||||||
halfHeight: Math.max(maskHeight / 2 + offset, 1),
|
centerY,
|
||||||
rotationRad,
|
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 {
|
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,41 +35,47 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildPath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const path = new Path2D();
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
path.ellipse(
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerX,
|
const path = new Path2D();
|
||||||
centerY,
|
path.ellipse(
|
||||||
maskWidth / 2,
|
centerX,
|
||||||
maskHeight / 2,
|
centerY,
|
||||||
rotationRad,
|
maskWidth / 2,
|
||||||
0,
|
maskHeight / 2,
|
||||||
Math.PI * 2,
|
rotationRad,
|
||||||
);
|
0,
|
||||||
return path;
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const offset = getStrokeOffset({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeAlign: params.strokeAlign,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
const path = new Path2D();
|
strokeWidth: params.strokeWidth,
|
||||||
path.ellipse(
|
});
|
||||||
centerX,
|
const path = new Path2D();
|
||||||
centerY,
|
path.ellipse(
|
||||||
Math.max(1, maskWidth / 2 + offset),
|
centerX,
|
||||||
Math.max(1, maskHeight / 2 + offset),
|
centerY,
|
||||||
rotationRad,
|
Math.max(1, maskWidth / 2 + offset),
|
||||||
0,
|
Math.max(1, maskHeight / 2 + offset),
|
||||||
Math.PI * 2,
|
rotationRad,
|
||||||
);
|
0,
|
||||||
return path;
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildPath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
return buildHeartPath({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerX,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerY,
|
return buildHeartPath({
|
||||||
halfWidth: maskWidth / 2,
|
centerX,
|
||||||
halfHeight: maskHeight / 2,
|
centerY,
|
||||||
rotationRad,
|
halfWidth: maskWidth / 2,
|
||||||
});
|
halfHeight: maskHeight / 2,
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const offset = getStrokeOffset({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeAlign: params.strokeAlign,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
return buildHeartPath({
|
strokeWidth: params.strokeWidth,
|
||||||
centerX,
|
});
|
||||||
centerY,
|
return buildHeartPath({
|
||||||
halfWidth: Math.max(maskWidth / 2 + offset, 1),
|
centerX,
|
||||||
halfHeight: Math.max(maskHeight / 2 + offset, 1),
|
centerY,
|
||||||
rotationRad,
|
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 {
|
||||||
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)) {
|
||||||
|
|
|
||||||
|
|
@ -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,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildPath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
return buildRectanglePath({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerX,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerY,
|
return buildRectanglePath({
|
||||||
halfWidth: maskWidth / 2,
|
centerX,
|
||||||
halfHeight: maskHeight / 2,
|
centerY,
|
||||||
rotationRad,
|
halfWidth: maskWidth / 2,
|
||||||
});
|
halfHeight: maskHeight / 2,
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const offset = getStrokeOffset({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeAlign: params.strokeAlign,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
return buildRectanglePath({
|
strokeWidth: params.strokeWidth,
|
||||||
centerX,
|
});
|
||||||
centerY,
|
return buildRectanglePath({
|
||||||
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
centerX,
|
||||||
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
centerY,
|
||||||
rotationRad,
|
halfWidth: Math.max(1, maskWidth / 2 + offset),
|
||||||
});
|
halfHeight: Math.max(1, maskHeight / 2 + offset),
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,124 +267,131 @@ 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 { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
const { centerX, centerY, rotation } = resolvedParams;
|
||||||
centerX,
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||||
centerY,
|
centerX,
|
||||||
rotation,
|
centerY,
|
||||||
width,
|
rotation,
|
||||||
height,
|
width,
|
||||||
});
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
||||||
const featherHalf = feather / 2;
|
const featherHalf = feather / 2;
|
||||||
const gradient = ctx.createLinearGradient(
|
const gradient = ctx.createLinearGradient(
|
||||||
lineX - normalX * featherHalf,
|
lineX - normalX * featherHalf,
|
||||||
lineY - normalY * featherHalf,
|
lineY - normalY * featherHalf,
|
||||||
lineX + normalX * featherHalf,
|
lineX + normalX * featherHalf,
|
||||||
lineY + normalY * featherHalf,
|
lineY + normalY * featherHalf,
|
||||||
);
|
);
|
||||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||||
gradient.addColorStop(1, "white");
|
gradient.addColorStop(1, "white");
|
||||||
|
|
||||||
ctx.fillStyle = gradient;
|
ctx.fillStyle = gradient;
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
},
|
},
|
||||||
|
|
||||||
buildPath({ resolvedParams, width, height }) {
|
opaqueFastPath: {
|
||||||
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
|
buildPath({ resolvedParams, width, height }) {
|
||||||
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
const { centerX, centerY, rotation } = resolvedParams;
|
||||||
centerX,
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
||||||
centerY,
|
centerX,
|
||||||
rotation,
|
centerY,
|
||||||
width,
|
rotation,
|
||||||
height,
|
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,
|
|
||||||
});
|
});
|
||||||
if (hit) vertices.push([hit.x, hit.y]);
|
|
||||||
} else if (!isVertex1Inside && isVertex2Inside) {
|
const edges: [number, number, number, number][] = [
|
||||||
const hit = lineEdgeIntersection({
|
[0, 0, width, 0],
|
||||||
lineX,
|
[width, 0, width, height],
|
||||||
lineY,
|
[width, height, 0, height],
|
||||||
normalX,
|
[0, height, 0, 0],
|
||||||
normalY,
|
];
|
||||||
x1,
|
|
||||||
y1,
|
const isInsideHalfPlane = ({
|
||||||
x2,
|
x,
|
||||||
y2,
|
y,
|
||||||
});
|
}: {
|
||||||
if (hit) {
|
x: number;
|
||||||
vertices.push([hit.x, hit.y]);
|
y: number;
|
||||||
vertices.push([x2, y2]);
|
}) => 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 (
|
if (
|
||||||
vertices.length < 3 ||
|
vertices.length < 3 ||
|
||||||
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
||||||
) {
|
) {
|
||||||
return new Path2D();
|
return new Path2D();
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = new Path2D();
|
const path = new Path2D();
|
||||||
path.moveTo(vertices[0][0], vertices[0][1]);
|
path.moveTo(vertices[0][0], vertices[0][1]);
|
||||||
for (let i = 1; i < vertices.length; i++) {
|
for (let i = 1; i < vertices.length; i++) {
|
||||||
path.lineTo(vertices[i][0], vertices[i][1]);
|
path.lineTo(vertices[i][0], vertices[i][1]);
|
||||||
}
|
}
|
||||||
path.closePath();
|
path.closePath();
|
||||||
return path;
|
return path;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const segment = getSplitMaskStrokeSegment({
|
kind: "strokeFromPath",
|
||||||
resolvedParams,
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
width,
|
const segment = getSplitMaskStrokeSegment({
|
||||||
height,
|
resolvedParams,
|
||||||
});
|
width,
|
||||||
const path = new Path2D();
|
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;
|
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 {
|
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,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
|
||||||
},
|
},
|
||||||
computeParamUpdate: computeBoxMaskParamUpdate,
|
computeParamUpdate: computeBoxMaskParamUpdate,
|
||||||
renderer: {
|
renderer: {
|
||||||
buildPath({ resolvedParams, width, height }) {
|
body: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "fillPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildPath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
return buildStarPath({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
centerX,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
centerY,
|
return buildStarPath({
|
||||||
halfWidth: maskWidth / 2,
|
centerX,
|
||||||
halfHeight: maskHeight / 2,
|
centerY,
|
||||||
rotationRad,
|
halfWidth: maskWidth / 2,
|
||||||
});
|
halfHeight: maskHeight / 2,
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
buildStrokePath({ resolvedParams, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as RectangleMaskParams;
|
kind: "strokeFromPath",
|
||||||
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
buildStrokePath({ resolvedParams, width, height }) {
|
||||||
getBoxLikeGeometry({ params, width, height });
|
const params = resolvedParams;
|
||||||
const offset = getStrokeOffset({
|
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
|
||||||
strokeAlign: params.strokeAlign,
|
getBoxLikeGeometry({ params, width, height });
|
||||||
strokeWidth: params.strokeWidth,
|
const offset = getStrokeOffset({
|
||||||
});
|
strokeAlign: params.strokeAlign,
|
||||||
return buildStarPath({
|
strokeWidth: params.strokeWidth,
|
||||||
centerX,
|
});
|
||||||
centerY,
|
return buildStarPath({
|
||||||
halfWidth: Math.max(maskWidth / 2 + offset, 1),
|
centerX,
|
||||||
halfHeight: Math.max(maskHeight / 2 + offset, 1),
|
centerY,
|
||||||
rotationRad,
|
halfWidth: Math.max(maskWidth / 2 + offset, 1),
|
||||||
});
|
halfHeight: Math.max(maskHeight / 2 + offset, 1),
|
||||||
|
rotationRad,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,69 +370,75 @@ 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",
|
||||||
const { layout } = measureTextMask({ params, height });
|
drawOpaque({ resolvedParams, ctx, width, height }) {
|
||||||
|
const params = resolvedParams;
|
||||||
|
const { layout } = measureTextMask({ params, height });
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(
|
ctx.translate(
|
||||||
width / 2 + params.centerX * width,
|
width / 2 + params.centerX * width,
|
||||||
height / 2 + params.centerY * height,
|
height / 2 + params.centerY * height,
|
||||||
);
|
);
|
||||||
ctx.scale(params.scale, params.scale);
|
ctx.scale(params.scale, params.scale);
|
||||||
if (params.rotation) {
|
if (params.rotation) {
|
||||||
ctx.rotate((params.rotation * Math.PI) / 180);
|
ctx.rotate((params.rotation * Math.PI) / 180);
|
||||||
}
|
}
|
||||||
drawMeasuredTextLayout({
|
drawMeasuredTextLayout({
|
||||||
ctx,
|
ctx,
|
||||||
layout,
|
layout,
|
||||||
textColor: "#ffffff",
|
textColor: "#ffffff",
|
||||||
background: null,
|
background: null,
|
||||||
});
|
});
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
renderStroke({ resolvedParams, ctx, width, height }) {
|
stroke: {
|
||||||
const params = resolvedParams as TextMaskParams;
|
kind: "renderStroke",
|
||||||
const { layout } = measureTextMask({ params, height });
|
renderStroke({ resolvedParams, ctx, width, height }) {
|
||||||
|
const params = resolvedParams;
|
||||||
|
const { layout } = measureTextMask({ params, height });
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(
|
ctx.translate(
|
||||||
width / 2 + params.centerX * width,
|
width / 2 + params.centerX * width,
|
||||||
height / 2 + params.centerY * height,
|
height / 2 + params.centerY * height,
|
||||||
);
|
);
|
||||||
ctx.scale(params.scale, params.scale);
|
ctx.scale(params.scale, params.scale);
|
||||||
if (params.rotation) {
|
if (params.rotation) {
|
||||||
ctx.rotate((params.rotation * Math.PI) / 180);
|
ctx.rotate((params.rotation * Math.PI) / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
strokeMeasuredTextLayout({
|
strokeMeasuredTextLayout({
|
||||||
ctx,
|
|
||||||
layout,
|
|
||||||
strokeColor: params.strokeColor,
|
|
||||||
strokeWidth: params.strokeWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (params.strokeAlign === "inside") {
|
|
||||||
ctx.globalCompositeOperation = "destination-in";
|
|
||||||
drawMeasuredTextLayout({
|
|
||||||
ctx,
|
ctx,
|
||||||
layout,
|
layout,
|
||||||
textColor: "#ffffff",
|
strokeColor: params.strokeColor,
|
||||||
background: null,
|
strokeWidth: params.strokeWidth,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (params.strokeAlign === "outside") {
|
if (params.strokeAlign === "inside") {
|
||||||
ctx.globalCompositeOperation = "destination-out";
|
ctx.globalCompositeOperation = "destination-in";
|
||||||
drawMeasuredTextLayout({
|
drawMeasuredTextLayout({
|
||||||
ctx,
|
ctx,
|
||||||
layout,
|
layout,
|
||||||
textColor: "#ffffff",
|
textColor: "#ffffff",
|
||||||
background: null,
|
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 type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
|
||||||
import { masksRegistry } from "./registry";
|
import { masksRegistry } from "./registry";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
||||||
export { masksRegistry } from "./registry";
|
export { masksRegistry } from "./registry";
|
||||||
export { registerDefaultMasks } from "./definitions";
|
export { registerDefaultMasks } from "./definitions";
|
||||||
|
|
||||||
export function buildDefaultMaskInstance({
|
type MaskWithoutId = Mask extends infer TMask
|
||||||
maskType,
|
? TMask extends Mask
|
||||||
elementSize,
|
? Omit<TMask, "id">
|
||||||
}: {
|
: never
|
||||||
maskType: MaskType;
|
: never;
|
||||||
elementSize?: { width: number; height: number };
|
|
||||||
}): Mask {
|
function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask {
|
||||||
const definition = masksRegistry.get(maskType);
|
switch (mask.type) {
|
||||||
const context: MaskDefaultContext = { elementSize };
|
case "split":
|
||||||
return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
|
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 { 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({
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
width: number;
|
|
||||||
height: number;
|
type MaskPathArgs<TParams extends BaseMaskParams> = {
|
||||||
}) => Path2D;
|
resolvedParams: TParams;
|
||||||
buildStrokePath?: (params: {
|
width: number;
|
||||||
resolvedParams: unknown;
|
height: number;
|
||||||
width: number;
|
};
|
||||||
height: number;
|
|
||||||
}) => Path2D;
|
type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & {
|
||||||
/** Renders the feathered mask directly onto ctx, bypassing JFA. */
|
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||||
renderMask?: (params: {
|
};
|
||||||
resolvedParams: unknown;
|
|
||||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> =
|
||||||
width: number;
|
| {
|
||||||
height: number;
|
kind: "fillPath";
|
||||||
feather: number;
|
buildPath(args: MaskPathArgs<TParams>): Path2D;
|
||||||
}) => void;
|
}
|
||||||
renderMaskHandlesFeather?: boolean;
|
| {
|
||||||
renderStroke?: (params: {
|
kind: "drawOpaque";
|
||||||
resolvedParams: unknown;
|
drawOpaque(args: MaskDrawArgs<TParams>): void;
|
||||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
}
|
||||||
width: number;
|
| {
|
||||||
height: number;
|
kind: "drawWithFeather";
|
||||||
}) => void;
|
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,24 +413,44 @@ 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": {
|
||||||
resolvedParams: mask.params,
|
const path2d = body.buildPath({
|
||||||
ctx: elementMaskCtx,
|
resolvedParams: mask.params,
|
||||||
width: Math.round(transform.width),
|
width: transform.width,
|
||||||
height: Math.round(transform.height),
|
height: transform.height,
|
||||||
feather: mask.params.feather,
|
});
|
||||||
});
|
elementMaskCtx.fillStyle = "white";
|
||||||
} else if (definition.renderer.buildPath) {
|
elementMaskCtx.fill(path2d);
|
||||||
const path2d = definition.renderer.buildPath({
|
break;
|
||||||
resolvedParams: mask.params,
|
}
|
||||||
width: transform.width,
|
case "drawOpaque":
|
||||||
height: transform.height,
|
body.drawOpaque({
|
||||||
});
|
resolvedParams: mask.params,
|
||||||
elementMaskCtx.fillStyle = "white";
|
ctx: elementMaskCtx,
|
||||||
elementMaskCtx.fill(path2d);
|
width: Math.round(transform.width),
|
||||||
} else {
|
height: Math.round(transform.height),
|
||||||
return;
|
});
|
||||||
|
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 });
|
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":
|
||||||
resolvedParams: mask.params,
|
stroke.renderStroke({
|
||||||
ctx: strokeCtx,
|
resolvedParams: mask.params,
|
||||||
width: transform.width,
|
ctx: strokeCtx,
|
||||||
height: transform.height,
|
width: transform.width,
|
||||||
});
|
height: transform.height,
|
||||||
} else {
|
});
|
||||||
const strokePath =
|
break;
|
||||||
definition.renderer.buildStrokePath?.({
|
case "strokeFromPath": {
|
||||||
|
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?.({
|
strokeCtx.strokeStyle = mask.params.strokeColor;
|
||||||
resolvedParams: mask.params,
|
strokeCtx.lineWidth = mask.params.strokeWidth;
|
||||||
width: transform.width,
|
strokeCtx.stroke(strokePath);
|
||||||
height: transform.height,
|
break;
|
||||||
}) ??
|
}
|
||||||
null;
|
|
||||||
if (!strokePath) return;
|
|
||||||
strokeCtx.strokeStyle = mask.params.strokeColor;
|
|
||||||
strokeCtx.lineWidth = mask.params.strokeWidth;
|
|
||||||
strokeCtx.stroke(strokePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
|
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue