表格支持

This commit is contained in:
iqudoo
2026-06-13 13:50:42 +08:00
parent 318569c757
commit 38b17c7589
10 changed files with 1933 additions and 56 deletions

View File

@@ -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<LabelDesignerProps> = ({
onChange,
selectedElementIds,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
suppressSelectionChrome = false,
}) => {
@@ -256,6 +272,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: number;
startElemW: number;
startElemH: number;
startTableRowHeights?: number[];
startTableColWidths?: number[];
groupStartPositions?: Record<string, { x: number; y: number }>;
} | null>(null);
@@ -269,6 +287,15 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
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<Map<number, { x: number; y: number }>>(new Map());
const marqueeRef = useRef(marqueeState);
@@ -297,7 +324,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
imageFit: 'contain',
};
onChange({
@@ -311,12 +339,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
);
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<LabelDesignerProps> = ({
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<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'mono',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else {
newElement = {
@@ -370,7 +402,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
}
@@ -883,11 +916,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
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<LabelDesignerProps> = ({
});
};
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<LabelDesignerProps> = ({
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<LabelDesignerProps> = ({
const overlayElements = filterVisibleElements(template.elements);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本', shortcut: 'T' },
{ id: 'table', icon: <Table2 className="w-[18px] h-[18px]" />, label: '表格' },
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
@@ -1734,6 +1851,52 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{element.name}
</div>
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
overlayElement.type === 'table' &&
onSelectTableCells && (
<div className="absolute inset-0 z-10 pointer-events-none">
{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 (
<div
key={`cell-${row}-${col}`}
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing border touch-none ${
isCellSelected
? 'bg-[#31a8ff]/25 border-[#31a8ff]'
: 'border-transparent hover:bg-[#31a8ff]/10 hover:border-[#31a8ff]/40'
}`}
style={{
left: `${cellX}px`,
top: `${cellY}px`,
width: `${cellW}px`,
height: `${cellH}px`,
}}
onPointerDown={(e) =>
handleTableCellPointerDown(e, overlayElement, row, col)
}
onPointerMove={(e) =>
handleTableCellPointerMove(e, overlayElement)
}
onPointerUp={(e) => handleTableCellPointerUp(e)}
onPointerCancel={(e) => handleTableCellPointerUp(e)}
title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`}
/>
);
})}
</div>
)}
</div>
);
})}