This commit is contained in:
iqudoo
2026-06-12 20:58:51 +08:00
parent c9a187bf9b
commit 239e2eeebd
9 changed files with 499 additions and 340 deletions

View File

@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<title>智能标签排版设计与打印生成器</title>
</head>
<body>

View File

@@ -26,7 +26,6 @@ import { useAppDialog } from './components/AppDialog';
import {
FileDown,
Code,
Wand2,
Loader2,
Plus,
Download,
@@ -34,6 +33,7 @@ import {
Trash2,
X,
} from 'lucide-react';
import logoUrl from './assets/logo.svg';
const TEMPLATE_PREVIEW_MAX_W = 220;
const TEMPLATE_PREVIEW_MAX_H = 132;
@@ -604,8 +604,7 @@ export default function App() {
return (
<div
className={`min-h-screen flex flex-col font-sans ${
workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
className={`min-h-screen flex flex-col font-sans ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile)
? 'bg-[#323232] text-[#e8e8e8] h-screen overflow-hidden'
: workflowStep === 'print-view' && isMobile
? 'bg-slate-100 text-slate-800 h-screen overflow-hidden'
@@ -619,23 +618,19 @@ export default function App() {
*/}
{!(isMobile && workflowStep === 'print-view') && (
<header
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${
workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : 'px-4 py-3 md:px-6 md:py-4'
className={`no-print bg-slate-950 border-b border-slate-900 shrink-0 shadow flex flex-wrap items-center justify-between gap-4 mobile-template-header ${workflowStep === 'template-design' || workflowStep === 'print-view' ? 'px-4 py-2' : 'px-4 py-3 md:px-6 md:py-4'
}`}
>
{/* Title logo and slogan description */}
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 md:p-2.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 rounded-xl shadow-inner shrink-0">
<Wand2 className="w-5 h-5" />
</div>
<img src={logoUrl} alt="" className="w-10 h-10" aria-hidden />
<div className="min-w-0">
<h1 className="text-sm font-bold tracking-wider flex items-center gap-2 text-slate-100 uppercase">
<span className="truncate">{isMobile ? '批量标签排版印刷生成器' : '批量标签排版印刷生成器'}</span>
<span className="text-[9.5px] bg-indigo-600 text-white font-extrabold px-1.5 py-0.5 rounded shadow-sm tracking-normal shrink-0">PDF</span>
<span className="truncate"></span>
</h1>
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && !isMobile && (
{workflowStep !== 'template-design' && workflowStep !== 'print-view' && (
<p className="text-[11px] text-slate-400">
Excel A4
Excel批量排版生成标签
</p>
)}
</div>
@@ -668,8 +663,7 @@ export default function App() {
{/* Primary Workspace scroll container */}
<main
className={`no-print flex-1 flex flex-col w-full ${
workflowStep === 'template-design' || workflowStep === 'print-view'
className={`no-print flex-1 flex flex-col w-full ${workflowStep === 'template-design' || workflowStep === 'print-view'
? 'overflow-hidden min-h-0'
: 'overflow-auto p-4 md:p-6 max-w-[1720px] mx-auto gap-6'
}`}
@@ -691,16 +685,16 @@ export default function App() {
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-16 md:py-24">
<div className="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-500 flex items-center justify-center mb-5 border border-indigo-100">
<Wand2 className="w-8 h-8" />
</div>
{/* <div className="w-16 h-16 rounded-2xl bg-indigo-50 flex items-center justify-center mb-5 border border-indigo-100">
<img src={logoUrl} alt="" className="w-12 h-12" aria-hidden />
</div> */}
<h2 className="text-lg font-bold text-gray-900"></h2>
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
{isMobile
? '导入已有模板,或电脑端进行设计。'
? '导入已有模板,或电脑端设计标签模板'
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
{!isMobile && (
<button
type="button"
@@ -738,8 +732,7 @@ export default function App() {
<div
key={tmpl.id}
onClick={() => handleSelectTemplate(tmpl.id!)}
className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-hidden group select-none ${
isActive
className={`bg-white border rounded-2xl p-5 shadow-xs transition-all flex flex-col cursor-pointer relative overflow-hidden group select-none ${isActive
? 'ring-2 ring-indigo-500 border-indigo-500 bg-indigo-50/5'
: 'border-gray-200 hover:shadow-md hover:border-indigo-200'
}`}
@@ -807,8 +800,7 @@ export default function App() {
e.stopPropagation();
openPrintView(tmpl.id!);
}}
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${
isMobile ? 'mobile-primary-btn flex-1' : ''
className={`shrink-0 whitespace-nowrap inline-flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold transition shadow-xs cursor-pointer ${isMobile ? 'mobile-primary-btn flex-1' : ''
}`}
>
<FileDown className="w-3.5 h-3.5 shrink-0" />
@@ -993,8 +985,7 @@ export default function App() {
disabled={
!appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating
}
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${
appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed'
}`}

1
src/assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg t="1781266539277" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9515" width="200" height="200"><path d="M690.2528 140.8h-356.608C297.984 140.8 268.8 170.9568 268.8 207.7952V294.4h486.4V207.7952c-0.1024-36.864-29.2864-66.9952-64.9472-66.9952z" fill="#B3DBFF" p-id="9516"></path><path d="M786.6624 268.8H237.2608c-39.168 0-70.8608 31.1296-70.8608 69.6064v296.0128c0 38.4512 31.6928 69.5808 70.8608 69.5808h549.504c39.1424 0 70.8352-31.1296 70.8352-69.5808V338.304c-0.1024-38.3744-31.7952-69.504-70.9376-69.504z" fill="#0084FF" p-id="9517"></path><path d="M268.8 810.2656c0 40.1152 35.4816 72.9344 78.7968 72.9344h328.8064c43.3152 0 78.7968-32.8192 78.7968-72.9344V678.4h-486.4v131.8656z" fill="#B3DBFF" p-id="9518"></path><path d="M204.8 358.4m25.6 0l128 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-128 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#FFFFFF" p-id="9519"></path><path d="M384 716.8m25.6 0l204.8 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-204.8 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#0084FF" p-id="9520"></path><path d="M384 793.6m25.6 0l204.8 0q25.6 0 25.6 25.6l0 0q0 25.6-25.6 25.6l-204.8 0q-25.6 0-25.6-25.6l0 0q0-25.6 25.6-25.6Z" fill="#0084FF" p-id="9521"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -10,14 +10,12 @@ import {
} from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import { toggleSide, isSideEnabled } from '../elementBox';
import {
alignToSelectionBounds,
alignSelectionToCanvas,
tileSelectedEvenly,
type AlignMode,
} from '../elementAlign';
import type { ElementSide } from '../types';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { CommitInput } from './CommitInput';
import {
@@ -779,7 +777,64 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/>
</div>
{/* Text Formatting Controls */}
{/* Alignment Tools */}
<div className="space-y-1.5">
<h5 className="ps-section-title">
</h5>
<div className="grid grid-cols-3 gap-1.5">
<AlignToolButton
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
label="左对齐"
title="靠左对齐 (X=0)"
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
label="水平居中"
title="水平居中对齐"
onClick={() => {
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
label="右对齐"
title="靠右对齐"
onClick={() => {
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
label="顶对齐"
title="靠顶对齐 (Y=0)"
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
/>
<AlignToolButton
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
label="垂直居中"
title="垂直居中对齐"
onClick={() => {
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
label="底对齐"
title="靠底对齐"
onClick={() => {
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
</div>
</div>
{/* Text Settings */}
{selectedElem.type === 'text' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
@@ -1096,7 +1151,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
)}
{/* Barcode Formats Options */}
{/* Barcode Settings */}
{selectedElem.type === 'barcode' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
@@ -1134,6 +1189,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
)}
{/* Image Settings */}
{selectedElem.type === 'image' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
@@ -1367,7 +1423,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
className="ps-range"
/>
</div>
<div className="border-t border-[#3a3a3a] pt-2.5">
<div className="border-t border-[#3a3a3a] pt-2.5 space-y-2">
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
@@ -1383,6 +1439,41 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}
className="ps-field font-mono"
/>
<div>
<label className="ps-field-label"> (mm)</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['paddingTop', '上'],
['paddingRight', '右'],
['paddingBottom', '下'],
['paddingLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<PropInput
type="number"
min="0"
max="10"
step="0.5"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
const updated = { ...selectedElem };
if (val === '' || val === undefined) {
delete updated[key];
} else {
updated[key] = Math.max(0, Math.min(10, Number(val) || 0));
}
handleElemChange(updated);
}}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
/>
</div>
))}
</div>
</div>
</div>
</div>
@@ -1459,39 +1550,85 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<div>
<label className="ps-field-label mb-1"></label>
<div className="grid grid-cols-4 gap-1">
<label className="ps-field-label"> (mm0 )</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['top', '上'],
['right', '右'],
['bottom', '下'],
['left', '左'],
] as [ElementSide, string][]
).map(([side, label]) => (
<button
key={side}
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
borderSides: toggleSide(selectedElem.borderSides, side),
borderWidth:
(selectedElem.borderWidth ?? 0) > 0
? selectedElem.borderWidth
: 0.3,
borderColor: selectedElem.borderColor || '#111827',
})
['borderWidthTop', '上'],
['borderWidthRight', '右'],
['borderWidthBottom', '下'],
['borderWidthLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<PropInput
type="number"
min="0"
max="5"
step="0.1"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
const updated = { ...selectedElem };
if (val === '' || val === undefined) {
delete updated[key];
} else {
updated[key] = Math.max(0, Math.min(5, Number(val) || 0));
}
className={`ps-btn text-[10px] justify-center ${
isSideEnabled(selectedElem.borderSides, side) &&
(selectedElem.borderWidth ?? 0) > 0
? 'active'
: ''
}`}
>
{label}
</button>
handleElemChange(updated);
}}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
/>
</div>
))}
</div>
</div>
<div>
<label className="ps-field-label"> ()</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['borderColorTop', '上'],
['borderColorRight', '右'],
['borderColorBottom', '下'],
['borderColorLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1 min-w-0">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<input
type="color"
value={
selectedElem[key]?.trim() ||
selectedElem.borderColor ||
'#111827'
}
onChange={(e) => {
const updated = { ...selectedElem };
updated[key] = e.target.value;
handleElemChange(updated);
}}
className="color-input shrink-0 scale-90"
title={`${label}边边框颜色`}
/>
<PropInput
type="text"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
const updated = { ...selectedElem };
if (val === '' || val === undefined) {
delete updated[key];
} else {
updated[key] = val;
}
handleElemChange(updated);
}}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
/>
</div>
))}
</div>
</div>
@@ -1533,62 +1670,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
</div>
{/* Alignment Tools */}
<div className="space-y-1.5">
<h5 className="ps-section-title">
</h5>
<div className="grid grid-cols-3 gap-1.5">
<AlignToolButton
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
label="左对齐"
title="靠左对齐 (X=0)"
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
label="水平居中"
title="水平居中对齐"
onClick={() => {
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
label="右对齐"
title="靠右对齐"
onClick={() => {
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
label="顶对齐"
title="靠顶对齐 (Y=0)"
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
/>
<AlignToolButton
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
label="垂直居中"
title="垂直居中对齐"
onClick={() => {
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
label="底对齐"
title="靠底对齐"
onClick={() => {
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
</div>
</div>
</div>
)}

View File

@@ -15,6 +15,86 @@ export interface BorderSides {
left: boolean;
}
export interface PaddingMm {
top: number;
right: number;
bottom: number;
left: number;
}
export interface BorderSideStyle {
width: number;
color: string;
}
export type BorderConfig = Record<ElementSide, BorderSideStyle>;
export function resolvePaddingMm(elem: TemplateElement): PaddingMm {
const base = elem.padding ?? 0;
const val = (specific?: number) =>
specific !== undefined && specific !== null ? specific : base;
return {
top: val(elem.paddingTop),
right: val(elem.paddingRight),
bottom: val(elem.paddingBottom),
left: val(elem.paddingLeft),
};
}
export function getPaddedRectPx(
x: number,
y: number,
w: number,
h: number,
elem: TemplateElement,
scale: number
) {
const pad = resolvePaddingMm(elem);
const top = Math.round(pad.top * scale);
const right = Math.round(pad.right * scale);
const bottom = Math.round(pad.bottom * scale);
const left = Math.round(pad.left * scale);
return {
x: x + left,
y: y + top,
w: Math.max(1, w - left - right),
h: Math.max(1, h - top - bottom),
paddingPx: { top, right, bottom, left },
};
}
export function resolveBorderConfig(elem: TemplateElement): BorderConfig {
const baseWidth = elem.borderWidth ?? 0;
const baseColor = elem.borderColor || '#111827';
const legacy = elem.borderSides ?? {};
const resolveSide = (
side: ElementSide,
widthOverride: number | undefined,
colorOverride: string | undefined
): BorderSideStyle => {
let width: number;
if (widthOverride !== undefined && widthOverride !== null) {
width = widthOverride;
} else if (baseWidth <= 0 || legacy[side] === false) {
width = 0;
} else {
width = baseWidth;
}
return {
width,
color: colorOverride?.trim() ? colorOverride : baseColor,
};
};
return {
top: resolveSide('top', elem.borderWidthTop, elem.borderColorTop),
right: resolveSide('right', elem.borderWidthRight, elem.borderColorRight),
bottom: resolveSide('bottom', elem.borderWidthBottom, elem.borderColorBottom),
left: resolveSide('left', elem.borderWidthLeft, elem.borderColorLeft),
};
}
export function resolveCornerRadiiPx(
elem: TemplateElement,
scale: number,
@@ -39,16 +119,12 @@ export function resolveCornerRadiiPx(
}
export function resolveBorderSides(elem: TemplateElement): BorderSides {
const width = elem.borderWidth ?? 0;
if (width <= 0) {
return { top: false, right: false, bottom: false, left: false };
}
const sides = elem.borderSides ?? {};
const cfg = resolveBorderConfig(elem);
return {
top: sides.top !== false,
right: sides.right !== false,
bottom: sides.bottom !== false,
left: sides.left !== false,
top: cfg.top.width > 0,
right: cfg.right.width > 0,
bottom: cfg.bottom.width > 0,
left: cfg.left.width > 0,
};
}
@@ -106,20 +182,17 @@ export function drawElementBorders(
elem: TemplateElement,
scale: number
) {
const widthMm = elem.borderWidth ?? 0;
if (widthMm <= 0) return;
const color = elem.borderColor || '#111827';
const lw = Math.max(1, widthMm * scale);
const border = resolveBorderConfig(elem);
const { tl, tr, br, bl } = radii;
const inset = lw / 2;
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineCap = 'butt';
ctx.lineJoin = 'round';
if (sides.top) {
const lw = Math.max(1, border.top.width * scale);
const inset = lw / 2;
ctx.strokeStyle = border.top.color;
ctx.lineWidth = lw;
ctx.beginPath();
if (sides.left && tl > 0) {
ctx.arc(x + tl, y + tl, Math.max(0, tl - inset), Math.PI, 1.5 * Math.PI);
@@ -136,6 +209,10 @@ export function drawElementBorders(
}
if (sides.right) {
const lw = Math.max(1, border.right.width * scale);
const inset = lw / 2;
ctx.strokeStyle = border.right.color;
ctx.lineWidth = lw;
ctx.beginPath();
const topStart = sides.top && tr > 0;
if (topStart) {
@@ -153,6 +230,10 @@ export function drawElementBorders(
}
if (sides.bottom) {
const lw = Math.max(1, border.bottom.width * scale);
const inset = lw / 2;
ctx.strokeStyle = border.bottom.color;
ctx.lineWidth = lw;
ctx.beginPath();
if (sides.right && br > 0) {
ctx.moveTo(x + w - inset, y + h - br);
@@ -170,6 +251,10 @@ export function drawElementBorders(
}
if (sides.left) {
const lw = Math.max(1, border.left.width * scale);
const inset = lw / 2;
ctx.strokeStyle = border.left.color;
ctx.lineWidth = lw;
ctx.beginPath();
const bottomStart = sides.bottom && bl > 0;
if (bottomStart) {
@@ -202,20 +287,3 @@ export function drawElementChrome(
drawElementBorders(ctx, x, y, w, h, radii, sides, elem, scale);
return { radii, sides };
}
export function toggleSide(
current: Partial<Record<ElementSide, boolean>> | undefined,
side: ElementSide
): Partial<Record<ElementSide, boolean>> {
const next = { ...current };
const enabled = current?.[side] !== false;
next[side] = !enabled;
return next;
}
export function isSideEnabled(
sides: Partial<Record<ElementSide, boolean>> | undefined,
side: ElementSide
): boolean {
return sides?.[side] !== false;
}

View File

@@ -10,6 +10,8 @@ import {
import {
resolveCornerRadiiPx,
resolveBorderSides,
resolvePaddingMm,
getPaddedRectPx,
traceRoundedRect,
drawElementFill,
drawElementBorders,
@@ -213,23 +215,6 @@ function layoutVerticalTextColumns(
return columns;
}
function getPaddedRect(
x: number,
y: number,
w: number,
h: number,
paddingMm: number,
scale: number
) {
const paddingPx = Math.round(paddingMm * scale);
return {
x: x + paddingPx,
y: y + paddingPx,
w: Math.max(1, w - 2 * paddingPx),
h: Math.max(1, h - 2 * paddingPx),
};
}
function drawImagePlaceholder(
ctx: CanvasRenderingContext2D,
x: number,
@@ -343,10 +328,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
const paddingMm = elem.padding || 0;
const paddingPx = Math.round(paddingMm * scale);
const writableW = Math.max(1, ew - 2 * paddingPx);
const writableH = Math.max(1, eh - 2 * paddingPx);
const padPx = (() => {
const pad = resolvePaddingMm(elem);
return {
top: Math.round(pad.top * scale),
right: Math.round(pad.right * scale),
bottom: Math.round(pad.bottom * scale),
left: Math.round(pad.left * scale),
};
})();
const writableW = Math.max(1, ew - padPx.left - padPx.right);
const writableH = Math.max(1, eh - padPx.top - padPx.bottom);
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
@@ -375,8 +367,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
const transformOriginX = ex + paddingPx + writableW / 2;
const transformOriginY = ey + paddingPx + writableH / 2;
const transformOriginX = ex + padPx.left + writableW / 2;
const transformOriginY = ey + padPx.top + writableH / 2;
ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) {
@@ -393,11 +385,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
let startX: number;
if (elem.textAlign === 'center') {
startX = ex + paddingPx + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
startX = ex + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = ex + paddingPx + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = ex + paddingPx + columnWidth / 2;
startX = ex + padPx.left + columnWidth / 2;
}
ctx.textAlign = 'center';
@@ -406,18 +398,18 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const colTextHeight = column.length * lineHeight;
let colStartY: number;
if (vAlign === 'top') {
colStartY = ey + paddingPx + lineHeight / 2;
colStartY = ey + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = ey + eh - paddingPx - colTextHeight + lineHeight / 2;
colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2;
} else {
colStartY =
ey + paddingPx + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
ey + padPx.top + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
}
const colX = startX + col * columnWidth;
for (let i = 0; i < column.length; i++) {
const charY = colStartY + i * lineHeight;
if (charY - lineHeight / 2 <= ey + eh - paddingPx) {
if (charY - lineHeight / 2 <= ey + eh - padPx.bottom) {
ctx.fillText(column[i], colX, charY);
}
}
@@ -426,32 +418,32 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = ex + paddingPx;
let textX = ex + padPx.left;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + paddingPx + writableW / 2;
textX = ex + padPx.left + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + paddingPx + (writableW - 2);
textX = ex + padPx.left + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + paddingPx + 2;
textX = ex + padPx.left + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = ey + paddingPx + lineHeight / 2;
startY = ey + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2;
} else {
startY =
ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
ey + padPx.top + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
}
for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
if (lineY - lineHeight / 2 <= ey + eh - padPx.bottom) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
@@ -468,7 +460,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} else if (elem.type === 'barcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const tempCanvas = document.createElement('canvas');
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
@@ -488,7 +480,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} catch (error) {
console.warn('Barcode drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
@@ -503,7 +495,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} else if (elem.type === 'qrcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (qrValue) {
@@ -522,7 +514,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} catch (error) {
console.warn('QR code drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
@@ -532,7 +524,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
} else if (elem.type === 'image') {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const src = value ? String(value).trim() : '';
if (!src) {
if (mode === 'design') {

View File

@@ -2,8 +2,20 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import { AppDialogProvider } from './components/AppDialog.tsx';
import logoUrl from './assets/logo.svg';
import './index.css';
const faviconLink =
document.querySelector<HTMLLinkElement>("link[rel='icon']") ??
(() => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/svg+xml';
document.head.appendChild(link);
return link;
})();
faviconLink.href = logoUrl;
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppDialogProvider>

View File

@@ -71,6 +71,10 @@ export interface TemplateElement {
textColor?: string;
/** 内边距 (mm),所有元素类型均有效 */
padding?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
locked?: boolean;
/** 图片缩放方式,仅 type=image 时有效 */
imageFit?: 'contain' | 'cover' | 'fill';
@@ -78,10 +82,18 @@ export interface TemplateElement {
textFormat?: TextFormat;
/** 数值/百分比/货币的小数位数,默认 2 */
decimalPlaces?: number;
/** 边框宽度 (mm)0 为无边框 */
/** 边框宽度 (mm)0 为无边框;各边留空时沿用此值 */
borderWidth?: number;
borderColor?: string;
/** 指定哪些边显示边框,默认四边 */
borderWidthTop?: number;
borderWidthRight?: number;
borderWidthBottom?: number;
borderWidthLeft?: number;
borderColorTop?: string;
borderColorRight?: string;
borderColorBottom?: string;
borderColorLeft?: string;
/** @deprecated 使用各边 borderWidth*0 为不显示) */
borderSides?: Partial<Record<ElementSide, boolean>>;
/** 圆角半径 (mm),可被各角覆盖 */
borderRadius?: number;

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />