表格支持
This commit is contained in:
29
src/App.tsx
29
src/App.tsx
@@ -18,6 +18,7 @@ import { PropSidebar } from './components/PropSidebar';
|
|||||||
import { ExportSidebar } from './components/ExportSidebar';
|
import { ExportSidebar } from './components/ExportSidebar';
|
||||||
import { MobileExportView } from './components/MobileExportView';
|
import { MobileExportView } from './components/MobileExportView';
|
||||||
import { MobileDesignView } from './components/MobileDesignView';
|
import { MobileDesignView } from './components/MobileDesignView';
|
||||||
|
import type { TableCellCoord } from './tableUtils';
|
||||||
import { LayoutPreview } from './components/PreviewGrid';
|
import { LayoutPreview } from './components/PreviewGrid';
|
||||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||||
import { renderLabelToDataUrl } from './labelRenderer';
|
import { renderLabelToDataUrl } from './labelRenderer';
|
||||||
@@ -145,6 +146,7 @@ export default function App() {
|
|||||||
|
|
||||||
// --- 5. Selection and Active item states ---
|
// --- 5. Selection and Active item states ---
|
||||||
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
|
||||||
|
const [selectedTableCells, setSelectedTableCells] = useState<TableCellCoord[]>([]);
|
||||||
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
|
const [activeRowIndex, setActiveRowIndex] = useState<number>(0);
|
||||||
// Modal / Inline input values for template creations
|
// Modal / Inline input values for template creations
|
||||||
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
||||||
@@ -215,6 +217,21 @@ export default function App() {
|
|||||||
handleTemplateChange(next);
|
handleTemplateChange(next);
|
||||||
}, [activeTemplate, designHistory, templates]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
|
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
|
||||||
const needReset =
|
const needReset =
|
||||||
@@ -1131,7 +1148,9 @@ export default function App() {
|
|||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
paper={paper}
|
paper={paper}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={setSelectedElementIds}
|
onSelectElements={handleSelectElements}
|
||||||
|
selectedTableCells={selectedTableCells}
|
||||||
|
onSelectTableCells={setSelectedTableCells}
|
||||||
onChangeTemplate={handleDesignTemplateChange}
|
onChangeTemplate={handleDesignTemplateChange}
|
||||||
canUndo={designHistory.canUndo}
|
canUndo={designHistory.canUndo}
|
||||||
canRedo={designHistory.canRedo}
|
canRedo={designHistory.canRedo}
|
||||||
@@ -1198,13 +1217,17 @@ export default function App() {
|
|||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
onChange={handleDesignTemplateChange}
|
onChange={handleDesignTemplateChange}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={setSelectedElementIds}
|
onSelectElements={handleSelectElements}
|
||||||
|
selectedTableCells={selectedTableCells}
|
||||||
|
onSelectTableCells={setSelectedTableCells}
|
||||||
/>
|
/>
|
||||||
<PropSidebar
|
<PropSidebar
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
onChangeTemplate={handleDesignTemplateChange}
|
onChangeTemplate={handleDesignTemplateChange}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={setSelectedElementIds}
|
onSelectElements={handleSelectElements}
|
||||||
|
selectedTableCells={selectedTableCells}
|
||||||
|
onSelectTableCells={setSelectedTableCells}
|
||||||
activeTab="element"
|
activeTab="element"
|
||||||
onChangeTab={() => { }}
|
onChangeTab={() => { }}
|
||||||
paper={paper}
|
paper={paper}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ 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 } from '../utils';
|
||||||
|
import {
|
||||||
|
createDefaultTableElement,
|
||||||
|
getTableColWidths,
|
||||||
|
getTableRowHeights,
|
||||||
|
iterTableCells,
|
||||||
|
getCellRectMm,
|
||||||
|
toggleTableCellSelection,
|
||||||
|
scaleTableTracks,
|
||||||
|
getCellAnchor,
|
||||||
|
type TableCellCoord,
|
||||||
|
} from '../tableUtils';
|
||||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
||||||
@@ -16,6 +27,7 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Move,
|
Move,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Table2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LabelDesignerProps {
|
interface LabelDesignerProps {
|
||||||
@@ -23,12 +35,14 @@ interface LabelDesignerProps {
|
|||||||
onChange: (updated: LabelTemplate) => void;
|
onChange: (updated: LabelTemplate) => void;
|
||||||
selectedElementIds: string[];
|
selectedElementIds: string[];
|
||||||
onSelectElements: (ids: string[]) => void;
|
onSelectElements: (ids: string[]) => void;
|
||||||
|
selectedTableCells?: TableCellCoord[];
|
||||||
|
onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void;
|
||||||
variant?: 'desktop' | 'mobile';
|
variant?: 'desktop' | 'mobile';
|
||||||
/** 微调等场景下隐藏选区框与缩放手柄 */
|
/** 微调等场景下隐藏选区框与缩放手柄 */
|
||||||
suppressSelectionChrome?: boolean;
|
suppressSelectionChrome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
|
||||||
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
|
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
|
||||||
|
|
||||||
const MIN_ELEM_W = 2;
|
const MIN_ELEM_W = 2;
|
||||||
@@ -194,6 +208,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
|
selectedTableCells = [],
|
||||||
|
onSelectTableCells,
|
||||||
variant = 'desktop',
|
variant = 'desktop',
|
||||||
suppressSelectionChrome = false,
|
suppressSelectionChrome = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -256,6 +272,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
startElemY: number;
|
startElemY: number;
|
||||||
startElemW: number;
|
startElemW: number;
|
||||||
startElemH: number;
|
startElemH: number;
|
||||||
|
startTableRowHeights?: number[];
|
||||||
|
startTableColWidths?: number[];
|
||||||
groupStartPositions?: Record<string, { x: number; y: number }>;
|
groupStartPositions?: Record<string, { x: number; y: number }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
@@ -269,6 +287,15 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
const ignoreCanvasClickRef = useRef(false);
|
const ignoreCanvasClickRef = useRef(false);
|
||||||
const dragMovedRef = 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 multiTouchActiveRef = useRef(false);
|
||||||
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||||
const marqueeRef = useRef(marqueeState);
|
const marqueeRef = useRef(marqueeState);
|
||||||
@@ -297,7 +324,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
barcodeFormat: 'CODE128',
|
barcodeFormat: 'CODE128',
|
||||||
showText: false,
|
showText: false,
|
||||||
fontFamily: 'sans',
|
fontFamily: 'sans',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundOpacity: 0,
|
||||||
imageFit: 'contain',
|
imageFit: 'contain',
|
||||||
};
|
};
|
||||||
onChange({
|
onChange({
|
||||||
@@ -311,12 +339,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const addElement = useCallback(
|
const addElement = useCallback(
|
||||||
(type: 'text' | 'barcode' | 'qrcode') => {
|
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
||||||
let newElement: TemplateElement;
|
let newElement: TemplateElement;
|
||||||
const id = `${type}_${Date.now()}`;
|
const id = `${type}_${Date.now()}`;
|
||||||
const n = template.elements.length + 1;
|
const n = template.elements.length + 1;
|
||||||
|
|
||||||
if (type === 'text') {
|
if (type === 'table') {
|
||||||
|
newElement = createDefaultTableElement(id, n);
|
||||||
|
} else if (type === 'text') {
|
||||||
newElement = {
|
newElement = {
|
||||||
id,
|
id,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -334,7 +364,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
barcodeFormat: 'CODE128',
|
barcodeFormat: 'CODE128',
|
||||||
showText: false,
|
showText: false,
|
||||||
fontFamily: 'sans',
|
fontFamily: 'sans',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundOpacity: 0,
|
||||||
};
|
};
|
||||||
} else if (type === 'barcode') {
|
} else if (type === 'barcode') {
|
||||||
newElement = {
|
newElement = {
|
||||||
@@ -352,7 +383,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
barcodeFormat: 'CODE128',
|
barcodeFormat: 'CODE128',
|
||||||
showText: true,
|
showText: true,
|
||||||
fontFamily: 'mono',
|
fontFamily: 'mono',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundOpacity: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
newElement = {
|
newElement = {
|
||||||
@@ -370,7 +402,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
barcodeFormat: 'CODE128',
|
barcodeFormat: 'CODE128',
|
||||||
showText: false,
|
showText: false,
|
||||||
fontFamily: 'sans',
|
fontFamily: 'sans',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundOpacity: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,11 +916,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
corner
|
corner
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = template.elements.map((el) =>
|
const updated = template.elements.map((el) => {
|
||||||
el.id === dragState.elementId
|
if (el.id !== dragState.elementId) return el;
|
||||||
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
|
if (
|
||||||
: el
|
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;
|
pendingDragElementsRef.current = updated;
|
||||||
|
|
||||||
const paintInteractionFloat = () => {
|
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 = (
|
const handleResizeStart = (
|
||||||
e: React.PointerEvent,
|
e: React.PointerEvent,
|
||||||
element: TemplateElement,
|
element: TemplateElement,
|
||||||
@@ -1135,6 +1247,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
startElemY: element.y,
|
startElemY: element.y,
|
||||||
startElemW: element.width,
|
startElemW: element.width,
|
||||||
startElemH: element.height,
|
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 overlayElements = filterVisibleElements(template.elements);
|
||||||
|
|
||||||
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
|
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
|
||||||
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
|
{ 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: '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: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
|
||||||
{ id: 'qrcode', icon: <QrCode 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}
|
{element.name}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
|
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
|
||||||
import { LabelTemplate, PaperConfig } from '../types';
|
import { LabelTemplate, PaperConfig } from '../types';
|
||||||
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
import { LabelDesigner } from './LabelDesigner';
|
import { LabelDesigner } from './LabelDesigner';
|
||||||
import { PropSidebar, MobilePropModule } from './PropSidebar';
|
import { PropSidebar, MobilePropModule } from './PropSidebar';
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ interface MobileDesignViewProps {
|
|||||||
paper: PaperConfig;
|
paper: PaperConfig;
|
||||||
selectedElementIds: string[];
|
selectedElementIds: string[];
|
||||||
onSelectElements: (ids: string[]) => void;
|
onSelectElements: (ids: string[]) => void;
|
||||||
|
selectedTableCells: TableCellCoord[];
|
||||||
|
onSelectTableCells: (cells: TableCellCoord[]) => void;
|
||||||
onChangeTemplate: (updated: LabelTemplate) => void;
|
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -23,6 +26,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
paper,
|
paper,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
|
selectedTableCells,
|
||||||
|
onSelectTableCells,
|
||||||
onChangeTemplate,
|
onChangeTemplate,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
@@ -82,6 +87,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
onChange={onChangeTemplate}
|
onChange={onChangeTemplate}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={onSelectElements}
|
onSelectElements={onSelectElements}
|
||||||
|
selectedTableCells={selectedTableCells}
|
||||||
|
onSelectTableCells={onSelectTableCells}
|
||||||
suppressSelectionChrome={mobileModule === 'nudge'}
|
suppressSelectionChrome={mobileModule === 'nudge'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +100,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
onChangeTemplate={onChangeTemplate}
|
onChangeTemplate={onChangeTemplate}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={onSelectElements}
|
onSelectElements={onSelectElements}
|
||||||
|
selectedTableCells={selectedTableCells}
|
||||||
|
onSelectTableCells={onSelectTableCells}
|
||||||
activeTab="element"
|
activeTab="element"
|
||||||
onChangeTab={() => {}}
|
onChangeTab={() => {}}
|
||||||
paper={paper}
|
paper={paper}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import { FieldSelect } from './PsSelect';
|
|||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { useAppDialog } from './AppDialog';
|
import { useAppDialog } from './AppDialog';
|
||||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
|
import {
|
||||||
|
TablePropertyFields,
|
||||||
|
TableCellContentFields,
|
||||||
|
scaleTableElementSize,
|
||||||
|
} from './TablePropertyFields';
|
||||||
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
|
import { expandTableForPadding } from '../tableUtils';
|
||||||
import {
|
import {
|
||||||
Sliders,
|
Sliders,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -63,6 +70,7 @@ import {
|
|||||||
QrCode,
|
QrCode,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Upload,
|
Upload,
|
||||||
|
Table2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
|
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
|
||||||
@@ -166,6 +174,8 @@ interface PropSidebarProps {
|
|||||||
onChangeTab: (tab: 'element' | 'paper') => void;
|
onChangeTab: (tab: 'element' | 'paper') => void;
|
||||||
paper: PaperConfig;
|
paper: PaperConfig;
|
||||||
onSelectElements?: (ids: string[]) => void;
|
onSelectElements?: (ids: string[]) => void;
|
||||||
|
selectedTableCells?: TableCellCoord[];
|
||||||
|
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||||
variant?: 'desktop' | 'mobile';
|
variant?: 'desktop' | 'mobile';
|
||||||
mobileModule?: MobilePropModule;
|
mobileModule?: MobilePropModule;
|
||||||
onMobileModuleChange?: (module: MobilePropModule) => void;
|
onMobileModuleChange?: (module: MobilePropModule) => void;
|
||||||
@@ -186,6 +196,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
onChangeTab,
|
onChangeTab,
|
||||||
paper,
|
paper,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
|
selectedTableCells = [],
|
||||||
|
onSelectTableCells,
|
||||||
variant = 'desktop',
|
variant = 'desktop',
|
||||||
mobileModule: mobileModuleProp,
|
mobileModule: mobileModuleProp,
|
||||||
onMobileModuleChange,
|
onMobileModuleChange,
|
||||||
@@ -266,6 +278,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commitTableAwareChange = (updatedElem: TemplateElement) => {
|
||||||
|
if (updatedElem.type === 'table') {
|
||||||
|
handleElemChange(expandTableForPadding(updatedElem));
|
||||||
|
} else {
|
||||||
|
handleElemChange(updatedElem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nudgeableElementIds = useMemo(() => {
|
const nudgeableElementIds = useMemo(() => {
|
||||||
const idSet = new Set(selectedElementIds);
|
const idSet = new Set(selectedElementIds);
|
||||||
return template.elements
|
return template.elements
|
||||||
@@ -639,43 +659,64 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PropTextarea
|
{selectedElem.type !== 'table' && (
|
||||||
value={selectedElem.content}
|
<>
|
||||||
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
|
<PropTextarea
|
||||||
rows={selectedElem.type === 'image' ? 3 : 2}
|
value={selectedElem.content}
|
||||||
className="ps-field resize-none"
|
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
|
||||||
placeholder={
|
rows={selectedElem.type === 'image' ? 3 : 2}
|
||||||
selectedElem.type === 'image'
|
className="ps-field resize-none"
|
||||||
? '图片 URL 或 data URI,也可使用 {变量名}'
|
placeholder={
|
||||||
: '输入文本,使用 {变量名} 引用变量'
|
selectedElem.type === 'image'
|
||||||
}
|
? '图片 URL 或 data URI,也可使用 {变量名}'
|
||||||
/>
|
: '输入文本,使用 {变量名} 引用变量'
|
||||||
{templateVariables.length > 0 && (
|
}
|
||||||
<div className="space-y-1">
|
/>
|
||||||
<div className="text-[9px] text-[#777] font-medium">插入变量到内容</div>
|
{templateVariables.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="space-y-1">
|
||||||
{templateVariables.map((v) => (
|
<div className="text-[9px] text-[#777] font-medium">插入变量到内容</div>
|
||||||
<button
|
<div className="flex flex-wrap gap-1">
|
||||||
key={v}
|
{templateVariables.map((v) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => insertVariable(v)}
|
key={v}
|
||||||
className="ps-btn text-[9px] font-mono"
|
type="button"
|
||||||
>
|
onClick={() => insertVariable(v)}
|
||||||
{`{${v}}`}
|
className="ps-btn text-[9px] font-mono"
|
||||||
</button>
|
>
|
||||||
))}
|
{`{${v}}`}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedElem.type === 'table' && (
|
||||||
|
<p className="text-[10px] text-[#777] leading-normal">
|
||||||
|
表格各单元格内容独立设置,默认为空。点击画布上的单元格后在属性面板编辑。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{(selectedElem.type === 'text' ||
|
{(selectedElem.type === 'text' ||
|
||||||
selectedElem.type === 'barcode' ||
|
selectedElem.type === 'barcode' ||
|
||||||
selectedElem.type === 'qrcode' ||
|
selectedElem.type === 'qrcode' ||
|
||||||
selectedElem.type === 'image') && (
|
selectedElem.type === 'image' ||
|
||||||
|
selectedElem.type === 'table') && (
|
||||||
<div className="pt-2 border-t border-[#3a3a3a]">
|
<div className="pt-2 border-t border-[#3a3a3a]">
|
||||||
|
{selectedElem.type === 'table' ? (
|
||||||
|
<TableCellContentFields
|
||||||
|
elem={selectedElem}
|
||||||
|
selectedCells={selectedTableCells}
|
||||||
|
onChange={handleElemChange}
|
||||||
|
templateVariables={templateVariables}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
|
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
|
||||||
{selectedElem.type === 'text' && (
|
{selectedElem.type === 'text' && (
|
||||||
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
|
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderVariableManagementBlock()}
|
{renderVariableManagementBlock()}
|
||||||
@@ -954,6 +995,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Settings */}
|
{/* Text Settings */}
|
||||||
|
{selectedElem.type === 'table' && selectedElementIds.length === 1 && (
|
||||||
|
<TablePropertyFields
|
||||||
|
elem={selectedElem}
|
||||||
|
selectedCells={selectedTableCells}
|
||||||
|
onChange={handleElemChange}
|
||||||
|
onSelectTableCells={onSelectTableCells}
|
||||||
|
templateVariables={templateVariables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectedElem.type === 'text' && (
|
{selectedElem.type === 'text' && (
|
||||||
<div className="space-y-3 ps-section-divider">
|
<div className="space-y-3 ps-section-divider">
|
||||||
<h5 className="ps-section-title">
|
<h5 className="ps-section-title">
|
||||||
@@ -1406,9 +1456,11 @@ 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);
|
||||||
const updated = { ...selectedElem, width: wVal };
|
let updated = { ...selectedElem, width: wVal };
|
||||||
if (selectedElem.type === 'qrcode') {
|
if (selectedElem.type === 'qrcode') {
|
||||||
updated.height = wVal;
|
updated.height = wVal;
|
||||||
|
} else if (selectedElem.type === 'table') {
|
||||||
|
updated = scaleTableElementSize(updated, wVal, updated.height);
|
||||||
}
|
}
|
||||||
handleElemChange(updated);
|
handleElemChange(updated);
|
||||||
}}
|
}}
|
||||||
@@ -1425,7 +1477,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
disabled={selectedElem.type === 'qrcode'}
|
disabled={selectedElem.type === 'qrcode'}
|
||||||
onCommit={(val) => {
|
onCommit={(val) => {
|
||||||
const hVal = Math.max(2, Number(val) || 2);
|
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' : ''
|
className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -1508,7 +1564,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
if (v === 'transparent' || v === '') {
|
if (v === 'transparent' || v === '') {
|
||||||
handleElemChange({
|
handleElemChange({
|
||||||
...selectedElem,
|
...selectedElem,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: '#ffffff',
|
||||||
backgroundOpacity: 0,
|
backgroundOpacity: 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1573,7 +1629,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
step="0.5"
|
step="0.5"
|
||||||
value={selectedElem.padding ?? 0}
|
value={selectedElem.padding ?? 0}
|
||||||
onCommit={(val) =>
|
onCommit={(val) =>
|
||||||
handleElemChange({
|
commitTableAwareChange({
|
||||||
...selectedElem,
|
...selectedElem,
|
||||||
padding: Math.max(0, Math.min(10, Number(val) || 0)),
|
padding: Math.max(0, Math.min(10, Number(val) || 0)),
|
||||||
})
|
})
|
||||||
@@ -1606,7 +1662,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
updated[key] = Math.max(0, Math.min(10, Number(val) || 0));
|
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"
|
className="ps-field font-mono text-[10px] flex-1 min-w-0"
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
@@ -1863,6 +1919,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
|
||||||
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
|
||||||
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
|
||||||
|
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate" title={elem.name}>
|
<span className="truncate" title={elem.name}>
|
||||||
{elem.name}
|
{elem.name}
|
||||||
|
|||||||
406
src/components/TableCellTextFields.tsx
Normal file
406
src/components/TableCellTextFields.tsx
Normal file
@@ -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<TableCellTextFieldsProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
|
||||||
|
<span className="text-[10px] font-semibold text-[#aaa] flex items-center gap-1">
|
||||||
|
<Type className="w-3 h-3" />
|
||||||
|
单元格文本
|
||||||
|
</span>
|
||||||
|
{(span.rowSpan > 1 || span.colSpan > 1) && (
|
||||||
|
<p className="text-[9px] text-[#888]">合并区域 {span.rowSpan}×{span.colSpan}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">内容</label>
|
||||||
|
<textarea
|
||||||
|
value={textElem.content}
|
||||||
|
rows={2}
|
||||||
|
className="ps-field resize-none"
|
||||||
|
placeholder="输入文本,使用 {变量名} 引用变量"
|
||||||
|
onChange={(e) => patchText({ ...textElem, content: e.target.value })}
|
||||||
|
/>
|
||||||
|
{templateVariables.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{templateVariables.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const token = `{${v}}`;
|
||||||
|
const content = textElem.content.includes(token)
|
||||||
|
? textElem.content
|
||||||
|
: textElem.content.trim()
|
||||||
|
? `${textElem.content}${textElem.content.endsWith(' ') ? '' : ' '}${token}`
|
||||||
|
: token;
|
||||||
|
patchText({ ...textElem, content });
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[9px] font-mono"
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">显示格式</label>
|
||||||
|
<FieldSelect
|
||||||
|
value={textElem.textFormat || 'text'}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
textFormat: e.target.value as 'text' | 'number' | 'percent' | 'currency',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field"
|
||||||
|
>
|
||||||
|
<option value="text">文本</option>
|
||||||
|
<option value="number">数值</option>
|
||||||
|
<option value="percent">百分比</option>
|
||||||
|
<option value="currency">货币 (¥)</option>
|
||||||
|
</FieldSelect>
|
||||||
|
</div>
|
||||||
|
{(textElem.textFormat === 'number' ||
|
||||||
|
textElem.textFormat === 'percent' ||
|
||||||
|
textElem.textFormat === 'currency') && (
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">小数位数</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="6"
|
||||||
|
value={textElem.decimalPlaces ?? 2}
|
||||||
|
onCommit={(val) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">字号 (pt)</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="6"
|
||||||
|
max="80"
|
||||||
|
value={textElem.fontSize}
|
||||||
|
onCommit={(val) => patchText({ ...textElem, fontSize: Number(val) || 12 })}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">字体库</label>
|
||||||
|
<FieldSelect
|
||||||
|
value={textElem.fontFamily}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
fontFamily: e.target.value as 'sans' | 'serif' | 'mono',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field"
|
||||||
|
>
|
||||||
|
<option value="sans">黑体 (Sans)</option>
|
||||||
|
<option value="serif">宋体 (Serif)</option>
|
||||||
|
<option value="mono">等宽体 (Mono)</option>
|
||||||
|
</FieldSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||||
|
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={textElem.textColor || '#111827'}
|
||||||
|
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
|
||||||
|
className="color-input shrink-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="#111827"
|
||||||
|
value={textElem.textColor || ''}
|
||||||
|
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
|
||||||
|
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="ps-field-label mb-0">文本样式</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
fontWeight: textElem.fontWeight === 'bold' ? 'normal' : 'bold',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`ps-btn ${textElem.fontWeight === 'bold' ? 'active' : ''}`}
|
||||||
|
title="加粗"
|
||||||
|
>
|
||||||
|
<Bold className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
fontStyle: textElem.fontStyle === 'italic' ? 'normal' : 'italic',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`ps-btn ${textElem.fontStyle === 'italic' ? 'active' : ''}`}
|
||||||
|
title="斜体"
|
||||||
|
>
|
||||||
|
<Italic className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textUnderline: !textElem.textUnderline })}
|
||||||
|
className={`ps-btn ${textElem.textUnderline ? 'active' : ''}`}
|
||||||
|
title="下划线"
|
||||||
|
>
|
||||||
|
<Underline className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textLineThrough: !textElem.textLineThrough })}
|
||||||
|
className={`ps-btn ${textElem.textLineThrough ? 'active' : ''}`}
|
||||||
|
title="删除线"
|
||||||
|
>
|
||||||
|
<Strikethrough className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">水平拉伸</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
step="5"
|
||||||
|
value={Math.round((textElem.textScaleX ?? 1) * 100)}
|
||||||
|
onCommit={(val) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">垂直拉伸</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
step="5"
|
||||||
|
value={Math.round((textElem.textScaleY ?? 1) * 100)}
|
||||||
|
onCommit={(val) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">字间距</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={textElem.letterSpacing ?? 0}
|
||||||
|
onCommit={(val) =>
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">mm</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="ps-field-label mb-0">排列方向</label>
|
||||||
|
<div className="ps-toggle-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textWritingMode: 'horizontal' })}
|
||||||
|
className={`ps-toggle-btn ${(textElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
横排
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textWritingMode: 'vertical' })}
|
||||||
|
className={`ps-toggle-btn ${textElem.textWritingMode === 'vertical' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
竖排
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="ps-field-label mb-0">对齐方式</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="ps-toggle-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textAlign: 'left' })}
|
||||||
|
className={`ps-toggle-btn ${textElem.textAlign === 'left' ? 'active' : ''}`}
|
||||||
|
title="左对齐"
|
||||||
|
>
|
||||||
|
<AlignLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textAlign: 'center' })}
|
||||||
|
className={`ps-toggle-btn ${textElem.textAlign === 'center' ? 'active' : ''}`}
|
||||||
|
title="水平居中"
|
||||||
|
>
|
||||||
|
<AlignCenter className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, textAlign: 'right' })}
|
||||||
|
className={`ps-toggle-btn ${textElem.textAlign === 'right' ? 'active' : ''}`}
|
||||||
|
title="右对齐"
|
||||||
|
>
|
||||||
|
<AlignRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ps-toggle-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, verticalAlign: 'top' })}
|
||||||
|
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'top' ? 'active' : ''}`}
|
||||||
|
title="顶对齐"
|
||||||
|
>
|
||||||
|
<AlignVerticalJustifyStart className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, verticalAlign: 'middle' })}
|
||||||
|
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'middle' ? 'active' : ''}`}
|
||||||
|
title="垂直居中"
|
||||||
|
>
|
||||||
|
<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, verticalAlign: 'bottom' })}
|
||||||
|
className={`ps-toggle-btn ${(textElem.verticalAlign ?? 'middle') === 'bottom' ? 'active' : ''}`}
|
||||||
|
title="底对齐"
|
||||||
|
>
|
||||||
|
<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textElem.wordWrap !== false}
|
||||||
|
onChange={(e) => patchText({ ...textElem, wordWrap: e.target.checked })}
|
||||||
|
className="ps-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">单元格背景</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={
|
||||||
|
textElem.backgroundColor && textElem.backgroundColor !== 'transparent'
|
||||||
|
? textElem.backgroundColor
|
||||||
|
: '#ffffff'
|
||||||
|
}
|
||||||
|
onChange={(e) => patchText({ ...textElem, backgroundColor: e.target.value })}
|
||||||
|
className="color-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchText({ ...textElem, backgroundColor: 'transparent' })}
|
||||||
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
清除背景
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ValueExtractFields elem={textElem} onChange={patchText} />
|
||||||
|
<TextDisplayAffixFields elem={textElem} onChange={patchText} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
293
src/components/TablePropertyFields.tsx
Normal file
293
src/components/TablePropertyFields.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TemplateElement } from '../types';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
|
import {
|
||||||
|
canMergeTableCells,
|
||||||
|
canUnmergeTableCell,
|
||||||
|
getCellAnchor,
|
||||||
|
getMergeAnchorFromSelection,
|
||||||
|
mergeTableCells,
|
||||||
|
normalizeTableCellSelection,
|
||||||
|
resizeTableGrid,
|
||||||
|
scaleTableTracks,
|
||||||
|
unmergeTableCell,
|
||||||
|
updateTableCells,
|
||||||
|
updateTableColWidth,
|
||||||
|
updateTableRowHeight,
|
||||||
|
getTableColWidths,
|
||||||
|
getTableRowHeights,
|
||||||
|
type TableCellCoord,
|
||||||
|
} from '../tableUtils';
|
||||||
|
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
||||||
|
import { TableCellTextFields } from './TableCellTextFields';
|
||||||
|
|
||||||
|
const PropInput = CommitInput;
|
||||||
|
|
||||||
|
interface TablePropertyFieldsProps {
|
||||||
|
elem: TemplateElement;
|
||||||
|
selectedCells: TableCellCoord[];
|
||||||
|
onChange: (elem: TemplateElement) => void;
|
||||||
|
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||||
|
templateVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
|
||||||
|
elem,
|
||||||
|
selectedCells,
|
||||||
|
onChange,
|
||||||
|
onSelectTableCells,
|
||||||
|
templateVariables = [],
|
||||||
|
}) => {
|
||||||
|
const rowHeights = getTableRowHeights(elem);
|
||||||
|
const colWidths = getTableColWidths(elem);
|
||||||
|
const normalizedCells = normalizeTableCellSelection(elem, selectedCells);
|
||||||
|
const canMerge = canMergeTableCells(elem, selectedCells);
|
||||||
|
const canUnmerge =
|
||||||
|
normalizedCells.length === 1 &&
|
||||||
|
canUnmergeTableCell(elem, normalizedCells[0].row, normalizedCells[0].col);
|
||||||
|
|
||||||
|
const primaryCell =
|
||||||
|
normalizedCells.length === 1
|
||||||
|
? getCellAnchor(elem, normalizedCells[0].row, normalizedCells[0].col)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 ps-section-divider">
|
||||||
|
<h5 className="ps-section-title">
|
||||||
|
<Table2 className="w-3.5 h-3.5" />
|
||||||
|
表格结构
|
||||||
|
</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">行数</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={elem.tableRows ?? 3}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const rows = Math.max(1, Math.min(50, Number(val) || 1));
|
||||||
|
onChange(resizeTableGrid(elem, rows, elem.tableCols ?? 3));
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">列数</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={elem.tableCols ?? 3}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const cols = Math.max(1, Math.min(50, Number(val) || 1));
|
||||||
|
onChange(resizeTableGrid(elem, elem.tableRows ?? 3, cols));
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[10px] font-semibold text-[#aaa]">行高 (mm)</span>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{rowHeights.map((h, i) => (
|
||||||
|
<div key={`rh-${i}`}>
|
||||||
|
<label className="text-[9px] text-[#666]">第 {i + 1} 行</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0.5"
|
||||||
|
value={h}
|
||||||
|
onCommit={(val) =>
|
||||||
|
onChange(updateTableRowHeight(elem, i, Number(val) || 0.5))
|
||||||
|
}
|
||||||
|
className="ps-field font-mono text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[10px] font-semibold text-[#aaa]">列宽 (mm)</span>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{colWidths.map((w, i) => (
|
||||||
|
<div key={`cw-${i}`}>
|
||||||
|
<label className="text-[9px] text-[#666]">第 {i + 1} 列</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0.5"
|
||||||
|
value={w}
|
||||||
|
onCommit={(val) =>
|
||||||
|
onChange(updateTableColWidth(elem, i, Number(val) || 0.5))
|
||||||
|
}
|
||||||
|
className="ps-field font-mono text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">网格线宽 (mm)</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={elem.tableBorderWidth ?? 0.2}
|
||||||
|
onCommit={(val) =>
|
||||||
|
onChange({ ...elem, tableBorderWidth: Math.max(0, Number(val) || 0) })
|
||||||
|
}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">网格线颜色</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={elem.tableBorderColor ?? '#374151'}
|
||||||
|
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })}
|
||||||
|
className="color-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{normalizedCells.length > 0 && (
|
||||||
|
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
|
||||||
|
<span className="text-[10px] bg-[#31a8ff] text-white font-bold px-1.5 py-0.5 inline-block">
|
||||||
|
已选 {normalizedCells.length} 个单元格
|
||||||
|
</span>
|
||||||
|
<p className="text-[9px] text-[#666] leading-snug">
|
||||||
|
单击选择单元格,拖动移动整个表格;再次点击已选单元格可取消。合并须为完整矩形。
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canMerge}
|
||||||
|
onClick={() => {
|
||||||
|
const anchor = getMergeAnchorFromSelection(selectedCells);
|
||||||
|
onChange(mergeTableCells(elem, selectedCells));
|
||||||
|
onSelectTableCells?.([anchor]);
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="合并所选单元格"
|
||||||
|
>
|
||||||
|
<Merge className="w-3 h-3" />
|
||||||
|
合并单元格
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canUnmerge}
|
||||||
|
onClick={() => {
|
||||||
|
const anchor = normalizedCells[0];
|
||||||
|
onChange(unmergeTableCell(elem, anchor.row, anchor.col));
|
||||||
|
onSelectTableCells?.([anchor]);
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[10px] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="取消合并"
|
||||||
|
>
|
||||||
|
<SplitSquareHorizontal className="w-3 h-3" />
|
||||||
|
取消合并
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{normalizedCells.length === 1 && primaryCell && (
|
||||||
|
<TableCellTextFields
|
||||||
|
elem={elem}
|
||||||
|
row={primaryCell.row}
|
||||||
|
col={primaryCell.col}
|
||||||
|
onChange={onChange}
|
||||||
|
templateVariables={templateVariables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TableCellContentFieldsProps {
|
||||||
|
elem: TemplateElement;
|
||||||
|
selectedCells: TableCellCoord[];
|
||||||
|
onChange: (elem: TemplateElement) => void;
|
||||||
|
templateVariables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
|
||||||
|
elem,
|
||||||
|
selectedCells,
|
||||||
|
onChange,
|
||||||
|
templateVariables,
|
||||||
|
}) => {
|
||||||
|
if (selectedCells.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-[10px] text-[#777] leading-normal">
|
||||||
|
在画布上点击单元格进行编辑;单元格内容默认为空,选中单个单元格可在属性面板设置文本内容与样式。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCells.length === 1) {
|
||||||
|
return (
|
||||||
|
<p className="text-[10px] text-[#777] leading-normal">
|
||||||
|
已选中 1 个单元格,请在属性面板的「单元格文本」中编辑内容与样式。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = getCellRawContent(elem, selectedCells[0].row, selectedCells[0].col);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
|
||||||
|
<label className="ps-field-label">批量设置内容({selectedCells.length} 个单元格)</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
rows={2}
|
||||||
|
className="ps-field resize-none"
|
||||||
|
placeholder="输入文本,使用 {变量名} 引用变量"
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(updateTableCells(elem, selectedCells, { content: e.target.value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{templateVariables.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{templateVariables.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = content + `{${v}}`;
|
||||||
|
onChange(updateTableCells(elem, selectedCells, { content: next }));
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[9px] font-mono"
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scaleTableElementSize(
|
||||||
|
elem: TemplateElement,
|
||||||
|
newWidth: number,
|
||||||
|
newHeight: number
|
||||||
|
): TemplateElement {
|
||||||
|
if (elem.type !== 'table') {
|
||||||
|
return { ...elem, width: newWidth, height: newHeight };
|
||||||
|
}
|
||||||
|
return scaleTableTracks(
|
||||||
|
elem,
|
||||||
|
newWidth,
|
||||||
|
newHeight,
|
||||||
|
elem.width,
|
||||||
|
elem.height,
|
||||||
|
getTableRowHeights(elem),
|
||||||
|
getTableColWidths(elem)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,17 @@ import {
|
|||||||
drawElementBorders,
|
drawElementBorders,
|
||||||
drawElementChrome,
|
drawElementChrome,
|
||||||
} from './elementBox';
|
} from './elementBox';
|
||||||
|
import {
|
||||||
|
iterTableCells,
|
||||||
|
getCellRectMm,
|
||||||
|
getCellAnchor,
|
||||||
|
getCellData,
|
||||||
|
getEffectiveCellStyle,
|
||||||
|
getTableRows,
|
||||||
|
getTableCols,
|
||||||
|
getTableRowHeights,
|
||||||
|
getTableColWidths,
|
||||||
|
} from './tableUtils';
|
||||||
|
|
||||||
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
||||||
|
|
||||||
@@ -215,6 +226,280 @@ function layoutVerticalTextColumns(
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawTextInRect(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
elem: TemplateElement,
|
||||||
|
boxX: number,
|
||||||
|
boxY: number,
|
||||||
|
boxW: number,
|
||||||
|
boxH: number,
|
||||||
|
value: string,
|
||||||
|
scale: number
|
||||||
|
) {
|
||||||
|
const pad = 2 * scale;
|
||||||
|
const writableW = Math.max(1, boxW - pad * 2);
|
||||||
|
const writableH = Math.max(1, boxH - pad * 2);
|
||||||
|
|
||||||
|
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
|
||||||
|
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
|
||||||
|
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
|
||||||
|
const fontFamily =
|
||||||
|
elem.fontFamily === 'mono'
|
||||||
|
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
|
||||||
|
: elem.fontFamily === 'serif'
|
||||||
|
? 'Georgia, Times New Roman, serif'
|
||||||
|
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
|
||||||
|
|
||||||
|
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
|
||||||
|
ctx.fillStyle = elem.textColor || '#111827';
|
||||||
|
|
||||||
|
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
|
||||||
|
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||||
|
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
|
||||||
|
`${letterSpacingPx}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordWrap = elem.wordWrap !== false;
|
||||||
|
const lineHeight = fontSizePx * 1.25;
|
||||||
|
const columnWidth = lineHeight;
|
||||||
|
const isVertical = elem.textWritingMode === 'vertical';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
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 = boxX + boxW / 2;
|
||||||
|
const transformOriginY = boxY + boxH / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||||
|
ctx.translate(transformOriginX, transformOriginY);
|
||||||
|
ctx.scale(textScaleX, textScaleY);
|
||||||
|
ctx.translate(-transformOriginX, -transformOriginY);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = boxX + pad + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
|
||||||
|
} else if (elem.textAlign === 'right') {
|
||||||
|
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
|
||||||
|
} else {
|
||||||
|
startX = boxX + pad + 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 = boxY + pad + lineHeight / 2;
|
||||||
|
} else if (vAlign === 'bottom') {
|
||||||
|
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2;
|
||||||
|
} else {
|
||||||
|
colStartY = boxY + pad + 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 <= boxY + boxH - pad) {
|
||||||
|
ctx.fillText(column[i], colX, charY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||||
|
const totalTextHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
|
let textX = boxX + pad;
|
||||||
|
let textAlign: CanvasTextAlign = 'left';
|
||||||
|
if (elem.textAlign === 'center') {
|
||||||
|
textX = boxX + boxW / 2;
|
||||||
|
textAlign = 'center';
|
||||||
|
} else if (elem.textAlign === 'right') {
|
||||||
|
textX = boxX + boxW - pad;
|
||||||
|
textAlign = 'right';
|
||||||
|
} else {
|
||||||
|
textX = boxX + pad + 2;
|
||||||
|
}
|
||||||
|
ctx.textAlign = textAlign;
|
||||||
|
|
||||||
|
let startY: number;
|
||||||
|
if (vAlign === 'top') {
|
||||||
|
startY = boxY + pad + lineHeight / 2;
|
||||||
|
} else if (vAlign === 'bottom') {
|
||||||
|
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2;
|
||||||
|
} else {
|
||||||
|
startY = boxY + pad + 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 <= boxY + boxH - pad) {
|
||||||
|
const line = lines[i];
|
||||||
|
ctx.fillText(line, textX, lineY);
|
||||||
|
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
|
||||||
|
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignStrokeCoord(v: number): number {
|
||||||
|
return Math.round(v) + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一绘制表格网格线,避免相邻单元格各画一条边造成双线 */
|
||||||
|
function drawTableGridLines(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
elem: TemplateElement,
|
||||||
|
ex: number,
|
||||||
|
ey: number,
|
||||||
|
scale: number
|
||||||
|
) {
|
||||||
|
const borderMm = elem.tableBorderWidth ?? 0.2;
|
||||||
|
if (borderMm <= 0) return;
|
||||||
|
|
||||||
|
const borderW = borderMm * scale;
|
||||||
|
if (borderW <= 0) return;
|
||||||
|
|
||||||
|
const borderColor = elem.tableBorderColor ?? '#374151';
|
||||||
|
const rows = getTableRows(elem);
|
||||||
|
const cols = getTableCols(elem);
|
||||||
|
const rowHeights = getTableRowHeights(elem);
|
||||||
|
const colWidths = getTableColWidths(elem);
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
|
||||||
|
const x0 = pad.left;
|
||||||
|
const y0 = pad.top;
|
||||||
|
const totalW = colWidths.reduce((a, b) => a + b, 0);
|
||||||
|
const totalH = rowHeights.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
const left = ex + x0 * scale;
|
||||||
|
const top = ey + y0 * scale;
|
||||||
|
const width = totalW * scale;
|
||||||
|
const height = totalH * scale;
|
||||||
|
|
||||||
|
ctx.strokeStyle = borderColor;
|
||||||
|
ctx.lineWidth = borderW;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const inset = borderW / 2;
|
||||||
|
ctx.rect(
|
||||||
|
alignStrokeCoord(left + inset),
|
||||||
|
alignStrokeCoord(top + inset),
|
||||||
|
Math.max(0, width - borderW),
|
||||||
|
Math.max(0, height - borderW)
|
||||||
|
);
|
||||||
|
|
||||||
|
let yAcc = y0;
|
||||||
|
for (let r = 1; r < rows; r++) {
|
||||||
|
yAcc += rowHeights[r - 1];
|
||||||
|
const y = alignStrokeCoord(ey + yAcc * scale);
|
||||||
|
let xAcc = x0;
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const w = colWidths[c];
|
||||||
|
const anchorAbove = getCellAnchor(elem, r - 1, c);
|
||||||
|
const anchorBelow = getCellAnchor(elem, r, c);
|
||||||
|
const sameMerge =
|
||||||
|
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
|
||||||
|
if (!sameMerge) {
|
||||||
|
const x1 = alignStrokeCoord(ex + xAcc * scale);
|
||||||
|
const x2 = alignStrokeCoord(ex + (xAcc + w) * scale);
|
||||||
|
ctx.moveTo(x1, y);
|
||||||
|
ctx.lineTo(x2, y);
|
||||||
|
}
|
||||||
|
xAcc += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let xAcc = x0;
|
||||||
|
for (let c = 1; c < cols; c++) {
|
||||||
|
xAcc += colWidths[c - 1];
|
||||||
|
const x = alignStrokeCoord(ex + xAcc * scale);
|
||||||
|
let yAcc2 = y0;
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const h = rowHeights[r];
|
||||||
|
const anchorLeft = getCellAnchor(elem, r, c - 1);
|
||||||
|
const anchorRight = getCellAnchor(elem, r, c);
|
||||||
|
const sameMerge =
|
||||||
|
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
|
||||||
|
if (!sameMerge) {
|
||||||
|
const y1 = alignStrokeCoord(ey + yAcc2 * scale);
|
||||||
|
const y2 = alignStrokeCoord(ey + (yAcc2 + h) * scale);
|
||||||
|
ctx.moveTo(x, y1);
|
||||||
|
ctx.lineTo(x, y2);
|
||||||
|
}
|
||||||
|
yAcc2 += h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawTableElement(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
elem: TemplateElement,
|
||||||
|
ex: number,
|
||||||
|
ey: number,
|
||||||
|
ew: number,
|
||||||
|
eh: number,
|
||||||
|
scale: number,
|
||||||
|
rowData: RecordRow | null,
|
||||||
|
rowIndex: number,
|
||||||
|
variableDefaults: Record<string, string> | null | undefined,
|
||||||
|
mode: ContentResolveMode
|
||||||
|
) {
|
||||||
|
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
||||||
|
const sides = resolveBorderSides(elem);
|
||||||
|
|
||||||
|
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
||||||
|
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
||||||
|
|
||||||
|
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(inner.x, inner.y, inner.w, inner.h);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
const anchors = iterTableCells(elem);
|
||||||
|
for (const { row, col } of anchors) {
|
||||||
|
const rect = getCellRectMm(elem, row, col);
|
||||||
|
const cellX = ex + Math.round(rect.x * scale);
|
||||||
|
const cellY = ey + Math.round(rect.y * scale);
|
||||||
|
const cellW = Math.round(rect.width * scale);
|
||||||
|
const cellH = Math.round(rect.height * scale);
|
||||||
|
|
||||||
|
const cellData = getCellData(elem, row, col);
|
||||||
|
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
|
||||||
|
ctx.fillStyle = cellData.backgroundColor;
|
||||||
|
ctx.fillRect(cellX, cellY, cellW, cellH);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textElem = getEffectiveCellStyle(elem, row, col);
|
||||||
|
if (!textElem.content.trim()) continue;
|
||||||
|
|
||||||
|
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
|
||||||
|
const displayText = String(cellValue ?? '').trim();
|
||||||
|
if (!displayText) continue;
|
||||||
|
|
||||||
|
drawTextInRect(ctx, textElem, cellX, cellY, cellW, cellH, displayText, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTableGridLines(ctx, elem, ex, ey, scale);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function drawImagePlaceholder(
|
function drawImagePlaceholder(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -316,7 +601,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
|
|
||||||
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
|
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
|
||||||
|
|
||||||
if (isGraphicContentEmpty(elem, value, mode)) {
|
if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +830,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (elem.type === 'table') {
|
||||||
|
await drawTableElement(
|
||||||
|
ctx,
|
||||||
|
elem,
|
||||||
|
ex,
|
||||||
|
ey,
|
||||||
|
ew,
|
||||||
|
eh,
|
||||||
|
scale,
|
||||||
|
rowData,
|
||||||
|
rowIndex,
|
||||||
|
variableDefaults,
|
||||||
|
mode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|||||||
560
src/tableUtils.ts
Normal file
560
src/tableUtils.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { TemplateElement, TableCellData } from './types';
|
||||||
|
import { resolvePaddingMm } from './elementBox';
|
||||||
|
|
||||||
|
export interface TableCellCoord {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tableCellKey(row: number, col: number): string {
|
||||||
|
return `${row},${col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTableCellKey(key: string): TableCellCoord | null {
|
||||||
|
const [r, c] = key.split(',').map(Number);
|
||||||
|
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
|
||||||
|
return { row: r, col: c };
|
||||||
|
}
|
||||||
|
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
export function getTableRows(elem: TemplateElement): number {
|
||||||
|
return Math.max(1, elem.tableRows ?? 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableCols(elem: TemplateElement): number {
|
||||||
|
return Math.max(1, elem.tableCols ?? 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableInnerSizeMm(elem: TemplateElement): { width: number; height: number } {
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, elem.width - pad.left - pad.right),
|
||||||
|
height: Math.max(1, elem.height - pad.top - pad.bottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableRowHeights(elem: TemplateElement): number[] {
|
||||||
|
const rows = getTableRows(elem);
|
||||||
|
const stored = elem.tableRowHeights;
|
||||||
|
if (stored && stored.length === rows) {
|
||||||
|
return stored.map((h) => Math.max(0.5, h));
|
||||||
|
}
|
||||||
|
const { height } = getTableInnerSizeMm(elem);
|
||||||
|
const even = round1(height / rows);
|
||||||
|
return Array.from({ length: rows }, () => even);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableColWidths(elem: TemplateElement): number[] {
|
||||||
|
const cols = getTableCols(elem);
|
||||||
|
const stored = elem.tableColWidths;
|
||||||
|
if (stored && stored.length === cols) {
|
||||||
|
return stored.map((w) => Math.max(0.5, w));
|
||||||
|
}
|
||||||
|
const { width } = getTableInnerSizeMm(elem);
|
||||||
|
const even = round1(width / cols);
|
||||||
|
return Array.from({ length: cols }, () => even);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内边距变化后保持行高/列宽不变,自动增大元素尺寸 */
|
||||||
|
export function expandTableForPadding(elem: TemplateElement): TemplateElement {
|
||||||
|
if (elem.type !== 'table') return elem;
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const rowHeights = getTableRowHeights(elem);
|
||||||
|
const colWidths = getTableColWidths(elem);
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableRowHeights: rowHeights,
|
||||||
|
tableColWidths: colWidths,
|
||||||
|
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
||||||
|
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTableDimensionsFromTracks(elem: TemplateElement): TemplateElement {
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const rowHeights = getTableRowHeights(elem);
|
||||||
|
const colWidths = getTableColWidths(elem);
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableRowHeights: rowHeights,
|
||||||
|
tableColWidths: colWidths,
|
||||||
|
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
||||||
|
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleTableTracks(
|
||||||
|
elem: TemplateElement,
|
||||||
|
newWidth: number,
|
||||||
|
newHeight: number,
|
||||||
|
startWidth: number,
|
||||||
|
startHeight: number,
|
||||||
|
startRowHeights: number[],
|
||||||
|
startColWidths: number[]
|
||||||
|
): TemplateElement {
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const startInnerW = Math.max(0.1, startWidth - pad.left - pad.right);
|
||||||
|
const startInnerH = Math.max(0.1, startHeight - pad.top - pad.bottom);
|
||||||
|
const newInnerW = Math.max(1, newWidth - pad.left - pad.right);
|
||||||
|
const newInnerH = Math.max(1, newHeight - pad.top - pad.bottom);
|
||||||
|
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)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
x: elem.x,
|
||||||
|
y: elem.y,
|
||||||
|
width: round1(newWidth),
|
||||||
|
height: round1(newHeight),
|
||||||
|
tableRowHeights,
|
||||||
|
tableColWidths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellData(elem: TemplateElement, row: number, col: number): TableCellData | undefined {
|
||||||
|
return elem.tableCells?.[tableCellKey(row, col)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellAnchor(elem: TemplateElement, row: number, col: number): TableCellCoord {
|
||||||
|
const cell = getCellData(elem, row, col);
|
||||||
|
if (cell?.mergeAnchor) {
|
||||||
|
return { row: cell.mergeAnchor.row, col: cell.mergeAnchor.col };
|
||||||
|
}
|
||||||
|
return { row, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellSpan(
|
||||||
|
elem: TemplateElement,
|
||||||
|
row: number,
|
||||||
|
col: number
|
||||||
|
): { rowSpan: number; colSpan: number } {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const anchorCell = getCellData(elem, anchor.row, anchor.col);
|
||||||
|
return {
|
||||||
|
rowSpan: Math.max(1, anchorCell?.rowSpan ?? 1),
|
||||||
|
colSpan: Math.max(1, anchorCell?.colSpan ?? 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellAnchor(elem: TemplateElement, row: number, col: number): boolean {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
return anchor.row === row && anchor.col === col;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iterTableCells(elem: TemplateElement): TableCellCoord[] {
|
||||||
|
const rows = getTableRows(elem);
|
||||||
|
const cols = getTableCols(elem);
|
||||||
|
const result: TableCellCoord[] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
if (isCellAnchor(elem, r, c)) {
|
||||||
|
result.push({ row: r, col: c });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellRectMm(
|
||||||
|
elem: TemplateElement,
|
||||||
|
row: number,
|
||||||
|
col: number
|
||||||
|
): { x: number; y: number; width: number; height: number } {
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const rowHeights = getTableRowHeights(elem);
|
||||||
|
const colWidths = getTableColWidths(elem);
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
|
||||||
|
|
||||||
|
let y = pad.top;
|
||||||
|
for (let r = 0; r < anchor.row; r++) y += rowHeights[r] ?? 0;
|
||||||
|
let x = pad.left;
|
||||||
|
for (let c = 0; c < anchor.col; c++) x += colWidths[c] ?? 0;
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
for (let c = anchor.col; c < anchor.col + colSpan; c++) width += colWidths[c] ?? 0;
|
||||||
|
let height = 0;
|
||||||
|
for (let r = anchor.row; r < anchor.row + rowSpan; r++) height += rowHeights[r] ?? 0;
|
||||||
|
|
||||||
|
return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultTableElement(id: string, index: number): TemplateElement {
|
||||||
|
const rows = 3;
|
||||||
|
const cols = 3;
|
||||||
|
const rowH = 5;
|
||||||
|
const colW = 10;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'table',
|
||||||
|
name: `表格 ${index}`,
|
||||||
|
x: 5,
|
||||||
|
y: 5,
|
||||||
|
width: cols * colW,
|
||||||
|
height: rows * rowH,
|
||||||
|
content: '',
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
wordWrap: true,
|
||||||
|
fontWeight: 'normal',
|
||||||
|
barcodeFormat: 'CODE128',
|
||||||
|
showText: false,
|
||||||
|
fontFamily: 'sans',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundOpacity: 0,
|
||||||
|
tableRows: rows,
|
||||||
|
tableCols: cols,
|
||||||
|
tableRowHeights: Array.from({ length: rows }, () => rowH),
|
||||||
|
tableColWidths: Array.from({ length: cols }, () => colW),
|
||||||
|
tableBorderWidth: 0.2,
|
||||||
|
tableBorderColor: '#374151',
|
||||||
|
tableCells: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeTableGrid(
|
||||||
|
elem: TemplateElement,
|
||||||
|
newRows: number,
|
||||||
|
newCols: number
|
||||||
|
): TemplateElement {
|
||||||
|
const rows = Math.max(1, Math.min(50, newRows));
|
||||||
|
const cols = Math.max(1, Math.min(50, newCols));
|
||||||
|
const oldRows = getTableRows(elem);
|
||||||
|
const oldCols = getTableCols(elem);
|
||||||
|
const { width, height } = getTableInnerSizeMm(elem);
|
||||||
|
|
||||||
|
let rowHeights = getTableRowHeights(elem);
|
||||||
|
let colWidths = getTableColWidths(elem);
|
||||||
|
|
||||||
|
if (rows > oldRows) {
|
||||||
|
const added = rows - oldRows;
|
||||||
|
const avg = rowHeights.reduce((a, b) => a + b, 0) / oldRows;
|
||||||
|
rowHeights = [...rowHeights, ...Array.from({ length: added }, () => round1(avg))];
|
||||||
|
} else if (rows < oldRows) {
|
||||||
|
rowHeights = rowHeights.slice(0, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cols > oldCols) {
|
||||||
|
const added = cols - oldCols;
|
||||||
|
const avg = colWidths.reduce((a, b) => a + b, 0) / oldCols;
|
||||||
|
colWidths = [...colWidths, ...Array.from({ length: added }, () => round1(avg))];
|
||||||
|
} else if (cols < oldCols) {
|
||||||
|
colWidths = colWidths.slice(0, cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCells: Record<string, TableCellData> = {};
|
||||||
|
if (elem.tableCells) {
|
||||||
|
for (const [key, data] of Object.entries(elem.tableCells)) {
|
||||||
|
const coord = parseTableCellKey(key);
|
||||||
|
if (!coord || coord.row >= rows || coord.col >= cols) continue;
|
||||||
|
const anchor = data.mergeAnchor ?? { row: coord.row, col: coord.col };
|
||||||
|
if (anchor.row >= rows || anchor.col >= cols) continue;
|
||||||
|
newCells[key] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const next: TemplateElement = {
|
||||||
|
...elem,
|
||||||
|
tableRows: rows,
|
||||||
|
tableCols: cols,
|
||||||
|
tableRowHeights: rowHeights,
|
||||||
|
tableColWidths: colWidths,
|
||||||
|
tableCells: newCells,
|
||||||
|
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
||||||
|
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
||||||
|
};
|
||||||
|
return sanitizeTableMerges(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionBounds(selection: TableCellCoord[]) {
|
||||||
|
const rows = selection.map((s) => s.row);
|
||||||
|
const cols = selection.map((s) => s.col);
|
||||||
|
return {
|
||||||
|
minR: Math.min(...rows),
|
||||||
|
maxR: Math.max(...rows),
|
||||||
|
minC: Math.min(...cols),
|
||||||
|
maxC: Math.max(...cols),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并选区的左上角锚点坐标 */
|
||||||
|
export function getMergeAnchorFromSelection(selection: TableCellCoord[]): TableCellCoord {
|
||||||
|
const { minR, minC } = selectionBounds(selection);
|
||||||
|
return { row: minR, col: minC };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将选区归一化为各合并区域的主单元格,去重 */
|
||||||
|
export function normalizeTableCellSelection(
|
||||||
|
elem: TemplateElement,
|
||||||
|
selection: TableCellCoord[]
|
||||||
|
): TableCellCoord[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: TableCellCoord[] = [];
|
||||||
|
for (const coord of selection) {
|
||||||
|
const anchor = getCellAnchor(elem, coord.row, coord.col);
|
||||||
|
const key = tableCellKey(anchor.row, anchor.col);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
result.push({ row: anchor.row, col: anchor.col });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): boolean {
|
||||||
|
if (selection.length < 2) return false;
|
||||||
|
const { minR, maxR, minC, maxC } = selectionBounds(selection);
|
||||||
|
const expected = (maxR - minR + 1) * (maxC - minC + 1);
|
||||||
|
if (selection.length !== expected) return false;
|
||||||
|
|
||||||
|
const selSet = new Set(selection.map((s) => tableCellKey(s.row, s.col)));
|
||||||
|
for (let r = minR; r <= maxR; r++) {
|
||||||
|
for (let c = minC; c <= maxC; c++) {
|
||||||
|
if (!selSet.has(tableCellKey(r, c))) return false;
|
||||||
|
const anchor = getCellAnchor(elem, r, c);
|
||||||
|
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||||
|
for (let ar = anchor.row; ar < anchor.row + span.rowSpan; ar++) {
|
||||||
|
for (let ac = anchor.col; ac < anchor.col + span.colSpan; ac++) {
|
||||||
|
if (!selSet.has(tableCellKey(ar, ac))) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): TemplateElement {
|
||||||
|
if (!canMergeTableCells(elem, selection)) return elem;
|
||||||
|
const { minR, maxR, minC, maxC } = selectionBounds(selection);
|
||||||
|
const rowSpan = maxR - minR + 1;
|
||||||
|
const colSpan = maxC - minC + 1;
|
||||||
|
const cells = { ...(elem.tableCells ?? {}) };
|
||||||
|
|
||||||
|
for (let r = minR; r <= maxR; r++) {
|
||||||
|
for (let c = minC; c <= maxC; c++) {
|
||||||
|
const key = tableCellKey(r, c);
|
||||||
|
const prev = cells[key] ?? {};
|
||||||
|
if (r === minR && c === minC) {
|
||||||
|
cells[key] = { ...prev, rowSpan, colSpan, mergeAnchor: undefined };
|
||||||
|
} else {
|
||||||
|
cells[key] = { ...prev, mergeAnchor: { row: minR, col: minC }, rowSpan: undefined, colSpan: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...elem, tableCells: cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUnmergeTableCell(elem: TemplateElement, row: number, col: number): boolean {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||||
|
return span.rowSpan > 1 || span.colSpan > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmergeTableCell(elem: TemplateElement, row: number, col: number): TemplateElement {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const span = getCellSpan(elem, anchor.row, anchor.col);
|
||||||
|
if (span.rowSpan === 1 && span.colSpan === 1) return elem;
|
||||||
|
|
||||||
|
const cells = { ...(elem.tableCells ?? {}) };
|
||||||
|
for (let r = anchor.row; r < anchor.row + span.rowSpan; r++) {
|
||||||
|
for (let c = anchor.col; c < anchor.col + span.colSpan; c++) {
|
||||||
|
const key = tableCellKey(r, c);
|
||||||
|
const prev = { ...(cells[key] ?? {}) };
|
||||||
|
delete prev.mergeAnchor;
|
||||||
|
delete prev.rowSpan;
|
||||||
|
delete prev.colSpan;
|
||||||
|
if (Object.keys(prev).length === 0) {
|
||||||
|
delete cells[key];
|
||||||
|
} else {
|
||||||
|
cells[key] = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...elem, tableCells: cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTableMerges(elem: TemplateElement): TemplateElement {
|
||||||
|
const rows = getTableRows(elem);
|
||||||
|
const cols = getTableCols(elem);
|
||||||
|
const cells = { ...(elem.tableCells ?? {}) };
|
||||||
|
for (const key of Object.keys(cells)) {
|
||||||
|
const coord = parseTableCellKey(key);
|
||||||
|
if (!coord || coord.row >= rows || coord.col >= cols) {
|
||||||
|
delete cells[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...elem, tableCells: cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTableRowHeight(
|
||||||
|
elem: TemplateElement,
|
||||||
|
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));
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableRowHeights: rowHeights,
|
||||||
|
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTableColWidth(
|
||||||
|
elem: TemplateElement,
|
||||||
|
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));
|
||||||
|
const pad = resolvePaddingMm(elem);
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableColWidths: colWidths,
|
||||||
|
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单元格原始内容,未设置时为空字符串 */
|
||||||
|
export function getCellRawContent(elem: TemplateElement, row: number, col: number): string {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const cell = getCellData(elem, anchor.row, anchor.col);
|
||||||
|
return cell?.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTableCellContent(
|
||||||
|
elem: TemplateElement,
|
||||||
|
row: number,
|
||||||
|
col: number
|
||||||
|
): string {
|
||||||
|
return getCellRawContent(elem, row, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveCellStyle(
|
||||||
|
elem: TemplateElement,
|
||||||
|
row: number,
|
||||||
|
col: number
|
||||||
|
): TemplateElement {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const cell = getCellData(elem, anchor.row, anchor.col);
|
||||||
|
const pick = <K extends keyof TableCellData & keyof TemplateElement>(
|
||||||
|
key: K,
|
||||||
|
fallback: TemplateElement[K]
|
||||||
|
): TemplateElement[K] => {
|
||||||
|
const cellVal = cell?.[key as keyof TableCellData];
|
||||||
|
if (cellVal !== undefined) return cellVal as TemplateElement[K];
|
||||||
|
const elemVal = elem[key];
|
||||||
|
return (elemVal !== undefined ? elemVal : fallback) as TemplateElement[K];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
type: 'text',
|
||||||
|
content: getCellRawContent(elem, anchor.row, anchor.col),
|
||||||
|
fontSize: pick('fontSize', elem.fontSize),
|
||||||
|
textAlign: pick('textAlign', elem.textAlign),
|
||||||
|
verticalAlign: pick('verticalAlign', elem.verticalAlign ?? 'middle'),
|
||||||
|
fontWeight: pick('fontWeight', elem.fontWeight),
|
||||||
|
fontStyle: pick('fontStyle', elem.fontStyle ?? 'normal'),
|
||||||
|
textUnderline: pick('textUnderline', elem.textUnderline ?? false),
|
||||||
|
textLineThrough: pick('textLineThrough', elem.textLineThrough ?? false),
|
||||||
|
textScaleX: pick('textScaleX', elem.textScaleX ?? 1),
|
||||||
|
textScaleY: pick('textScaleY', elem.textScaleY ?? 1),
|
||||||
|
letterSpacing: pick('letterSpacing', elem.letterSpacing ?? 0),
|
||||||
|
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
||||||
|
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
||||||
|
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
||||||
|
fontFamily: pick('fontFamily', elem.fontFamily),
|
||||||
|
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
||||||
|
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
||||||
|
numberPartDisplay: pick('numberPartDisplay', elem.numberPartDisplay ?? 'full'),
|
||||||
|
textExtractMode: pick('textExtractMode', elem.textExtractMode ?? 'none'),
|
||||||
|
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
|
||||||
|
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
|
||||||
|
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
|
||||||
|
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
|
||||||
|
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
|
||||||
|
backgroundColor: cell?.backgroundColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
||||||
|
'content',
|
||||||
|
'fontSize',
|
||||||
|
'textAlign',
|
||||||
|
'verticalAlign',
|
||||||
|
'fontWeight',
|
||||||
|
'fontStyle',
|
||||||
|
'textUnderline',
|
||||||
|
'textLineThrough',
|
||||||
|
'textScaleX',
|
||||||
|
'textScaleY',
|
||||||
|
'letterSpacing',
|
||||||
|
'textWritingMode',
|
||||||
|
'textColor',
|
||||||
|
'backgroundColor',
|
||||||
|
'wordWrap',
|
||||||
|
'fontFamily',
|
||||||
|
'textFormat',
|
||||||
|
'decimalPlaces',
|
||||||
|
'numberPartDisplay',
|
||||||
|
'textExtractMode',
|
||||||
|
'textSplitDelimiter',
|
||||||
|
'textSplitIndex',
|
||||||
|
'textExtractLength',
|
||||||
|
'textDisplayPrefix',
|
||||||
|
'textDisplaySuffix',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyCellTextElementChange(
|
||||||
|
elem: TemplateElement,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
textElem: TemplateElement
|
||||||
|
): TemplateElement {
|
||||||
|
const patch: Partial<TableCellData> = {};
|
||||||
|
for (const key of CELL_TEXT_PATCH_KEYS) {
|
||||||
|
const val = textElem[key as keyof TemplateElement];
|
||||||
|
if (val !== undefined) {
|
||||||
|
(patch as Record<string, unknown>)[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updateTableCells(elem, [{ row, col }], patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTableCells(
|
||||||
|
elem: TemplateElement,
|
||||||
|
coords: TableCellCoord[],
|
||||||
|
patch: Partial<TableCellData>
|
||||||
|
): TemplateElement {
|
||||||
|
const cells = { ...(elem.tableCells ?? {}) };
|
||||||
|
for (const { row, col } of coords) {
|
||||||
|
const anchor = getCellAnchor(elem, row, col);
|
||||||
|
const key = tableCellKey(anchor.row, anchor.col);
|
||||||
|
cells[key] = { ...(cells[key] ?? {}), ...patch };
|
||||||
|
}
|
||||||
|
return { ...elem, tableCells: cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cellCoordKey(coord: TableCellCoord): string {
|
||||||
|
return tableCellKey(coord.row, coord.col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击单元格:已选则取消,未选则加入选区(无需 Shift/Ctrl) */
|
||||||
|
export function toggleTableCellSelection(
|
||||||
|
current: TableCellCoord[],
|
||||||
|
coord: TableCellCoord
|
||||||
|
): TableCellCoord[] {
|
||||||
|
const key = cellCoordKey(coord);
|
||||||
|
const exists = current.some((c) => cellCoordKey(c) === key);
|
||||||
|
if (exists) return current.filter((c) => cellCoordKey(c) !== key);
|
||||||
|
return [...current, coord];
|
||||||
|
}
|
||||||
51
src/types.ts
51
src/types.ts
@@ -40,9 +40,44 @@ export const VISIBILITY_OPERATOR_LABELS: Record<VisibilityOperator, string> = {
|
|||||||
export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
|
export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
|
||||||
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
|
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
|
||||||
|
|
||||||
|
/** 表格单元格数据;键为 `${row},${col}` */
|
||||||
|
export interface TableCellData {
|
||||||
|
/** 被合并单元格指向主单元格坐标 */
|
||||||
|
mergeAnchor?: { row: number; col: number };
|
||||||
|
/** 主单元格合并行数,默认 1 */
|
||||||
|
rowSpan?: number;
|
||||||
|
/** 主单元格合并列数,默认 1 */
|
||||||
|
colSpan?: number;
|
||||||
|
content?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
textAlign?: 'left' | 'center' | 'right';
|
||||||
|
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||||
|
fontWeight?: 'normal' | 'bold';
|
||||||
|
fontStyle?: 'normal' | 'italic';
|
||||||
|
textUnderline?: boolean;
|
||||||
|
textLineThrough?: boolean;
|
||||||
|
textScaleX?: number;
|
||||||
|
textScaleY?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
|
textWritingMode?: 'horizontal' | 'vertical';
|
||||||
|
textColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
wordWrap?: boolean;
|
||||||
|
fontFamily?: 'sans' | 'serif' | 'mono';
|
||||||
|
textFormat?: TextFormat;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
numberPartDisplay?: NumberPartDisplay;
|
||||||
|
textExtractMode?: TextExtractMode;
|
||||||
|
textSplitDelimiter?: string;
|
||||||
|
textSplitIndex?: number;
|
||||||
|
textExtractLength?: number;
|
||||||
|
textDisplayPrefix?: string;
|
||||||
|
textDisplaySuffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateElement {
|
export interface TemplateElement {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'text' | 'barcode' | 'qrcode' | 'image';
|
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
||||||
name: string;
|
name: string;
|
||||||
x: number; // in mm
|
x: number; // in mm
|
||||||
y: number; // in mm
|
y: number; // in mm
|
||||||
@@ -137,6 +172,20 @@ export interface TemplateElement {
|
|||||||
borderRadiusTR?: number;
|
borderRadiusTR?: number;
|
||||||
borderRadiusBR?: number;
|
borderRadiusBR?: number;
|
||||||
borderRadiusBL?: number;
|
borderRadiusBL?: number;
|
||||||
|
/** 表格行数,仅 type=table */
|
||||||
|
tableRows?: number;
|
||||||
|
/** 表格列数,仅 type=table */
|
||||||
|
tableCols?: number;
|
||||||
|
/** 各行高度 (mm),仅 type=table */
|
||||||
|
tableRowHeights?: number[];
|
||||||
|
/** 各列宽度 (mm),仅 type=table */
|
||||||
|
tableColWidths?: number[];
|
||||||
|
/** 单元格数据,键 `${row},${col}`,仅 type=table */
|
||||||
|
tableCells?: Record<string, TableCellData>;
|
||||||
|
/** 单元格网格线宽 (mm),仅 type=table,默认 0.2 */
|
||||||
|
tableBorderWidth?: number;
|
||||||
|
/** 单元格网格线颜色,仅 type=table */
|
||||||
|
tableBorderColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataSourceMeta {
|
export interface DataSourceMeta {
|
||||||
|
|||||||
26
src/utils.ts
26
src/utils.ts
@@ -400,11 +400,11 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
|
|||||||
|
|
||||||
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
|
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
|
||||||
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
|
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
|
||||||
const { dataSource, dataSourceMeta, showPdfCutLines, exportRange: _exportRange, ...rest } = tmpl;
|
const { ...rest } = tmpl;
|
||||||
const cutLine: CutLineConfig = {
|
const cutLine: CutLineConfig = {
|
||||||
...DEFAULT_CUT_LINE_CONFIG,
|
...DEFAULT_CUT_LINE_CONFIG,
|
||||||
...rest.cutLine,
|
...rest.cutLine,
|
||||||
enabled: rest.cutLine?.enabled ?? showPdfCutLines !== false,
|
enabled: rest.cutLine?.enabled ?? true,
|
||||||
width:
|
width:
|
||||||
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
|
typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0
|
||||||
? rest.cutLine.width
|
? rest.cutLine.width
|
||||||
@@ -495,6 +495,24 @@ export function resolveElementBackgroundFill(
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从元素内容(含表格单元格)提取变量名 */
|
||||||
|
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
||||||
|
const vars = new Set<string>();
|
||||||
|
for (const v of extractVariablesFromContent(elem.content)) {
|
||||||
|
vars.add(v);
|
||||||
|
}
|
||||||
|
if (elem.type === 'table' && elem.tableCells) {
|
||||||
|
for (const cell of Object.values(elem.tableCells)) {
|
||||||
|
if (cell.content) {
|
||||||
|
for (const v of extractVariablesFromContent(cell.content)) {
|
||||||
|
vars.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(vars);
|
||||||
|
}
|
||||||
|
|
||||||
/** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */
|
/** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */
|
||||||
export function extractTemplateVariables(template: {
|
export function extractTemplateVariables(template: {
|
||||||
variableList?: string[];
|
variableList?: string[];
|
||||||
@@ -502,7 +520,7 @@ export function extractTemplateVariables(template: {
|
|||||||
}): string[] {
|
}): string[] {
|
||||||
const vars = new Set<string>(template.variableList ?? []);
|
const vars = new Set<string>(template.variableList ?? []);
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromContent(elem.content)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
for (const rule of resolveVisibilityRules(elem)) {
|
||||||
@@ -517,7 +535,7 @@ export function extractTemplateVariables(template: {
|
|||||||
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
|
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
|
||||||
const vars = new Set<string>();
|
const vars = new Set<string>();
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromContent(elem.content)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
for (const rule of resolveVisibilityRules(elem)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user