279 lines
7.1 KiB
TypeScript
279 lines
7.1 KiB
TypeScript
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) return false;
|
||
if (mask.enabled === false) return false;
|
||
if (mask.type === 'image') return !!mask.image?.trim();
|
||
return true;
|
||
}
|
||
|
||
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
|
||
return {
|
||
enabled: true,
|
||
type: 'rect',
|
||
mode: 'include',
|
||
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),
|
||
};
|
||
}
|
||
|
||
function resolveMaskRegionPx(
|
||
elem: TemplateElement,
|
||
mask: ElementMaskConfig,
|
||
scale: number
|
||
) {
|
||
const region = resolveMaskRegionMm(elem, mask);
|
||
return {
|
||
x: Math.round(region.x * scale),
|
||
y: Math.round(region.y * scale),
|
||
w: Math.max(1, Math.round(region.width * scale)),
|
||
h: Math.max(1, Math.round(region.height * scale)),
|
||
};
|
||
}
|
||
|
||
function fillRectMaskAlpha(
|
||
ctx: CanvasRenderingContext2D,
|
||
mx: number,
|
||
my: number,
|
||
mw: number,
|
||
mh: number,
|
||
mask: ElementMaskConfig,
|
||
scale: number
|
||
) {
|
||
ctx.fillStyle = '#ffffff';
|
||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
||
ctx.fill();
|
||
}
|
||
|
||
/** 将图片按亮度转为 alpha 蒙版(白=不透明,黑=透明) */
|
||
function buildImageLuminanceMaskCanvas(
|
||
img: HTMLImageElement,
|
||
w: number,
|
||
h: number,
|
||
fit: 'contain' | 'cover' | 'fill',
|
||
invert: boolean
|
||
): HTMLCanvasElement {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
if (!ctx) return canvas;
|
||
|
||
drawImageWithFit(ctx, img, 0, 0, w, h, fit);
|
||
const imageData = ctx.getImageData(0, 0, w, h);
|
||
const data = imageData.data;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||
const alpha = invert ? 255 - luminance : luminance;
|
||
data[i] = 255;
|
||
data[i + 1] = 255;
|
||
data[i + 2] = 255;
|
||
data[i + 3] = alpha;
|
||
}
|
||
ctx.putImageData(imageData, 0, 0);
|
||
return canvas;
|
||
}
|
||
|
||
/** 生成与元素同尺寸的蒙版 alpha 图(不透明区域保留图层像素) */
|
||
async function buildMaskAlphaCanvas(
|
||
elem: TemplateElement,
|
||
mask: ElementMaskConfig,
|
||
ew: number,
|
||
eh: number,
|
||
scale: number
|
||
): Promise<HTMLCanvasElement> {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = Math.max(1, ew);
|
||
canvas.height = Math.max(1, eh);
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return canvas;
|
||
|
||
const { x: mx, y: my, w: mw, h: mh } = resolveMaskRegionPx(elem, mask, scale);
|
||
const include = (mask.mode ?? 'include') === 'include';
|
||
|
||
if (mask.type === 'image' && mask.image) {
|
||
const img = await loadImageOrNull(mask.image);
|
||
if (img) {
|
||
const maskCanvas = buildImageLuminanceMaskCanvas(
|
||
img,
|
||
mw,
|
||
mh,
|
||
mask.imageFit ?? 'fill',
|
||
!include
|
||
);
|
||
ctx.drawImage(maskCanvas, mx, my);
|
||
} else if (include) {
|
||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||
}
|
||
return canvas;
|
||
}
|
||
|
||
if (include) {
|
||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||
return canvas;
|
||
}
|
||
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, ew, eh);
|
||
ctx.globalCompositeOperation = 'destination-out';
|
||
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
return canvas;
|
||
}
|
||
|
||
/** 对已绘制的图层离屏画布应用蒙版(destination-in) */
|
||
async function applyMaskToLayerCanvas(
|
||
layerCtx: CanvasRenderingContext2D,
|
||
elem: TemplateElement,
|
||
mask: ElementMaskConfig,
|
||
ew: number,
|
||
eh: number,
|
||
scale: number
|
||
) {
|
||
const alphaCanvas = await buildMaskAlphaCanvas(elem, mask, ew, eh, scale);
|
||
layerCtx.save();
|
||
layerCtx.globalCompositeOperation = 'destination-in';
|
||
layerCtx.drawImage(alphaCanvas, 0, 0);
|
||
layerCtx.restore();
|
||
}
|
||
|
||
type DrawLayerFn = (
|
||
ctx: CanvasRenderingContext2D,
|
||
x: number,
|
||
y: number,
|
||
w: number,
|
||
h: number
|
||
) => void | Promise<void>;
|
||
|
||
/**
|
||
* 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层(背景、内容、边框),应用蒙版后再合成到主画布。
|
||
*/
|
||
export async function composeElementLayer(
|
||
mainCtx: CanvasRenderingContext2D,
|
||
elem: TemplateElement,
|
||
ex: number,
|
||
ey: number,
|
||
ew: number,
|
||
eh: number,
|
||
scale: number,
|
||
drawLayer: DrawLayerFn
|
||
) {
|
||
const mask = isElementMaskActive(elem.mask) ? elem.mask : undefined;
|
||
|
||
if (!mask) {
|
||
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||
return;
|
||
}
|
||
|
||
const layerCanvas = document.createElement('canvas');
|
||
layerCanvas.width = Math.max(1, ew);
|
||
layerCanvas.height = Math.max(1, eh);
|
||
const layerCtx = layerCanvas.getContext('2d');
|
||
if (!layerCtx) {
|
||
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||
return;
|
||
}
|
||
|
||
await drawLayer(layerCtx, 0, 0, ew, eh);
|
||
await applyMaskToLayerCanvas(layerCtx, elem, mask, ew, eh, scale);
|
||
mainCtx.drawImage(layerCanvas, ex, ey);
|
||
}
|
||
|
||
/** @deprecated 使用 composeElementLayer */
|
||
export const drawElementContentWithMask = composeElementLayer;
|