diff --git a/src/components/ElementFloatPreview.tsx b/src/components/ElementFloatPreview.tsx index c092cc5..08d0c30 100644 --- a/src/components/ElementFloatPreview.tsx +++ b/src/components/ElementFloatPreview.tsx @@ -3,6 +3,12 @@ import { TemplateElement } from '../types'; import { mmToPx } from '../utils'; import { renderLabelToDataUrl } from '../labelRenderer'; +/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */ +function getFloatPreviewPaddingMm(element: TemplateElement): number { + if (element.type !== 'table') return 0; + return (element.tableBorderWidth ?? 0.2) / 2 + 0.1; +} + interface ElementFloatPreviewProps { element: TemplateElement; renderScale: number; @@ -17,10 +23,11 @@ export async function renderElementFloatPreview( renderScale: number, variableDefaults?: Record | null ): Promise { + const padMm = getFloatPreviewPaddingMm(element); return renderLabelToDataUrl({ - widthMm: element.width, - heightMm: element.height, - elements: [{ ...element, x: 0, y: 0 }], + widthMm: element.width + padMm * 2, + heightMm: element.height + padMm * 2, + elements: [{ ...element, x: padMm, y: padMm }], rowData: null, rowIndex: 0, showBorder: false, diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index c75a419..b2aa6ea 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { LabelTemplate, TemplateElement } from '../types'; -import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; +import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils'; import type { ElementClipboardActions } from '../hooks/useElementClipboard'; import { createDefaultTableElement, @@ -327,8 +327,7 @@ export const LabelDesigner: React.FC = ({ id, type: 'image', name: `图片 ${n}`, - x: 5, - y: 5, + ...centerElementOnLabel(template.width, template.height, 30, 30), width: 30, height: 30, content: src, @@ -421,6 +420,14 @@ export const LabelDesigner: React.FC = ({ }; } + const centered = centerElementOnLabel( + template.width, + template.height, + newElement.width, + newElement.height + ); + newElement = { ...newElement, ...centered }; + onChange({ ...template, elements: [...template.elements, newElement], diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index 7241666..8fba2ad 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -997,7 +997,9 @@ export const PropSidebar: React.FC = ({ const renderPropertiesPanelBody = () => ( <> -
{renderPreviewReferenceBackgroundFields()}
+ {!selectedElem && ( +
{renderPreviewReferenceBackgroundFields()}
+ )} {/* Label Canvas Dimensions */} {!selectedElem && (
@@ -1684,13 +1686,15 @@ export const PropSidebar: React.FC = ({ value={selectedElem.width} onCommit={(val) => { const wVal = Math.max(2, Number(val) || 2); - let updated = { ...selectedElem, width: wVal }; if (selectedElem.type === 'qrcode') { - updated.height = wVal; + handleElemChange({ ...selectedElem, width: wVal, height: wVal }); } else if (selectedElem.type === 'table') { - updated = scaleTableElementSize(updated, wVal, updated.height); + handleElemChange( + scaleTableElementSize(selectedElem, wVal, selectedElem.height) + ); + } else { + handleElemChange({ ...selectedElem, width: wVal }); } - handleElemChange(updated); }} className="ps-field font-mono" /> diff --git a/src/components/TablePropertyFields.tsx b/src/components/TablePropertyFields.tsx index 4e5c9d9..d5498e7 100644 --- a/src/components/TablePropertyFields.tsx +++ b/src/components/TablePropertyFields.tsx @@ -14,6 +14,8 @@ import { updateTableCells, updateTableColWidth, updateTableRowHeight, + equalizeTableColWidths, + equalizeTableRowHeights, getTableColWidths, getTableRowHeights, getCellRawContent, @@ -90,7 +92,17 @@ export const TablePropertyFields: React.FC = ({
- 行高 (mm) +
+ 行高 (mm) + +
{rowHeights.map((h, i) => (
@@ -111,7 +123,17 @@ export const TablePropertyFields: React.FC = ({
- 列宽 (mm) +
+ 列宽 (mm) + +
{colWidths.map((w, i) => (
@@ -287,7 +309,9 @@ export const TableCellContentFields: React.FC = ({ export function scaleTableElementSize( elem: TemplateElement, newWidth: number, - newHeight: number + newHeight: number, + startWidth = elem.width, + startHeight = elem.height ): TemplateElement { if (elem.type !== 'table') { return { ...elem, width: newWidth, height: newHeight }; @@ -296,8 +320,8 @@ export function scaleTableElementSize( elem, newWidth, newHeight, - elem.width, - elem.height, + startWidth, + startHeight, getTableRowHeights(elem), getTableColWidths(elem) ); diff --git a/src/labelRenderer.ts b/src/labelRenderer.ts index f2d0a31..430fd57 100644 --- a/src/labelRenderer.ts +++ b/src/labelRenderer.ts @@ -422,12 +422,17 @@ function drawTableGridLines( ctx.beginPath(); const inset = borderW / 2; - ctx.rect( - alignStrokeCoord(left + inset), - alignStrokeCoord(top + inset), - Math.max(0, width - borderW), - Math.max(0, height - borderW) - ); + const right = left + width; + const bottom = top + height; + // 外框:描边完全落在网格区域内,避免 clip / 画布边缘裁切右、下边 + ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset)); + ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset)); + ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset)); + ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset)); + ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset)); + ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset)); + ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset)); + ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset)); for (let r = 1; r < rows; r++) { const y = alignStrokeCoord(rowBounds[r]); @@ -532,8 +537,8 @@ async function drawTableElement( ); } - drawTableGridLines(ctx, elem, ex, ey, scale); ctx.restore(); + drawTableGridLines(ctx, elem, ex, ey, scale); } function drawImagePlaceholder( diff --git a/src/tableUtils.ts b/src/tableUtils.ts index d9df40c..e301f26 100644 --- a/src/tableUtils.ts +++ b/src/tableUtils.ts @@ -56,6 +56,36 @@ export function getTableColWidths(elem: TemplateElement): number[] { return Array.from({ length: cols }, () => even); } +/** 调整末项(必要时向前)使轨道总和精确等于 targetTotal */ +function absorbTrackSumDiff(tracks: number[], targetTotal: number, min = 0.5): number[] { + const result = tracks.map((t) => round1(Math.max(min, t))); + let diff = round1(targetTotal - result.reduce((a, b) => a + b, 0)); + for (let i = result.length - 1; i >= 0 && diff !== 0; i--) { + const adjusted = round1(Math.max(min, result[i] + diff)); + const applied = round1(adjusted - result[i]); + result[i] = adjusted; + diff = round1(diff - applied); + } + return result; +} + +/** 按比例缩放轨道,严格使总和等于 targetTotal(不放大目标总量) */ +function scaleTracksToExactTotal(tracks: number[], targetTotal: number, min = 0.5): number[] { + if (tracks.length === 0) return []; + const count = tracks.length; + const floor = count * min; + if (targetTotal <= floor + 0.01) { + return absorbTrackSumDiff(Array.from({ length: count }, () => min), targetTotal, min); + } + const current = tracks.reduce((a, b) => a + b, 0); + if (current <= 0) { + const even = round1(targetTotal / count); + return absorbTrackSumDiff(Array.from({ length: count }, () => even), targetTotal, min); + } + const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * targetTotal))); + return absorbTrackSumDiff(scaled, targetTotal, min); +} + /** 将轨道尺寸总和调整为 targetTotal(保持相对比例,末项吸收取整误差) */ function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] { if (tracks.length === 0) return []; @@ -73,6 +103,48 @@ function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): n return scaled; } +/** 调整单条轨道尺寸,保持轨道总和不变;优先由 index 之后的轨道吸收差值 */ +function resizeTrackKeepingTotal( + tracks: number[], + index: number, + sizeMm: number, + min = 0.5 +): number[] { + if (index < 0 || index >= tracks.length) return tracks; + const total = round1(tracks.reduce((a, b) => a + b, 0)); + if (tracks.length === 1) { + return [round1(Math.max(min, Math.min(total, sizeMm)))]; + } + + const beforeSum = round1(tracks.slice(0, index).reduce((a, b) => a + b, 0)); + const afterCount = tracks.length - index - 1; + const beforeCount = index; + + const maxForIndex = + afterCount > 0 + ? round1(total - beforeSum - afterCount * min) + : round1(total - beforeCount * min); + const newSize = round1(Math.max(min, Math.min(maxForIndex, sizeMm))); + const result = [...tracks]; + result[index] = newSize; + + if (afterCount > 0) { + const afterRemaining = round1(total - beforeSum - newSize); + const scaledAfter = scaleTracksToExactTotal(tracks.slice(index + 1), afterRemaining, min); + for (let i = 0; i < scaledAfter.length; i++) { + result[index + 1 + i] = scaledAfter[i]; + } + } else if (beforeCount > 0) { + const beforeRemaining = round1(total - newSize); + const scaledBefore = scaleTracksToExactTotal(tracks.slice(0, index), beforeRemaining, min); + for (let i = 0; i < scaledBefore.length; i++) { + result[i] = scaledBefore[i]; + } + } + + return absorbTrackSumDiff(result, total, min); +} + /** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */ function distributeTracks(total: number, count: number, min = 0.5): number[] { if (count <= 0) return []; @@ -132,8 +204,14 @@ export function scaleTableTracks( const scaleW = newInnerW / startInnerW; const scaleH = newInnerH / startInnerH; - const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH))); - const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW))); + const tableRowHeights = scaleTracksToExactTotal( + startRowHeights.map((h) => h * scaleH), + newInnerH + ); + const tableColWidths = scaleTracksToExactTotal( + startColWidths.map((w) => w * scaleW), + newInnerW + ); return { ...elem, @@ -456,14 +534,27 @@ export function updateTableRowHeight( rowIndex: number, heightMm: number ): TemplateElement { - const rowHeights = [...getTableRowHeights(elem)]; - if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem; - rowHeights[rowIndex] = Math.max(0.5, round1(heightMm)); + if (rowIndex < 0 || rowIndex >= getTableRows(elem)) return elem; const pad = resolvePaddingMm(elem); + const innerH = getTableInnerSizeMm(elem).height; + const rowHeights = scaleTracksToExactTotal(getTableRowHeights(elem), innerH); + const isLast = rowIndex === rowHeights.length - 1; + + if (isLast) { + const nextHeights = [...rowHeights]; + nextHeights[rowIndex] = round1(Math.max(0.5, heightMm)); + const newInnerH = round1(nextHeights.reduce((a, b) => a + b, 0)); + return { + ...elem, + tableRowHeights: nextHeights, + height: round1(newInnerH + pad.top + pad.bottom), + }; + } + return { ...elem, - tableRowHeights: rowHeights, - height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), + tableRowHeights: resizeTrackKeepingTotal(rowHeights, rowIndex, heightMm), + height: elem.height, }; } @@ -472,14 +563,55 @@ export function updateTableColWidth( colIndex: number, widthMm: number ): TemplateElement { - const colWidths = [...getTableColWidths(elem)]; - if (colIndex < 0 || colIndex >= colWidths.length) return elem; - colWidths[colIndex] = Math.max(0.5, round1(widthMm)); + if (colIndex < 0 || colIndex >= getTableCols(elem)) return elem; const pad = resolvePaddingMm(elem); + const innerW = getTableInnerSizeMm(elem).width; + const colWidths = scaleTracksToExactTotal(getTableColWidths(elem), innerW); + const isLast = colIndex === colWidths.length - 1; + + if (isLast) { + const nextWidths = [...colWidths]; + nextWidths[colIndex] = round1(Math.max(0.5, widthMm)); + const newInnerW = round1(nextWidths.reduce((a, b) => a + b, 0)); + return { + ...elem, + tableColWidths: nextWidths, + width: round1(newInnerW + pad.left + pad.right), + }; + } + return { ...elem, - tableColWidths: colWidths, - width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), + tableColWidths: resizeTrackKeepingTotal(colWidths, colIndex, widthMm), + width: elem.width, + }; +} + +/** 在现有元素高度内均分行高 */ +export function equalizeTableRowHeights(elem: TemplateElement): TemplateElement { + const rows = getTableRows(elem); + const innerH = getTableInnerSizeMm(elem).height; + return { + ...elem, + tableRowHeights: scaleTracksToExactTotal( + Array.from({ length: rows }, () => innerH / rows), + innerH + ), + height: elem.height, + }; +} + +/** 在现有元素宽度内均分列宽 */ +export function equalizeTableColWidths(elem: TemplateElement): TemplateElement { + const cols = getTableCols(elem); + const innerW = getTableInnerSizeMm(elem).width; + return { + ...elem, + tableColWidths: scaleTracksToExactTotal( + Array.from({ length: cols }, () => innerW / cols), + innerW + ), + width: elem.width, }; } diff --git a/src/utils.ts b/src/utils.ts index 7ac0eca..652c7c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,6 +30,21 @@ export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号'; /** 粘贴副本时相对原位置的偏移 (mm) */ export const PASTE_ELEMENT_OFFSET_MM = 2; +/** 在标签画布上居中放置元素(mm,保留 1 位小数) */ +export function centerElementOnLabel( + labelWidth: number, + labelHeight: number, + elementWidth: number, + elementHeight: number +): { x: number; y: number } { + const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10; + const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10; + return { + x: Math.max(0, x), + y: Math.max(0, y), + }; +} + /** 深拷贝元素并生成新 id,用于复制/粘贴 */ export function cloneTemplateElementsForPaste( elements: TemplateElement[],