Files
label-designer/src/elementMask.ts
iqudoo 3a50e7e33b fix
2026-06-16 23:41:27 +08:00

279 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;