优化蒙版
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,8 @@ export interface ElementMaskConfig {
|
||||
image?: string;
|
||||
/** 图片蒙版在区域内的适应方式 */
|
||||
imageFit?: 'contain' | 'cover' | 'fill';
|
||||
/** include=保留蒙版区域内,exclude=保留区域外;默认 include */
|
||||
mode?: 'include' | 'exclude';
|
||||
}
|
||||
|
||||
export interface DataSourceMeta {
|
||||
|
||||
Reference in New Issue
Block a user