优化蒙版
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 { createPortal } from 'react-dom';
|
||||||
import { Upload, X } from 'lucide-react';
|
import { Upload, X } from 'lucide-react';
|
||||||
import type { ElementMaskConfig, TemplateElement } from '../types';
|
import type { ElementMaskConfig, TemplateElement } from '../types';
|
||||||
@@ -22,14 +22,16 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
}) => {
|
}) => {
|
||||||
const [draft, setDraft] = useState<ElementMaskConfig>({ enabled: true, type: 'rect' });
|
const [draft, setDraft] = useState<ElementMaskConfig>({ enabled: true, type: 'rect' });
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !element) return;
|
if (!open || !element) return;
|
||||||
setDraft(
|
const initial = element.mask?.enabled
|
||||||
element.mask?.enabled
|
|
||||||
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
|
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
|
||||||
: createDefaultElementMask(element)
|
: createDefaultElementMask(element);
|
||||||
);
|
draftRef.current = initial;
|
||||||
|
setDraft(initial);
|
||||||
}, [open, element]);
|
}, [open, element]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,16 +54,56 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
const canConfirm = maskType !== 'image' || !!draft.image?.trim();
|
const canConfirm = maskType !== 'image' || !!draft.image?.trim();
|
||||||
|
|
||||||
const patchDraft = (patch: Partial<ElementMaskConfig>) => {
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget as HTMLFormElement;
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
if (!canConfirm) return;
|
if (!canConfirm) return;
|
||||||
onConfirm({ ...draft, enabled: true });
|
onConfirm(normalizeDraft(draftRef.current));
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (active instanceof HTMLElement && form.contains(active)) {
|
||||||
|
active.blur();
|
||||||
|
setTimeout(submit, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit();
|
||||||
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
onConfirm(undefined);
|
onConfirm(undefined);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -87,9 +129,25 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="ps-modal-body space-y-3">
|
<div className="ps-modal-body space-y-3">
|
||||||
<p className="text-[11px] text-[#888] leading-relaxed">
|
<p className="text-[11px] text-[#888] leading-relaxed">
|
||||||
蒙版用于裁剪图层可见区域,相对元素左上角定位;预览、导出与打印均生效。
|
蒙版仅作用于当前图层(背景与内容);先在离屏画布合成,再按蒙版裁剪后绘制到画布。边框不受蒙版影响。
|
||||||
</p>
|
</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>
|
<div>
|
||||||
<label className="ps-field-label">蒙版类型</label>
|
<label className="ps-field-label">蒙版类型</label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
@@ -135,7 +193,9 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
min="0.5"
|
min="0.5"
|
||||||
value={regionW}
|
value={regionW}
|
||||||
onCommit={(val) =>
|
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]"
|
className="ps-field font-mono text-[11px]"
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +208,9 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
min="0.5"
|
min="0.5"
|
||||||
value={regionH}
|
value={regionH}
|
||||||
onCommit={(val) =>
|
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]"
|
className="ps-field font-mono text-[11px]"
|
||||||
/>
|
/>
|
||||||
@@ -188,15 +250,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
max="50"
|
max="50"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={draft[key] ?? ''}
|
value={draft[key] ?? ''}
|
||||||
onCommit={(val) => {
|
onCommit={(val) => patchDraftCorner(key, val)}
|
||||||
if (val === '' || val === undefined) {
|
|
||||||
const next = { ...draft };
|
|
||||||
delete next[key];
|
|
||||||
setDraft(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
|
|
||||||
}}
|
|
||||||
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
@@ -226,7 +280,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||||
推荐使用带透明区域的 PNG;不透明区域将保留图层内容,透明区域将被裁剪。
|
按图片亮度控制蒙版:白色区域保留内容,黑色区域裁剪;反向模式下黑白互换。
|
||||||
</p>
|
</p>
|
||||||
{draft.image && (
|
{draft.image && (
|
||||||
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
|
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
|
||||||
@@ -257,7 +311,7 @@ export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ps-modal-footer">
|
<div className="ps-modal-footer">
|
||||||
{element.mask?.enabled && (
|
{element.mask && element.mask.enabled !== false && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
|
|||||||
@@ -1636,9 +1636,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-drag-float-placeholder={el.id}
|
data-drag-float-placeholder={el.id}
|
||||||
className={`absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]${
|
className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
|
||||||
dragMultiSelectionBounds ? '' : ' border border-[#31a8ff]/40'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
@@ -1672,6 +1670,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (placeholder) placeholder.style.display = 'none';
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ function drawImageWithFit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
|
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();
|
if (mask.type === 'image') return !!mask.image?.trim();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ export function createDefaultElementMask(elem: TemplateElement): ElementMaskConf
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: 'rect',
|
type: 'rect',
|
||||||
|
mode: 'include',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: elem.width,
|
width: elem.width,
|
||||||
@@ -110,40 +112,168 @@ export function resolveMaskCornerRadiiPx(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用 destination-in 将已绘制元素裁剪到蒙版区域 */
|
function resolveMaskRegionPx(
|
||||||
export async function applyElementMask(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
elem: TemplateElement,
|
elem: TemplateElement,
|
||||||
ex: number,
|
mask: ElementMaskConfig,
|
||||||
ey: number,
|
scale: number
|
||||||
scale: number,
|
|
||||||
mask: ElementMaskConfig
|
|
||||||
) {
|
) {
|
||||||
if (!isElementMaskActive(mask)) return;
|
|
||||||
|
|
||||||
const region = resolveMaskRegionMm(elem, mask);
|
const region = resolveMaskRegionMm(elem, mask);
|
||||||
const mx = ex + Math.round(region.x * scale);
|
return {
|
||||||
const my = ey + Math.round(region.y * scale);
|
x: Math.round(region.x * scale),
|
||||||
const mw = Math.max(1, Math.round(region.width * scale));
|
y: Math.round(region.y * scale),
|
||||||
const mh = Math.max(1, Math.round(region.height * scale));
|
w: Math.max(1, Math.round(region.width * scale)),
|
||||||
|
h: Math.max(1, Math.round(region.height * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ctx.save();
|
function fillRectMaskAlpha(
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
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) {
|
if (mask.type === 'image' && mask.image) {
|
||||||
const img = await loadImageOrNull(mask.image);
|
const img = await loadImageOrNull(mask.image);
|
||||||
if (img) {
|
if (img) {
|
||||||
drawImageWithFit(ctx, img, mx, my, mw, mh, mask.imageFit ?? 'fill');
|
const maskCanvas = buildImageLuminanceMaskCanvas(
|
||||||
} else {
|
img,
|
||||||
ctx.fillStyle = '#000';
|
mw,
|
||||||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
mh,
|
||||||
ctx.fill();
|
mask.imageFit ?? 'fill',
|
||||||
|
!include
|
||||||
|
);
|
||||||
|
ctx.drawImage(maskCanvas, mx, my);
|
||||||
|
} else if (include) {
|
||||||
|
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||||
}
|
}
|
||||||
} else {
|
return canvas;
|
||||||
ctx.fillStyle = '#000';
|
|
||||||
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
|
||||||
ctx.fill();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
buildTableGridBoundariesPx,
|
||||||
getCellRectPx,
|
getCellRectPx,
|
||||||
} from './tableUtils';
|
} from './tableUtils';
|
||||||
import { applyElementMask, isElementMaskActive } from './elementMask';
|
import { composeElementLayer, isElementMaskActive } from './elementMask';
|
||||||
|
|
||||||
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
||||||
|
|
||||||
@@ -542,18 +542,20 @@ async function drawTableElement(
|
|||||||
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||||
const sides = resolveBorderSides(elem);
|
const sides = resolveBorderSides(elem);
|
||||||
|
|
||||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
const drawCellContent = async (
|
||||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
targetCtx: CanvasRenderingContext2D,
|
||||||
|
ox: number,
|
||||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
|
oy: number
|
||||||
|
) => {
|
||||||
|
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ox, oy);
|
||||||
const gridX = colBounds[0];
|
const gridX = colBounds[0];
|
||||||
const gridY = rowBounds[0];
|
const gridY = rowBounds[0];
|
||||||
const gridW = colBounds[colBounds.length - 1] - gridX;
|
const gridW = colBounds[colBounds.length - 1] - gridX;
|
||||||
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
||||||
ctx.save();
|
targetCtx.save();
|
||||||
ctx.beginPath();
|
targetCtx.beginPath();
|
||||||
ctx.rect(gridX, gridY, gridW, gridH);
|
targetCtx.rect(gridX, gridY, gridW, gridH);
|
||||||
ctx.clip();
|
targetCtx.clip();
|
||||||
|
|
||||||
const anchors = iterTableCells(elem);
|
const anchors = iterTableCells(elem);
|
||||||
for (const { row, col } of anchors) {
|
for (const { row, col } of anchors) {
|
||||||
@@ -562,14 +564,14 @@ async function drawTableElement(
|
|||||||
row,
|
row,
|
||||||
col,
|
col,
|
||||||
scale,
|
scale,
|
||||||
ex,
|
ox,
|
||||||
ey
|
oy
|
||||||
);
|
);
|
||||||
|
|
||||||
const cellData = getCellData(elem, row, col);
|
const cellData = getCellData(elem, row, col);
|
||||||
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
|
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
|
||||||
ctx.fillStyle = cellData.backgroundColor;
|
targetCtx.fillStyle = cellData.backgroundColor;
|
||||||
ctx.fillRect(cellX, cellY, cellW, cellH);
|
targetCtx.fillRect(cellX, cellY, cellW, cellH);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textElem = getEffectiveCellStyle(elem, row, col);
|
const textElem = getEffectiveCellStyle(elem, row, col);
|
||||||
@@ -580,7 +582,7 @@ async function drawTableElement(
|
|||||||
if (!displayText) continue;
|
if (!displayText) continue;
|
||||||
|
|
||||||
drawTextContentInBox(
|
drawTextContentInBox(
|
||||||
ctx,
|
targetCtx,
|
||||||
{
|
{
|
||||||
...textElem,
|
...textElem,
|
||||||
padding: 0,
|
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);
|
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 radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||||
const sides = resolveBorderSides(elem);
|
const sides = resolveBorderSides(elem);
|
||||||
|
|
||||||
ctx.save();
|
await composeElementLayer(ctx, elem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
|
drawElementFill(layerCtx, ox, oy, ow, oh, elem, radii);
|
||||||
ctx.clip();
|
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);
|
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
||||||
} else if (elem.type === 'barcode') {
|
} else if (elem.type === 'barcode') {
|
||||||
try {
|
try {
|
||||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const sides = resolveBorderSides(elem);
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
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, {
|
JsBarcode(tempCanvas, barcodeValue, {
|
||||||
format: elem.barcodeFormat || 'CODE128',
|
format: elem.barcodeFormat || 'CODE128',
|
||||||
width: 2,
|
width: 2,
|
||||||
@@ -885,9 +894,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
margin: 2,
|
margin: 2,
|
||||||
background: GENERATOR_TRANSPARENT_BG,
|
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) {
|
} catch (error) {
|
||||||
console.warn('Barcode drawing failed', error);
|
console.warn('Barcode drawing failed', error);
|
||||||
if (mode === 'design') {
|
if (mode === 'design') {
|
||||||
@@ -905,11 +915,14 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
}
|
}
|
||||||
} else if (elem.type === 'qrcode') {
|
} else if (elem.type === 'qrcode') {
|
||||||
try {
|
try {
|
||||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const sides = resolveBorderSides(elem);
|
||||||
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
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');
|
const tempCanvas = document.createElement('canvas');
|
||||||
await QRCode.toCanvas(tempCanvas, qrValue, {
|
await QRCode.toCanvas(tempCanvas, qrValue, {
|
||||||
width: Math.max(64, inner.w),
|
width: Math.max(64, inner.w),
|
||||||
@@ -919,9 +932,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
light: GENERATOR_TRANSPARENT_BG,
|
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) {
|
} catch (error) {
|
||||||
console.warn('QR code drawing failed', error);
|
console.warn('QR code drawing failed', error);
|
||||||
if (mode === 'design') {
|
if (mode === 'design') {
|
||||||
@@ -942,6 +956,30 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
continue;
|
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);
|
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -949,6 +987,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
} else {
|
} else {
|
||||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (elem.type === 'table') {
|
} else if (elem.type === 'table') {
|
||||||
await drawTableElement(
|
await drawTableElement(
|
||||||
ctx,
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export interface ElementMaskConfig {
|
|||||||
image?: string;
|
image?: string;
|
||||||
/** 图片蒙版在区域内的适应方式 */
|
/** 图片蒙版在区域内的适应方式 */
|
||||||
imageFit?: 'contain' | 'cover' | 'fill';
|
imageFit?: 'contain' | 'cover' | 'fill';
|
||||||
|
/** include=保留蒙版区域内,exclude=保留区域外;默认 include */
|
||||||
|
mode?: 'include' | 'exclude';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataSourceMeta {
|
export interface DataSourceMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user