This commit is contained in:
iqudoo
2026-06-14 18:02:18 +08:00
parent b4c2cf07d0
commit 8c9b53b1b6
7 changed files with 229 additions and 35 deletions

View File

@@ -3,6 +3,12 @@ import { TemplateElement } from '../types';
import { mmToPx } from '../utils'; import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; 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 { interface ElementFloatPreviewProps {
element: TemplateElement; element: TemplateElement;
renderScale: number; renderScale: number;
@@ -17,10 +23,11 @@ export async function renderElementFloatPreview(
renderScale: number, renderScale: number,
variableDefaults?: Record<string, string> | null variableDefaults?: Record<string, string> | null
): Promise<string> { ): Promise<string> {
const padMm = getFloatPreviewPaddingMm(element);
return renderLabelToDataUrl({ return renderLabelToDataUrl({
widthMm: element.width, widthMm: element.width + padMm * 2,
heightMm: element.height, heightMm: element.height + padMm * 2,
elements: [{ ...element, x: 0, y: 0 }], elements: [{ ...element, x: padMm, y: padMm }],
rowData: null, rowData: null,
rowIndex: 0, rowIndex: 0,
showBorder: false, showBorder: false,

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types'; 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 type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
createDefaultTableElement, createDefaultTableElement,
@@ -327,8 +327,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
id, id,
type: 'image', type: 'image',
name: `图片 ${n}`, name: `图片 ${n}`,
x: 5, ...centerElementOnLabel(template.width, template.height, 30, 30),
y: 5,
width: 30, width: 30,
height: 30, height: 30,
content: src, content: src,
@@ -421,6 +420,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}; };
} }
const centered = centerElementOnLabel(
template.width,
template.height,
newElement.width,
newElement.height
);
newElement = { ...newElement, ...centered };
onChange({ onChange({
...template, ...template,
elements: [...template.elements, newElement], elements: [...template.elements, newElement],

View File

@@ -997,7 +997,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const renderPropertiesPanelBody = () => ( const renderPropertiesPanelBody = () => (
<> <>
{!selectedElem && (
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div> <div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
)}
{/* Label Canvas Dimensions */} {/* Label Canvas Dimensions */}
{!selectedElem && ( {!selectedElem && (
<div className="space-y-4"> <div className="space-y-4">
@@ -1684,13 +1686,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
value={selectedElem.width} value={selectedElem.width}
onCommit={(val) => { onCommit={(val) => {
const wVal = Math.max(2, Number(val) || 2); const wVal = Math.max(2, Number(val) || 2);
let updated = { ...selectedElem, width: wVal };
if (selectedElem.type === 'qrcode') { if (selectedElem.type === 'qrcode') {
updated.height = wVal; handleElemChange({ ...selectedElem, width: wVal, height: wVal });
} else if (selectedElem.type === 'table') { } 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" className="ps-field font-mono"
/> />

View File

@@ -14,6 +14,8 @@ import {
updateTableCells, updateTableCells,
updateTableColWidth, updateTableColWidth,
updateTableRowHeight, updateTableRowHeight,
equalizeTableColWidths,
equalizeTableRowHeights,
getTableColWidths, getTableColWidths,
getTableRowHeights, getTableRowHeights,
getCellRawContent, getCellRawContent,
@@ -90,7 +92,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span> <span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<button
type="button"
onClick={() => onChange(equalizeTableRowHeights(elem))}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
title="将各行高度恢复为相等"
>
</button>
</div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{rowHeights.map((h, i) => ( {rowHeights.map((h, i) => (
<div key={`rh-${i}`}> <div key={`rh-${i}`}>
@@ -111,7 +123,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span> <span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<button
type="button"
onClick={() => onChange(equalizeTableColWidths(elem))}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
title="将各列宽度恢复为相等"
>
</button>
</div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{colWidths.map((w, i) => ( {colWidths.map((w, i) => (
<div key={`cw-${i}`}> <div key={`cw-${i}`}>
@@ -287,7 +309,9 @@ export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
export function scaleTableElementSize( export function scaleTableElementSize(
elem: TemplateElement, elem: TemplateElement,
newWidth: number, newWidth: number,
newHeight: number newHeight: number,
startWidth = elem.width,
startHeight = elem.height
): TemplateElement { ): TemplateElement {
if (elem.type !== 'table') { if (elem.type !== 'table') {
return { ...elem, width: newWidth, height: newHeight }; return { ...elem, width: newWidth, height: newHeight };
@@ -296,8 +320,8 @@ export function scaleTableElementSize(
elem, elem,
newWidth, newWidth,
newHeight, newHeight,
elem.width, startWidth,
elem.height, startHeight,
getTableRowHeights(elem), getTableRowHeights(elem),
getTableColWidths(elem) getTableColWidths(elem)
); );

View File

@@ -422,12 +422,17 @@ function drawTableGridLines(
ctx.beginPath(); ctx.beginPath();
const inset = borderW / 2; const inset = borderW / 2;
ctx.rect( const right = left + width;
alignStrokeCoord(left + inset), const bottom = top + height;
alignStrokeCoord(top + inset), // 外框:描边完全落在网格区域内,避免 clip / 画布边缘裁切右、下边
Math.max(0, width - borderW), ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
Math.max(0, height - borderW) 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++) { for (let r = 1; r < rows; r++) {
const y = alignStrokeCoord(rowBounds[r]); const y = alignStrokeCoord(rowBounds[r]);
@@ -532,8 +537,8 @@ async function drawTableElement(
); );
} }
drawTableGridLines(ctx, elem, ex, ey, scale);
ctx.restore(); ctx.restore();
drawTableGridLines(ctx, elem, ex, ey, scale);
} }
function drawImagePlaceholder( function drawImagePlaceholder(

View File

@@ -56,6 +56,36 @@ export function getTableColWidths(elem: TemplateElement): number[] {
return Array.from({ length: cols }, () => even); 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保持相对比例末项吸收取整误差 */ /** 将轨道尺寸总和调整为 targetTotal保持相对比例末项吸收取整误差 */
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] { function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
if (tracks.length === 0) return []; if (tracks.length === 0) return [];
@@ -73,6 +103,48 @@ function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): n
return scaled; 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 条轨道(末项吸收取整误差) */ /** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
function distributeTracks(total: number, count: number, min = 0.5): number[] { function distributeTracks(total: number, count: number, min = 0.5): number[] {
if (count <= 0) return []; if (count <= 0) return [];
@@ -132,8 +204,14 @@ export function scaleTableTracks(
const scaleW = newInnerW / startInnerW; const scaleW = newInnerW / startInnerW;
const scaleH = newInnerH / startInnerH; const scaleH = newInnerH / startInnerH;
const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH))); const tableRowHeights = scaleTracksToExactTotal(
const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW))); startRowHeights.map((h) => h * scaleH),
newInnerH
);
const tableColWidths = scaleTracksToExactTotal(
startColWidths.map((w) => w * scaleW),
newInnerW
);
return { return {
...elem, ...elem,
@@ -456,14 +534,27 @@ export function updateTableRowHeight(
rowIndex: number, rowIndex: number,
heightMm: number heightMm: number
): TemplateElement { ): TemplateElement {
const rowHeights = [...getTableRowHeights(elem)]; if (rowIndex < 0 || rowIndex >= getTableRows(elem)) return elem;
if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem;
rowHeights[rowIndex] = Math.max(0.5, round1(heightMm));
const pad = resolvePaddingMm(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 { return {
...elem, ...elem,
tableRowHeights: rowHeights, tableRowHeights: nextHeights,
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), height: round1(newInnerH + pad.top + pad.bottom),
};
}
return {
...elem,
tableRowHeights: resizeTrackKeepingTotal(rowHeights, rowIndex, heightMm),
height: elem.height,
}; };
} }
@@ -472,14 +563,55 @@ export function updateTableColWidth(
colIndex: number, colIndex: number,
widthMm: number widthMm: number
): TemplateElement { ): TemplateElement {
const colWidths = [...getTableColWidths(elem)]; if (colIndex < 0 || colIndex >= getTableCols(elem)) return elem;
if (colIndex < 0 || colIndex >= colWidths.length) return elem;
colWidths[colIndex] = Math.max(0.5, round1(widthMm));
const pad = resolvePaddingMm(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 { return {
...elem, ...elem,
tableColWidths: colWidths, tableColWidths: nextWidths,
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), width: round1(newInnerW + pad.left + pad.right),
};
}
return {
...elem,
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,
}; };
} }

View File

@@ -30,6 +30,21 @@ export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
/** 粘贴副本时相对原位置的偏移 (mm) */ /** 粘贴副本时相对原位置的偏移 (mm) */
export const PASTE_ELEMENT_OFFSET_MM = 2; 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用于复制/粘贴 */ /** 深拷贝元素并生成新 id用于复制/粘贴 */
export function cloneTemplateElementsForPaste( export function cloneTemplateElementsForPaste(
elements: TemplateElement[], elements: TemplateElement[],