diff --git a/src/App.tsx b/src/App.tsx index 6ddcb8f..3bff025 100644 --- a/src/App.tsx +++ b/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" > - 新建空白模板 + 新建模板 @@ -697,8 +697,8 @@ export default function App() { 还没有标签模板 {isMobile - ? '导入 JSON 模板后,即可上传 Excel 数据并批量生成排版后的PDF文档。' - : '创建空白模板开始可视化设计,或导入已有的 JSON 模板文件。'} + ? '请导入已有模板,或电脑端进行设计。' + : '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'} {!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" > - 新建空白模板 + 新建模板 )} @@ -1074,7 +1074,7 @@ export default function App() { id="add-template-dialog-title" className="text-sm font-bold text-gray-900" > - 新建空白模板 + 新建模板 = ({ draftSelectedCount, compact = false, }) => { - const [dataCollapsed, setDataCollapsed] = useState(false); - const [layoutCollapsed, setLayoutCollapsed] = useState(true); + type ExportPanelKey = 'data' | 'layout'; + const [activePanel, setActivePanel] = useState('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 = ({ setDataCollapsed((v) => !v)} + onToggle={() => toggleExportPanel('data')} icon={} title="数据源与变量绑定" bodyClassName="ps-panel-body p-3 min-h-0 space-y-3" @@ -181,7 +186,7 @@ export const ExportSidebar: React.FC = ({ setLayoutCollapsed((v) => !v)} + onToggle={() => toggleExportPanel('layout')} icon={} title="整页排版 & PDF 设置" bodyClassName="ps-panel-body p-3 min-h-0 space-y-3" diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index a2beedb..c29c79e 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -864,6 +864,8 @@ export const LabelDesigner: React.FC = ({ 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') diff --git a/src/components/PreviewGrid.tsx b/src/components/PreviewGrid.tsx index 79d6839..3f522c9 100644 --- a/src/components/PreviewGrid.tsx +++ b/src/components/PreviewGrid.tsx @@ -352,14 +352,13 @@ export const PaperSettingsPanel: React.FC = ({ 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' } > - - 自动边距 + 计算 diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index 547cc99..afdc2cf 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -48,6 +48,7 @@ import { Link2, Plus, Ruler, + RotateCw, Barcode, QrCode, Image as ImageIcon, @@ -181,7 +182,10 @@ export const PropSidebar: React.FC = ({ 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 = ({ + + 排列方向 + + + handleElemChange({ ...selectedElem, textWritingMode: 'horizontal' }) + } + className={`ps-toggle-btn ${(selectedElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`} + title="横排" + > + 横排 + + + handleElemChange({ ...selectedElem, textWritingMode: 'vertical' }) + } + className={`ps-toggle-btn ${selectedElem.textWritingMode === 'vertical' ? 'active' : ''}`} + title="竖排" + > + 竖排 + + + + 对齐方式 @@ -1234,6 +1264,26 @@ export const PropSidebar: React.FC = ({ }`} /> + + + 旋转角度(度) + + + { + let deg = Number(val) || 0; + deg = ((deg % 360) + 360) % 360; + handleElemChange({ ...selectedElem, rotation: deg }); + }} + className="ps-field font-mono flex-1" + /> + + diff --git a/src/labelRenderer.ts b/src/labelRenderer.ts index f250b43..c98e2d0 100644 --- a/src/labelRenderer.ts +++ b/src/labelRenderer.ts @@ -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(); diff --git a/src/types.ts b/src/types.ts index 8f164c5..23cc796 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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';
{isMobile - ? '导入 JSON 模板后,即可上传 Excel 数据并批量生成排版后的PDF文档。' - : '创建空白模板开始可视化设计,或导入已有的 JSON 模板文件。'} + ? '请导入已有模板,或电脑端进行设计。' + : '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}