From 38b17c75894a9bb258bf596571fb84cf0c81207c Mon Sep 17 00:00:00 2001 From: iqudoo Date: Sat, 13 Jun 2026 13:50:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 29 +- src/components/LabelDesigner.tsx | 191 ++++++++- src/components/MobileDesignView.tsx | 9 + src/components/PropSidebar.tsx | 123 ++++-- src/components/TableCellTextFields.tsx | 406 ++++++++++++++++++ src/components/TablePropertyFields.tsx | 293 +++++++++++++ src/labelRenderer.ts | 301 ++++++++++++- src/tableUtils.ts | 560 +++++++++++++++++++++++++ src/types.ts | 51 ++- src/utils.ts | 26 +- 10 files changed, 1933 insertions(+), 56 deletions(-) create mode 100644 src/components/TableCellTextFields.tsx create mode 100644 src/components/TablePropertyFields.tsx create mode 100644 src/tableUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 92dc7d1..e704fb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { PropSidebar } from './components/PropSidebar'; import { ExportSidebar } from './components/ExportSidebar'; import { MobileExportView } from './components/MobileExportView'; import { MobileDesignView } from './components/MobileDesignView'; +import type { TableCellCoord } from './tableUtils'; import { LayoutPreview } from './components/PreviewGrid'; import { CanvasLabelImage } from './components/CanvasLabelImage'; import { renderLabelToDataUrl } from './labelRenderer'; @@ -145,6 +146,7 @@ export default function App() { // --- 5. Selection and Active item states --- const [selectedElementIds, setSelectedElementIds] = useState([]); + const [selectedTableCells, setSelectedTableCells] = useState([]); const [activeRowIndex, setActiveRowIndex] = useState(0); // Modal / Inline input values for template creations const [showAddTmplForm, setShowAddTmplForm] = useState(false); @@ -215,6 +217,21 @@ export default function App() { handleTemplateChange(next); }, [activeTemplate, designHistory, templates]); + const handleSelectElements = useCallback( + (ids: string[]) => { + setSelectedElementIds(ids); + if (ids.length !== 1) { + setSelectedTableCells([]); + return; + } + const el = activeTemplate?.elements.find((e) => e.id === ids[0]); + if (!el || el.type !== 'table') { + setSelectedTableCells([]); + } + }, + [activeTemplate] + ); + useEffect(() => { if (workflowStep !== 'template-design' || !activeTemplate?.id) return; const needReset = @@ -1131,7 +1148,9 @@ export default function App() { template={activeTemplate} paper={paper} selectedElementIds={selectedElementIds} - onSelectElements={setSelectedElementIds} + onSelectElements={handleSelectElements} + selectedTableCells={selectedTableCells} + onSelectTableCells={setSelectedTableCells} onChangeTemplate={handleDesignTemplateChange} canUndo={designHistory.canUndo} canRedo={designHistory.canRedo} @@ -1198,13 +1217,17 @@ export default function App() { template={activeTemplate} onChange={handleDesignTemplateChange} selectedElementIds={selectedElementIds} - onSelectElements={setSelectedElementIds} + onSelectElements={handleSelectElements} + selectedTableCells={selectedTableCells} + onSelectTableCells={setSelectedTableCells} /> { }} paper={paper} diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index 09b0ac8..f0ebeda 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -2,6 +2,17 @@ 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 { + createDefaultTableElement, + getTableColWidths, + getTableRowHeights, + iterTableCells, + getCellRectMm, + toggleTableCellSelection, + scaleTableTracks, + getCellAnchor, + type TableCellCoord, +} from '../tableUtils'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { CanvasLabelImage } from './CanvasLabelImage'; import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview'; @@ -16,6 +27,7 @@ import { Image as ImageIcon, Move, Trash2, + Table2, } from 'lucide-react'; interface LabelDesignerProps { @@ -23,12 +35,14 @@ interface LabelDesignerProps { onChange: (updated: LabelTemplate) => void; selectedElementIds: string[]; onSelectElements: (ids: string[]) => void; + selectedTableCells?: TableCellCoord[]; + onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void; variant?: 'desktop' | 'mobile'; /** 微调等场景下隐藏选区框与缩放手柄 */ suppressSelectionChrome?: boolean; } -type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode'; +type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table'; type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se'; const MIN_ELEM_W = 2; @@ -194,6 +208,8 @@ export const LabelDesigner: React.FC = ({ onChange, selectedElementIds, onSelectElements, + selectedTableCells = [], + onSelectTableCells, variant = 'desktop', suppressSelectionChrome = false, }) => { @@ -256,6 +272,8 @@ export const LabelDesigner: React.FC = ({ startElemY: number; startElemW: number; startElemH: number; + startTableRowHeights?: number[]; + startTableColWidths?: number[]; groupStartPositions?: Record; } | null>(null); @@ -269,6 +287,15 @@ export const LabelDesigner: React.FC = ({ const ignoreCanvasClickRef = useRef(false); const dragMovedRef = useRef(false); + const tableCellPointerRef = useRef<{ + row: number; + col: number; + elementId: string; + startX: number; + startY: number; + pointerId: number; + dragStarted: boolean; + } | null>(null); const multiTouchActiveRef = useRef(false); const touchPointerMapRef = useRef>(new Map()); const marqueeRef = useRef(marqueeState); @@ -297,7 +324,8 @@ export const LabelDesigner: React.FC = ({ barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', - backgroundColor: 'transparent', + backgroundColor: '#ffffff', + backgroundOpacity: 0, imageFit: 'contain', }; onChange({ @@ -311,12 +339,14 @@ export const LabelDesigner: React.FC = ({ ); const addElement = useCallback( - (type: 'text' | 'barcode' | 'qrcode') => { + (type: 'text' | 'barcode' | 'qrcode' | 'table') => { let newElement: TemplateElement; const id = `${type}_${Date.now()}`; const n = template.elements.length + 1; - if (type === 'text') { + if (type === 'table') { + newElement = createDefaultTableElement(id, n); + } else if (type === 'text') { newElement = { id, type: 'text', @@ -334,7 +364,8 @@ export const LabelDesigner: React.FC = ({ barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', - backgroundColor: 'transparent', + backgroundColor: '#ffffff', + backgroundOpacity: 0, }; } else if (type === 'barcode') { newElement = { @@ -352,7 +383,8 @@ export const LabelDesigner: React.FC = ({ barcodeFormat: 'CODE128', showText: true, fontFamily: 'mono', - backgroundColor: 'transparent', + backgroundColor: '#ffffff', + backgroundOpacity: 0, }; } else { newElement = { @@ -370,7 +402,8 @@ export const LabelDesigner: React.FC = ({ barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', - backgroundColor: 'transparent', + backgroundColor: '#ffffff', + backgroundOpacity: 0, }; } @@ -883,11 +916,25 @@ export const LabelDesigner: React.FC = ({ corner ); - const updated = template.elements.map((el) => - el.id === dragState.elementId - ? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h } - : el - ); + const updated = template.elements.map((el) => { + if (el.id !== dragState.elementId) return el; + if ( + el.type === 'table' && + dragState.startTableRowHeights && + dragState.startTableColWidths + ) { + return scaleTableTracks( + { ...el, x: rect.x, y: rect.y }, + rect.w, + rect.h, + dragState.startElemW, + dragState.startElemH, + dragState.startTableRowHeights, + dragState.startTableColWidths + ); + } + return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }; + }); pendingDragElementsRef.current = updated; const paintInteractionFloat = () => { @@ -1109,6 +1156,71 @@ export const LabelDesigner: React.FC = ({ }); }; + const handleTableCellPointerDown = ( + e: React.PointerEvent, + element: TemplateElement, + row: number, + col: number + ) => { + if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return; + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + tableCellPointerRef.current = { + row, + col, + elementId: element.id, + startX: e.clientX, + startY: e.clientY, + pointerId: e.pointerId, + dragStarted: false, + }; + }; + + const handleTableCellPointerMove = ( + e: React.PointerEvent, + element: TemplateElement + ) => { + const pending = tableCellPointerRef.current; + if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) { + return; + } + if (pending.dragStarted || element.locked) return; + + const dx = e.clientX - pending.startX; + const dy = e.clientY - pending.startY; + if ( + Math.abs(dx) > DRAG_START_THRESHOLD_PX || + Math.abs(dy) > DRAG_START_THRESHOLD_PX + ) { + pending.dragStarted = true; + dragMovedRef.current = true; + tableCellPointerRef.current = null; + try { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + handleDragStart(e, element, 'drag'); + } + }; + + const handleTableCellPointerUp = (e: React.PointerEvent) => { + const pending = tableCellPointerRef.current; + if (!pending || pending.pointerId !== e.pointerId) return; + + if (!pending.dragStarted && onSelectTableCells) { + onSelectTableCells((prev) => + toggleTableCellSelection(prev, { row: pending.row, col: pending.col }) + ); + } + tableCellPointerRef.current = null; + try { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + }; + const handleResizeStart = ( e: React.PointerEvent, element: TemplateElement, @@ -1135,6 +1247,10 @@ export const LabelDesigner: React.FC = ({ startElemY: element.y, startElemW: element.width, startElemH: element.height, + startTableRowHeights: + element.type === 'table' ? getTableRowHeights(element) : undefined, + startTableColWidths: + element.type === 'table' ? getTableColWidths(element) : undefined, }); }; @@ -1300,8 +1416,9 @@ export const LabelDesigner: React.FC = ({ const overlayElements = filterVisibleElements(template.elements); const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [ - { id: 'move', icon: , label: '移动工具', shortcut: 'V' }, - { id: 'text', icon: , label: '文本工具', shortcut: 'T' }, + { id: 'move', icon: , label: '移动', shortcut: 'V' }, + { id: 'text', icon: , label: '文本', shortcut: 'T' }, + { id: 'table', icon: , label: '表格' }, { id: 'barcode', icon: , label: '条形码' }, { id: 'qrcode', icon: , label: '二维码' }, ]; @@ -1734,6 +1851,52 @@ export const LabelDesigner: React.FC = ({ {element.name} )} + + {isSelected && + !hideSelectionChrome && + !isLocked && + selectedElementIds.length === 1 && + overlayElement.type === 'table' && + onSelectTableCells && ( +
+ {iterTableCells(overlayElement).map(({ row, col }) => { + const rect = getCellRectMm(overlayElement, row, col); + const cellX = mmToPx(rect.x, displayZoom); + const cellY = mmToPx(rect.y, displayZoom); + const cellW = mmToPx(rect.width, displayZoom); + const cellH = mmToPx(rect.height, displayZoom); + const isCellSelected = selectedTableCells.some((c) => { + const anchor = getCellAnchor(overlayElement, c.row, c.col); + return anchor.row === row && anchor.col === col; + }); + return ( +
+ handleTableCellPointerDown(e, overlayElement, row, col) + } + onPointerMove={(e) => + handleTableCellPointerMove(e, overlayElement) + } + onPointerUp={(e) => handleTableCellPointerUp(e)} + onPointerCancel={(e) => handleTableCellPointerUp(e)} + title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`} + /> + ); + })} +
+ )}
); })} diff --git a/src/components/MobileDesignView.tsx b/src/components/MobileDesignView.tsx index 9e34bbe..b1aa125 100644 --- a/src/components/MobileDesignView.tsx +++ b/src/components/MobileDesignView.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react'; import { LabelTemplate, PaperConfig } from '../types'; +import type { TableCellCoord } from '../tableUtils'; import { LabelDesigner } from './LabelDesigner'; import { PropSidebar, MobilePropModule } from './PropSidebar'; @@ -9,6 +10,8 @@ interface MobileDesignViewProps { paper: PaperConfig; selectedElementIds: string[]; onSelectElements: (ids: string[]) => void; + selectedTableCells: TableCellCoord[]; + onSelectTableCells: (cells: TableCellCoord[]) => void; onChangeTemplate: (updated: LabelTemplate) => void; canUndo: boolean; canRedo: boolean; @@ -23,6 +26,8 @@ export const MobileDesignView: React.FC = ({ paper, selectedElementIds, onSelectElements, + selectedTableCells, + onSelectTableCells, onChangeTemplate, canUndo, canRedo, @@ -82,6 +87,8 @@ export const MobileDesignView: React.FC = ({ onChange={onChangeTemplate} selectedElementIds={selectedElementIds} onSelectElements={onSelectElements} + selectedTableCells={selectedTableCells} + onSelectTableCells={onSelectTableCells} suppressSelectionChrome={mobileModule === 'nudge'} /> @@ -93,6 +100,8 @@ export const MobileDesignView: React.FC = ({ onChangeTemplate={onChangeTemplate} selectedElementIds={selectedElementIds} onSelectElements={onSelectElements} + selectedTableCells={selectedTableCells} + onSelectTableCells={onSelectTableCells} activeTab="element" onChangeTab={() => {}} paper={paper} diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index 9c1b7f8..fc2ecec 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -24,6 +24,13 @@ import { FieldSelect } from './PsSelect'; import { CommitInput } from './CommitInput'; import { useAppDialog } from './AppDialog'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; +import { + TablePropertyFields, + TableCellContentFields, + scaleTableElementSize, +} from './TablePropertyFields'; +import type { TableCellCoord } from '../tableUtils'; +import { expandTableForPadding } from '../tableUtils'; import { Sliders, LayoutGrid, @@ -63,6 +70,7 @@ import { QrCode, Image as ImageIcon, Upload, + Table2, } from 'lucide-react'; const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2'; @@ -166,6 +174,8 @@ interface PropSidebarProps { onChangeTab: (tab: 'element' | 'paper') => void; paper: PaperConfig; onSelectElements?: (ids: string[]) => void; + selectedTableCells?: TableCellCoord[]; + onSelectTableCells?: (cells: TableCellCoord[]) => void; variant?: 'desktop' | 'mobile'; mobileModule?: MobilePropModule; onMobileModuleChange?: (module: MobilePropModule) => void; @@ -186,6 +196,8 @@ export const PropSidebar: React.FC = ({ onChangeTab, paper, onSelectElements, + selectedTableCells = [], + onSelectTableCells, variant = 'desktop', mobileModule: mobileModuleProp, onMobileModuleChange, @@ -266,6 +278,14 @@ export const PropSidebar: React.FC = ({ }); }; + const commitTableAwareChange = (updatedElem: TemplateElement) => { + if (updatedElem.type === 'table') { + handleElemChange(expandTableForPadding(updatedElem)); + } else { + handleElemChange(updatedElem); + } + }; + const nudgeableElementIds = useMemo(() => { const idSet = new Set(selectedElementIds); return template.elements @@ -639,43 +659,64 @@ export const PropSidebar: React.FC = ({ )} - handleElemChange({ ...selectedElem, content: val })} - rows={selectedElem.type === 'image' ? 3 : 2} - className="ps-field resize-none" - placeholder={ - selectedElem.type === 'image' - ? '图片 URL 或 data URI,也可使用 {变量名}' - : '输入文本,使用 {变量名} 引用变量' - } - /> - {templateVariables.length > 0 && ( -
-
插入变量到内容
-
- {templateVariables.map((v) => ( - - ))} -
-
+ {selectedElem.type !== 'table' && ( + <> + handleElemChange({ ...selectedElem, content: val })} + rows={selectedElem.type === 'image' ? 3 : 2} + className="ps-field resize-none" + placeholder={ + selectedElem.type === 'image' + ? '图片 URL 或 data URI,也可使用 {变量名}' + : '输入文本,使用 {变量名} 引用变量' + } + /> + {templateVariables.length > 0 && ( +
+
插入变量到内容
+
+ {templateVariables.map((v) => ( + + ))} +
+
+ )} + + )} + {selectedElem.type === 'table' && ( +

+ 表格各单元格内容独立设置,默认为空。点击画布上的单元格后在属性面板编辑。 +

)} {(selectedElem.type === 'text' || selectedElem.type === 'barcode' || selectedElem.type === 'qrcode' || - selectedElem.type === 'image') && ( + selectedElem.type === 'image' || + selectedElem.type === 'table') && (
+ {selectedElem.type === 'table' ? ( + + ) : ( + <> {selectedElem.type === 'text' && ( )} + + )}
)} {renderVariableManagementBlock()} @@ -954,6 +995,15 @@ export const PropSidebar: React.FC = ({ {/* Text Settings */} + {selectedElem.type === 'table' && selectedElementIds.length === 1 && ( + + )} {selectedElem.type === 'text' && (
@@ -1406,9 +1456,11 @@ export const PropSidebar: React.FC = ({ value={selectedElem.width} onCommit={(val) => { const wVal = Math.max(2, Number(val) || 2); - const updated = { ...selectedElem, width: wVal }; + let updated = { ...selectedElem, width: wVal }; if (selectedElem.type === 'qrcode') { updated.height = wVal; + } else if (selectedElem.type === 'table') { + updated = scaleTableElementSize(updated, wVal, updated.height); } handleElemChange(updated); }} @@ -1425,7 +1477,11 @@ export const PropSidebar: React.FC = ({ disabled={selectedElem.type === 'qrcode'} onCommit={(val) => { const hVal = Math.max(2, Number(val) || 2); - handleElemChange({ ...selectedElem, height: hVal }); + if (selectedElem.type === 'table') { + handleElemChange(scaleTableElementSize(selectedElem, selectedElem.width, hVal)); + } else { + handleElemChange({ ...selectedElem, height: hVal }); + } }} className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : '' }`} @@ -1508,7 +1564,7 @@ export const PropSidebar: React.FC = ({ if (v === 'transparent' || v === '') { handleElemChange({ ...selectedElem, - backgroundColor: 'transparent', + backgroundColor: '#ffffff', backgroundOpacity: 0, }); } else { @@ -1573,7 +1629,7 @@ export const PropSidebar: React.FC = ({ step="0.5" value={selectedElem.padding ?? 0} onCommit={(val) => - handleElemChange({ + commitTableAwareChange({ ...selectedElem, padding: Math.max(0, Math.min(10, Number(val) || 0)), }) @@ -1606,7 +1662,7 @@ export const PropSidebar: React.FC = ({ } else { updated[key] = Math.max(0, Math.min(10, Number(val) || 0)); } - handleElemChange(updated); + commitTableAwareChange(updated); }} className="ps-field font-mono text-[10px] flex-1 min-w-0" placeholder="—" @@ -1863,6 +1919,7 @@ export const PropSidebar: React.FC = ({ {elem.type === 'barcode' && } {elem.type === 'qrcode' && } {elem.type === 'image' && } + {elem.type === 'table' && } {elem.name} diff --git a/src/components/TableCellTextFields.tsx b/src/components/TableCellTextFields.tsx new file mode 100644 index 0000000..ac962f6 --- /dev/null +++ b/src/components/TableCellTextFields.tsx @@ -0,0 +1,406 @@ +import React from 'react'; +import { TemplateElement } from '../types'; +import { CommitInput } from './CommitInput'; +import { FieldSelect } from './PsSelect'; +import { ValueExtractFields } from './ValueExtractFields'; +import { TextDisplayAffixFields } from './TextDisplayAffixFields'; +import { + applyCellTextElementChange, + getCellSpan, + getEffectiveCellStyle, +} from '../tableUtils'; +import { + Type, + Bold, + Italic, + Underline, + Strikethrough, + AlignLeft, + AlignCenter, + AlignRight, + AlignVerticalJustifyStart, + AlignVerticalJustifyCenter, + AlignVerticalJustifyEnd, +} from 'lucide-react'; + +const PropInput = CommitInput; + +interface TableCellTextFieldsProps { + elem: TemplateElement; + row: number; + col: number; + onChange: (elem: TemplateElement) => void; + templateVariables?: string[]; +} + +export const TableCellTextFields: React.FC = ({ + elem, + row, + col, + onChange, + templateVariables = [], +}) => { + const textElem = getEffectiveCellStyle(elem, row, col); + const span = getCellSpan(elem, row, col); + + const patchText = (updated: TemplateElement) => { + onChange(applyCellTextElementChange(elem, row, col, updated)); + }; + + return ( +
+ + + 单元格文本 + + {(span.rowSpan > 1 || span.colSpan > 1) && ( +

合并区域 {span.rowSpan}×{span.colSpan}

+ )} + +
+ +