蒙版
This commit is contained in:
149
src/elementMask.ts
Normal file
149
src/elementMask.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { ElementMaskConfig, TemplateElement } from './types';
|
||||
import { traceRoundedRect, type CornerRadiiPx } from './elementBox';
|
||||
|
||||
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = trimmed;
|
||||
});
|
||||
}
|
||||
|
||||
function drawImageWithFit(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
fit: 'contain' | 'cover' | 'fill' = 'contain'
|
||||
) {
|
||||
if (fit === 'fill') {
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
return;
|
||||
}
|
||||
const imgRatio = img.width / img.height;
|
||||
const boxRatio = w / h;
|
||||
if (fit === 'contain') {
|
||||
let dw = w;
|
||||
let dh = h;
|
||||
let dx = x;
|
||||
let dy = y;
|
||||
if (imgRatio > boxRatio) {
|
||||
dh = w / imgRatio;
|
||||
dy = y + (h - dh) / 2;
|
||||
} else {
|
||||
dw = h * imgRatio;
|
||||
dx = x + (w - dw) / 2;
|
||||
}
|
||||
ctx.drawImage(img, dx, dy, dw, dh);
|
||||
return;
|
||||
}
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = img.width;
|
||||
let sh = img.height;
|
||||
if (imgRatio > boxRatio) {
|
||||
sw = img.height * boxRatio;
|
||||
sx = (img.width - sw) / 2;
|
||||
} else {
|
||||
sh = img.width / boxRatio;
|
||||
sy = (img.height - sh) / 2;
|
||||
}
|
||||
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
|
||||
}
|
||||
|
||||
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
|
||||
if (!mask?.enabled) return false;
|
||||
if (mask.type === 'image') return !!mask.image?.trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
type: 'rect',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: elem.width,
|
||||
height: elem.height,
|
||||
borderRadius: 0,
|
||||
imageFit: 'fill',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMaskRegionMm(
|
||||
elem: TemplateElement,
|
||||
mask: ElementMaskConfig
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
return {
|
||||
x: mask.x ?? 0,
|
||||
y: mask.y ?? 0,
|
||||
width: mask.width ?? elem.width,
|
||||
height: mask.height ?? elem.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMaskCornerRadiiPx(
|
||||
mask: ElementMaskConfig,
|
||||
scale: number,
|
||||
w: number,
|
||||
h: number
|
||||
): CornerRadiiPx {
|
||||
const maxR = Math.min(w, h) / 2;
|
||||
const baseMm = mask.borderRadius ?? 0;
|
||||
|
||||
const radiusFor = (specificMm?: number): number => {
|
||||
const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm;
|
||||
if (mm <= 0) return 0;
|
||||
return Math.min(maxR, mm * scale);
|
||||
};
|
||||
|
||||
return {
|
||||
tl: radiusFor(mask.borderRadiusTL),
|
||||
tr: radiusFor(mask.borderRadiusTR),
|
||||
br: radiusFor(mask.borderRadiusBR),
|
||||
bl: radiusFor(mask.borderRadiusBL),
|
||||
};
|
||||
}
|
||||
|
||||
/** 使用 destination-in 将已绘制元素裁剪到蒙版区域 */
|
||||
export async function applyElementMask(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
elem: TemplateElement,
|
||||
ex: number,
|
||||
ey: number,
|
||||
scale: number,
|
||||
mask: ElementMaskConfig
|
||||
) {
|
||||
if (!isElementMaskActive(mask)) return;
|
||||
|
||||
const region = resolveMaskRegionMm(elem, mask);
|
||||
const mx = ex + Math.round(region.x * scale);
|
||||
const my = ey + Math.round(region.y * scale);
|
||||
const mw = Math.max(1, Math.round(region.width * scale));
|
||||
const mh = Math.max(1, Math.round(region.height * scale));
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
|
||||
if (mask.type === 'image' && mask.image) {
|
||||
const img = await loadImageOrNull(mask.image);
|
||||
if (img) {
|
||||
drawImageWithFit(ctx, img, mx, my, mw, mh, mask.imageFit ?? 'fill');
|
||||
} else {
|
||||
ctx.fillStyle = '#000';
|
||||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
||||
ctx.fill();
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#000';
|
||||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user