优化蒙版

This commit is contained in:
iqudoo
2026-06-15 17:43:01 +08:00
parent 455946c51c
commit 25fa186d02
5 changed files with 373 additions and 148 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Upload, X } from 'lucide-react';
import type { ElementMaskConfig, TemplateElement } from '../types';
@@ -22,14 +22,16 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
onConfirm,
}) => {
const [draft, setDraft] = useState<ElementMaskConfig>({ enabled: true, type: 'rect' });
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
if (!open || !element) return;
setDraft(
element.mask?.enabled
const initial = element.mask?.enabled
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
: createDefaultElementMask(element)
);
: createDefaultElementMask(element);
draftRef.current = initial;
setDraft(initial);
}, [open, element]);
useEffect(() => {
@@ -52,16 +54,56 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
const canConfirm = maskType !== 'image' || !!draft.image?.trim();
const patchDraft = (patch: Partial<ElementMaskConfig>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDraft((prev) => {
const next = { ...prev, ...patch };
draftRef.current = next;
return next;
});
};
const patchDraftCorner = (key: keyof ElementMaskConfig, val: string) => {
if (val.trim() === '') {
setDraft((prev) => {
const next = { ...prev };
delete next[key];
draftRef.current = next;
return next;
});
return;
}
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
};
const normalizeDraft = (source: ElementMaskConfig): ElementMaskConfig => ({
...source,
enabled: true,
x: round1(Number(source.x) || 0),
y: round1(Number(source.y) || 0),
width: Math.max(0.5, round1(Number(source.width) || element.width)),
height: Math.max(0.5, round1(Number(source.height) || element.height)),
borderRadius: Math.max(0, round1(Number(source.borderRadius) || 0)),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const active = document.activeElement;
const submit = () => {
if (!canConfirm) return;
onConfirm({ ...draft, enabled: true });
onConfirm(normalizeDraft(draftRef.current));
onClose();
};
if (active instanceof HTMLElement && form.contains(active)) {
active.blur();
setTimeout(submit, 0);
return;
}
submit();
};
const handleClear = () => {
onConfirm(undefined);
onClose();
@@ -87,9 +129,25 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
<form onSubmit={handleSubmit}>
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={draft.mode ?? 'include'}
onChange={(e) =>
patchDraft({
mode: e.target.value as ElementMaskConfig['mode'],
})
}
className="ps-field text-[11px]"
>
<option value="include"></option>
<option value="exclude"></option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
@@ -135,7 +193,9 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
min="0.5"
value={regionW}
onCommit={(val) =>
patchDraft({ width: Math.max(0.5, round1(Number(val) || element.width)) })
patchDraft({
width: Math.max(0.5, round1(Number(val) || element.width)),
})
}
className="ps-field font-mono text-[11px]"
/>
@@ -148,7 +208,9 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
min="0.5"
value={regionH}
onCommit={(val) =>
patchDraft({ height: Math.max(0.5, round1(Number(val) || element.height)) })
patchDraft({
height: Math.max(0.5, round1(Number(val) || element.height)),
})
}
className="ps-field font-mono text-[11px]"
/>
@@ -188,15 +250,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
max="50"
step="0.5"
value={draft[key] ?? ''}
onCommit={(val) => {
if (val === '' || val === undefined) {
const next = { ...draft };
delete next[key];
setDraft(next);
return;
}
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
}}
onCommit={(val) => patchDraftCorner(key, val)}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
/>
@@ -226,7 +280,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
/>
</label>
<p className="text-[9px] text-[#666] leading-relaxed">
使 PNG
</p>
{draft.image && (
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
@@ -257,7 +311,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
)}
</div>
<div className="ps-modal-footer">
{element.mask?.enabled && (
{element.mask && element.mask.enabled !== false && (
<button
type="button"
onClick={handleClear}

View File

@@ -1636,9 +1636,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>
<div
data-drag-float-placeholder={el.id}
className={`absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]${
dragMultiSelectionBounds ? '' : ' border border-[#31a8ff]/40'
}`}
className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
/>
<img
ref={(node) => {
@@ -1672,6 +1670,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (placeholder) placeholder.style.display = 'none';
}}
/>
{selectedElementIds.length === 1 && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[3]"
aria-hidden
/>
)}
</div>
);
})}

View File

@@ -57,7 +57,8 @@ function drawImageWithFit(
}
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
if (!mask?.enabled) return false;
if (!mask) return false;
if (mask.enabled === false) return false;
if (mask.type === 'image') return !!mask.image?.trim();
return true;
}
@@ -66,6 +67,7 @@ export function createDefaultElementMask(elem: TemplateElement): ElementMaskConf
return {
enabled: true,
type: 'rect',
mode: 'include',
x: 0,
y: 0,
width: elem.width,
@@ -110,40 +112,168 @@ export function resolveMaskCornerRadiiPx(
};
}
/** 使用 destination-in 将已绘制元素裁剪到蒙版区域 */
export async function applyElementMask(
ctx: CanvasRenderingContext2D,
function resolveMaskRegionPx(
elem: TemplateElement,
ex: number,
ey: number,
scale: number,
mask: ElementMaskConfig
mask: ElementMaskConfig,
scale: number
) {
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));
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)),
};
}
ctx.save();
ctx.globalCompositeOperation = 'destination-in';
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) {
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();
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);
}
} else {
ctx.fillStyle = '#000';
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
ctx.fill();
return canvas;
}
ctx.restore();
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>;
/**
* 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层,应用蒙版后再合成到主画布。
* 仅对当前元素的 layer 回调内容生效,边框等装饰应在合成后单独绘制。
*/
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;

