优化:支持文字排版
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -649,7 +649,7 @@ export default function App() {
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-semibold whitespace-nowrap transition hover:bg-indigo-700 shadow-sm cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" />
|
||||
新建空白模板
|
||||
新建模板
|
||||
</button>
|
||||
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
|
||||
<Upload className="w-4 h-4 shrink-0" />
|
||||
@@ -697,8 +697,8 @@ export default function App() {
|
||||
<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 模板后,即可上传 Excel 数据并批量生成排版后的PDF文档。'
|
||||
: '创建空白模板开始可视化设计,或导入已有的 JSON 模板文件。'}
|
||||
? '请导入已有模板,或电脑端进行设计。'
|
||||
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||
{!isMobile && (
|
||||
@@ -708,7 +708,7 @@ export default function App() {
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" />
|
||||
新建空白模板
|
||||
新建模板
|
||||
</button>
|
||||
)}
|
||||
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
||||
@@ -1074,7 +1074,7 @@ export default function App() {
|
||||
id="add-template-dialog-title"
|
||||
className="text-sm font-bold text-gray-900"
|
||||
>
|
||||
新建空白模板
|
||||
新建模板
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -61,8 +61,13 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
draftSelectedCount,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [dataCollapsed, setDataCollapsed] = useState(false);
|
||||
const [layoutCollapsed, setLayoutCollapsed] = useState(true);
|
||||
type ExportPanelKey = 'data' | 'layout';
|
||||
const [activePanel, setActivePanel] = useState<ExportPanelKey | null>('data');
|
||||
const dataCollapsed = activePanel !== 'data';
|
||||
const layoutCollapsed = activePanel !== 'layout';
|
||||
const toggleExportPanel = (key: ExportPanelKey) => {
|
||||
setActivePanel((prev) => (prev === key ? null : key));
|
||||
};
|
||||
const hasData = rows.length > 0;
|
||||
|
||||
const dataRangeSection = (
|
||||
@@ -149,7 +154,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
<CollapsiblePanelSection
|
||||
sectionClassName="export-data-panel"
|
||||
collapsed={dataCollapsed}
|
||||
onToggle={() => setDataCollapsed((v) => !v)}
|
||||
onToggle={() => toggleExportPanel('data')}
|
||||
icon={<Database className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||
title="数据源与变量绑定"
|
||||
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||
@@ -181,7 +186,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
<CollapsiblePanelSection
|
||||
sectionClassName="export-layout-panel"
|
||||
collapsed={layoutCollapsed}
|
||||
onToggle={() => setLayoutCollapsed((v) => !v)}
|
||||
onToggle={() => toggleExportPanel('layout')}
|
||||
icon={<Layout className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
|
||||
title="整页排版 & PDF 设置"
|
||||
bodyClassName="ps-panel-body p-3 min-h-0 space-y-3"
|
||||
|
||||
@@ -864,6 +864,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
width: `${elemW}px`,
|
||||
height: `${elemH}px`,
|
||||
zIndex: isSelected ? index + 1000 : index + 10,
|
||||
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
onMouseDown={
|
||||
isLocked ? undefined : (e) => handleElementMouseDown(e, element, 'drag')
|
||||
|
||||
@@ -352,14 +352,13 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||
onClick={autoCenterMargins}
|
||||
className={
|
||||
isPs
|
||||
? 'ps-btn w-full text-[10px]'
|
||||
? 'ps-btn text-[10px]'
|
||||
: isMobile
|
||||
? 'mobile-secondary-btn w-full'
|
||||
: 'w-full inline-flex items-center justify-center gap-1 bg-white hover:bg-gray-100 border border-gray-200 text-gray-600 py-1.5 px-3 rounded-lg text-[10px] font-bold cursor-pointer'
|
||||
}
|
||||
>
|
||||
<RefreshCcw className="w-3.5 h-3.5" />
|
||||
自动边距
|
||||
计算
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
Link2,
|
||||
Plus,
|
||||
Ruler,
|
||||
RotateCw,
|
||||
Barcode,
|
||||
QrCode,
|
||||
Image as ImageIcon,
|
||||
@@ -181,7 +182,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
|
||||
const togglePanel = (key: keyof PanelCollapseState) => {
|
||||
setPanelCollapsed((prev) => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
const isCollapsed = prev[key];
|
||||
const next: PanelCollapseState = isCollapsed
|
||||
? { content: true, properties: true, layers: true, [key]: false }
|
||||
: { ...prev, [key]: true };
|
||||
try {
|
||||
localStorage.setItem(PANEL_COLLAPSE_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
@@ -1028,6 +1032,32 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="ps-field-label mb-0">排列方向</label>
|
||||
<div className="ps-toggle-group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleElemChange({ ...selectedElem, textWritingMode: 'horizontal' })
|
||||
}
|
||||
className={`ps-toggle-btn ${(selectedElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
|
||||
title="横排"
|
||||
>
|
||||
横排
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleElemChange({ ...selectedElem, textWritingMode: 'vertical' })
|
||||
}
|
||||
className={`ps-toggle-btn ${selectedElem.textWritingMode === 'vertical' ? 'active' : ''}`}
|
||||
title="竖排"
|
||||
>
|
||||
竖排
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="ps-field-label mb-0">对齐方式</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -1234,6 +1264,26 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="ps-field-label flex items-center gap-1">
|
||||
旋转角度(度)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<PropInput
|
||||
type="number"
|
||||
step="1"
|
||||
min="-360"
|
||||
max="360"
|
||||
value={selectedElem.rotation ?? 0}
|
||||
onCommit={(val) => {
|
||||
let deg = Number(val) || 0;
|
||||
deg = ((deg % 360) + 360) % 360;
|
||||
handleElemChange({ ...selectedElem, rotation: deg });
|
||||
}}
|
||||
className="ps-field font-mono flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -121,6 +121,98 @@ function drawImageWithFit(
|
||||
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
|
||||
}
|
||||
|
||||
function applyElementRotation(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
rotationDeg: number | undefined
|
||||
) {
|
||||
const rotation = rotationDeg ?? 0;
|
||||
if (rotation === 0) return;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.translate(-cx, -cy);
|
||||
}
|
||||
|
||||
function layoutHorizontalTextLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
value: string,
|
||||
writableW: number,
|
||||
wordWrap: boolean
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const paragraphs = String(value).split(/\r?\n/);
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (!wordWrap) {
|
||||
lines.push(paragraph);
|
||||
continue;
|
||||
}
|
||||
let currentLine = '';
|
||||
for (let i = 0; i < paragraph.length; i++) {
|
||||
const char = paragraph[i];
|
||||
const testLine = currentLine + char;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
currentLine = char;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
if (currentLine || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 竖排:按列组织字符,列高受限时换列 */
|
||||
function layoutVerticalTextColumns(
|
||||
value: string,
|
||||
writableH: number,
|
||||
lineHeight: number,
|
||||
wordWrap: boolean
|
||||
): string[][] {
|
||||
const columns: string[][] = [];
|
||||
const paragraphs = String(value).split(/\r?\n/);
|
||||
|
||||
for (let p = 0; p < paragraphs.length; p++) {
|
||||
const paragraph = paragraphs[p];
|
||||
if (!wordWrap) {
|
||||
columns.push([...paragraph]);
|
||||
} else {
|
||||
let currentColumn: string[] = [];
|
||||
for (const char of paragraph) {
|
||||
const colHeight = (currentColumn.length + 1) * lineHeight;
|
||||
if (colHeight > writableH - 4 && currentColumn.length > 0) {
|
||||
columns.push(currentColumn);
|
||||
currentColumn = [char];
|
||||
} else {
|
||||
currentColumn.push(char);
|
||||
}
|
||||
}
|
||||
if (currentColumn.length > 0) {
|
||||
columns.push(currentColumn);
|
||||
}
|
||||
}
|
||||
if (p < paragraphs.length - 1) {
|
||||
columns.push([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length === 0) {
|
||||
columns.push([]);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function getPaddedRect(
|
||||
x: number,
|
||||
y: number,
|
||||
@@ -238,6 +330,9 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
|
||||
|
||||
if (elem.type === 'text') {
|
||||
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||
const sides = resolveBorderSides(elem);
|
||||
@@ -273,58 +368,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
}
|
||||
|
||||
const wordWrap = elem.wordWrap !== false;
|
||||
const lines: string[] = [];
|
||||
const paragraphs = String(value).split(/\r?\n/);
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (!wordWrap) {
|
||||
lines.push(paragraph);
|
||||
continue;
|
||||
}
|
||||
let currentLine = '';
|
||||
for (let i = 0; i < paragraph.length; i++) {
|
||||
const char = paragraph[i];
|
||||
const testLine = currentLine + char;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
currentLine = char;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
if (currentLine || lines.length === 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
const lineHeight = fontSizePx * 1.25;
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
const columnWidth = lineHeight;
|
||||
const isVertical = elem.textWritingMode === 'vertical';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
let textX = ex + paddingPx;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = ex + paddingPx + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = ex + paddingPx + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = ex + paddingPx + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = ey + paddingPx + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY = ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -337,12 +385,77 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
ctx.translate(-transformOriginX, -transformOriginY);
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
|
||||
if (isVertical) {
|
||||
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||
const totalColumnsWidth = columns.length * columnWidth;
|
||||
|
||||
let startX: number;
|
||||
if (elem.textAlign === 'center') {
|
||||
startX = ex + paddingPx + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||
} else if (elem.textAlign === 'right') {
|
||||
startX = ex + paddingPx + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||
} else {
|
||||
startX = ex + paddingPx + columnWidth / 2;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
const column = columns[col];
|
||||
const colTextHeight = column.length * lineHeight;
|
||||
let colStartY: number;
|
||||
if (vAlign === 'top') {
|
||||
colStartY = ey + paddingPx + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
colStartY = ey + eh - paddingPx - colTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
colStartY =
|
||||
ey + paddingPx + 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) {
|
||||
ctx.fillText(column[i], colX, charY);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
let textX = ex + paddingPx;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = ex + paddingPx + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = ex + paddingPx + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = ex + paddingPx + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = ey + paddingPx + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY =
|
||||
ey + paddingPx + 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) {
|
||||
const line = lines[i];
|
||||
ctx.fillText(line, textX, lineY);
|
||||
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,8 +472,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
||||
|
||||
if (!barcodeValue) continue;
|
||||
|
||||
if (barcodeValue) {
|
||||
JsBarcode(tempCanvas, barcodeValue, {
|
||||
format: elem.barcodeFormat || 'CODE128',
|
||||
width: 2,
|
||||
@@ -372,6 +484,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
});
|
||||
|
||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Barcode drawing failed', error);
|
||||
if (mode === 'design') {
|
||||
@@ -393,8 +506,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
const inner = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
|
||||
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
||||
|
||||
if (!qrValue) continue;
|
||||
|
||||
if (qrValue) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(tempCanvas, qrValue, {
|
||||
width: Math.max(64, inner.w),
|
||||
@@ -406,6 +518,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
});
|
||||
|
||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('QR code drawing failed', error);
|
||||
if (mode === 'design') {
|
||||
@@ -436,6 +549,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface TemplateElement {
|
||||
y: number; // in mm
|
||||
width: number; // in mm
|
||||
height: number; // in mm
|
||||
/** 旋转角度(度),顺时针,默认 0 */
|
||||
rotation?: number;
|
||||
content: string; // static text or variable binding, e.g. "{货位}" or "格式化文本 {货位}"
|
||||
/** 显示控制:conditional 时按 visibilityRules 判断 */
|
||||
visibilityMode?: ElementVisibilityMode;
|
||||
@@ -45,6 +47,8 @@ export interface TemplateElement {
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
/** 是否自动换行,仅 type=text,默认 true */
|
||||
wordWrap?: boolean;
|
||||
/** 文本排列方向,仅 type=text,默认 horizontal */
|
||||
textWritingMode?: 'horizontal' | 'vertical';
|
||||
fontWeight: 'normal' | 'bold';
|
||||
/** 斜体,仅 type=text */
|
||||
fontStyle?: 'normal' | 'italic';
|
||||
|
||||
Reference in New Issue
Block a user