View File

@@ -27,7 +27,7 @@ import {
buildTableGridBoundariesPx,
getCellRectPx,
} from './tableUtils';
import { applyElementMask, isElementMaskActive } from './elementMask';
import { composeElementLayer, isElementMaskActive } from './elementMask';
const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -542,18 +542,20 @@ async function drawTableElement(
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const drawCellContent = async (
targetCtx: CanvasRenderingContext2D,
ox: number,
oy: number
) => {
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ox, oy);
const gridX = colBounds[0];
const gridY = rowBounds[0];
const gridW = colBounds[colBounds.length - 1] - gridX;
const gridH = rowBounds[rowBounds.length - 1] - gridY;
ctx.save();
ctx.beginPath();
ctx.rect(gridX, gridY, gridW, gridH);
ctx.clip();
targetCtx.save();
targetCtx.beginPath();
targetCtx.rect(gridX, gridY, gridW, gridH);
targetCtx.clip();
const anchors = iterTableCells(elem);
for (const { row, col } of anchors) {
@@ -562,14 +564,14 @@ async function drawTableElement(
row,
col,
scale,
ex,
ey
ox,
oy
);
const cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
ctx.fillStyle = cellData.backgroundColor;
ctx.fillRect(cellX, cellY, cellW, cellH);
targetCtx.fillStyle = cellData.backgroundColor;
targetCtx.fillRect(cellX, cellY, cellW, cellH);
}
const textElem = getEffectiveCellStyle(elem, row, col);
@@ -580,7 +582,7 @@ async function drawTableElement(
if (!displayText) continue;
drawTextContentInBox(
ctx,
targetCtx,
{
...textElem,
padding: 0,
@@ -598,7 +600,15 @@ async function drawTableElement(
);
}
ctx.restore();
targetCtx.restore();
};
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
await drawCellContent(layerCtx, ox, oy);
});
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
drawTableGridLines(ctx, elem, ex, ey, scale);
}
@@ -858,24 +868,23 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
ctx.save();
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
ctx.clip();
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
drawTextContentInBox(layerCtx, elem, ox, oy, ow, oh, String(value), scale);
});
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
ctx.restore();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const tempCanvas = document.createElement('canvas');
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
if (barcodeValue) {
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
if (!barcodeValue) return;
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale);
const tempCanvas = document.createElement('canvas');
JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128',
width: 2,
@@ -885,9 +894,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
margin: 2,
background: GENERATOR_TRANSPARENT_BG,
});
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} catch (error) {
console.warn('Barcode drawing failed', error);
if (mode === 'design') {
@@ -905,11 +915,14 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
} else if (elem.type === 'qrcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (qrValue) {
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
if (!qrValue) return;
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale);
const tempCanvas = document.createElement('canvas');
await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w),
@@ -919,9 +932,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
light: GENERATOR_TRANSPARENT_BG,
},
});
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} catch (error) {
console.warn('QR code drawing failed', error);
if (mode === 'design') {
@@ -942,6 +956,30 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue;
}
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
const hasMask = isElementMaskActive(elem.mask);
if (hasMask) {
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
const inner = getPaddedRectPx(ox, oy, ow, oh, elem, scale);
if (img) {
drawImageWithFit(
layerCtx,
img,
inner.x,
inner.y,
inner.w,
inner.h,
elem.imageFit ?? 'contain'
);
} else {
drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片');
}
});
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
if (img) {
@@ -949,6 +987,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} else {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
}
} else if (elem.type === 'table') {
await drawTableElement(
ctx,
@@ -965,10 +1004,6 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
);
}
if (elem.mask && isElementMaskActive(elem.mask)) {
await applyElementMask(ctx, elem, ex, ey, scale, elem.mask);
}
ctx.restore();
}

View File

@@ -243,6 +243,8 @@ export interface ElementMaskConfig {
image?: string;
/** 图片蒙版在区域内的适应方式 */
imageFit?: 'contain' | 'cover' | 'fill';
/** include=保留蒙版区域内exclude=保留区域外;默认 include */
mode?: 'include' | 'exclude';
}
export interface DataSourceMeta {