表格支持

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

View File

@@ -2,6 +2,17 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import {
createDefaultTableElement,
getTableColWidths,
getTableRowHeights,
iterTableCells,
getCellRectMm,
toggleTableCellSelection,
scaleTableTracks,
getCellAnchor,
type TableCellCoord,
} from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
@@ -16,6 +27,7 @@ import {
Image as ImageIcon,
Move,
Trash2,
Table2,
} from 'lucide-react';
interface LabelDesignerProps {
@@ -23,12 +35,14 @@ interface LabelDesignerProps {
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void;
variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean;
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
const MIN_ELEM_W = 2;
@@ -194,6 +208,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onChange,
selectedElementIds,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
suppressSelectionChrome = false,
}) => {
@@ -256,6 +272,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: number;
startElemW: number;
startElemH: number;
startTableRowHeights?: number[];
startTableColWidths?: number[];
groupStartPositions?: Record<string, { x: number; y: number }>;
} | null>(null);
@@ -269,6 +287,15 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const ignoreCanvasClickRef = useRef(false);
const dragMovedRef = useRef(false);
const tableCellPointerRef = useRef<{
row: number;
col: number;
elementId: string;
startX: number;
startY: number;
pointerId: number;
dragStarted: boolean;
} | null>(null);
const multiTouchActiveRef = useRef(false);
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const marqueeRef = useRef(marqueeState);
@@ -297,7 +324,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
imageFit: 'contain',
};
onChange({
@@ -311,12 +339,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
);
const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode') => {
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
let newElement: TemplateElement;
const id = `${type}_${Date.now()}`;
const n = template.elements.length + 1;
if (type === 'text') {
if (type === 'table') {
newElement = createDefaultTableElement(id, n);
} else if (type === 'text') {
newElement = {
id,
type: 'text',
@@ -334,7 +364,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else if (type === 'barcode') {
newElement = {
@@ -352,7 +383,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'mono',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else {
newElement = {
@@ -370,7 +402,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
}
@@ -883,11 +916,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
corner
);
const updated = template.elements.map((el) =>
el.id === dragState.elementId
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
: el
);
const updated = template.elements.map((el) => {
if (el.id !== dragState.elementId) return el;
if (
el.type === 'table' &&
dragState.startTableRowHeights &&
dragState.startTableColWidths
) {
return scaleTableTracks(
{ ...el, x: rect.x, y: rect.y },
rect.w,
rect.h,
dragState.startElemW,
dragState.startElemH,
dragState.startTableRowHeights,
dragState.startTableColWidths
);
}
return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h };
});
pendingDragElementsRef.current = updated;
const paintInteractionFloat = () => {
@@ -1109,6 +1156,71 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
});
};
const handleTableCellPointerDown = (
e: React.PointerEvent,
element: TemplateElement,
row: number,
col: number
) => {
if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
tableCellPointerRef.current = {
row,
col,
elementId: element.id,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
dragStarted: false,
};
};
const handleTableCellPointerMove = (
e: React.PointerEvent,
element: TemplateElement
) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) {
return;
}
if (pending.dragStarted || element.locked) return;
const dx = e.clientX - pending.startX;
const dy = e.clientY - pending.startY;
if (
Math.abs(dx) > DRAG_START_THRESHOLD_PX ||
Math.abs(dy) > DRAG_START_THRESHOLD_PX
) {
pending.dragStarted = true;
dragMovedRef.current = true;
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
handleDragStart(e, element, 'drag');
}
};
const handleTableCellPointerUp = (e: React.PointerEvent) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId) return;
if (!pending.dragStarted && onSelectTableCells) {
onSelectTableCells((prev) =>
toggleTableCellSelection(prev, { row: pending.row, col: pending.col })
);
}
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
const handleResizeStart = (
e: React.PointerEvent,
element: TemplateElement,
@@ -1135,6 +1247,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
startTableRowHeights:
element.type === 'table' ? getTableRowHeights(element) : undefined,
startTableColWidths:
element.type === 'table' ? getTableColWidths(element) : undefined,
});
};
@@ -1300,8 +1416,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const overlayElements = filterVisibleElements(template.elements);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本', shortcut: 'T' },
{ id: 'table', icon: <Table2 className="w-[18px] h-[18px]" />, label: '表格' },
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
@@ -1734,6 +1851,52 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{element.name}
</div>
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
overlayElement.type === 'table' &&
onSelectTableCells && (
<div className="absolute inset-0 z-10 pointer-events-none">
{iterTableCells(overlayElement).map(({ row, col }) => {
const rect = getCellRectMm(overlayElement, row, col);
const cellX = mmToPx(rect.x, displayZoom);
const cellY = mmToPx(rect.y, displayZoom);
const cellW = mmToPx(rect.width, displayZoom);
const cellH = mmToPx(rect.height, displayZoom);
const isCellSelected = selectedTableCells.some((c) => {
const anchor = getCellAnchor(overlayElement, c.row, c.col);
return anchor.row === row && anchor.col === col;
});
return (
<div
key={`cell-${row}-${col}`}
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing border touch-none ${
isCellSelected
? 'bg-[#31a8ff]/25 border-[#31a8ff]'
: 'border-transparent hover:bg-[#31a8ff]/10 hover:border-[#31a8ff]/40'
}`}
style={{
left: `${cellX}px`,
top: `${cellY}px`,
width: `${cellW}px`,
height: `${cellH}px`,
}}
onPointerDown={(e) =>
handleTableCellPointerDown(e, overlayElement, row, col)
}
onPointerMove={(e) =>
handleTableCellPointerMove(e, overlayElement)
}
onPointerUp={(e) => handleTableCellPointerUp(e)}
onPointerCancel={(e) => handleTableCellPointerUp(e)}
title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`}
/>
);
})}
</div>
)}
</div>
);
})}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types';
import type { TableCellCoord } from '../tableUtils';
import { LabelDesigner } from './LabelDesigner';
import { PropSidebar, MobilePropModule } from './PropSidebar';
@@ -9,6 +10,8 @@ interface MobileDesignViewProps {
paper: PaperConfig;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
selectedTableCells: TableCellCoord[];
onSelectTableCells: (cells: TableCellCoord[]) => void;
onChangeTemplate: (updated: LabelTemplate) => void;
canUndo: boolean;
canRedo: boolean;
@@ -23,6 +26,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
paper,
selectedElementIds,
onSelectElements,
selectedTableCells,
onSelectTableCells,
onChangeTemplate,
canUndo,
canRedo,
@@ -82,6 +87,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onChange={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells}
suppressSelectionChrome={mobileModule === 'nudge'}
/>
</div>
@@ -93,6 +100,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onChangeTemplate={onChangeTemplate}
selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements}
selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells}
activeTab="element"
onChangeTab={() => {}}
paper={paper}

View File

@@ -24,6 +24,13 @@ import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import {
TablePropertyFields,
TableCellContentFields,
scaleTableElementSize,
} from './TablePropertyFields';
import type { TableCellCoord } from '../tableUtils';
import { expandTableForPadding } from '../tableUtils';
import {
Sliders,
LayoutGrid,
@@ -63,6 +70,7 @@ import {
QrCode,
Image as ImageIcon,
Upload,
Table2,
} from 'lucide-react';
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
@@ -166,6 +174,8 @@ interface PropSidebarProps {
onChangeTab: (tab: 'element' | 'paper') => void;
paper: PaperConfig;
onSelectElements?: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[]) => void;
variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void;
@@ -186,6 +196,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onChangeTab,
paper,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
mobileModule: mobileModuleProp,
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 idSet = new Set(selectedElementIds);
return template.elements
@@ -639,43 +659,64 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</label>
</div>
)}
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
rows={selectedElem.type === 'image' ? 3 : 2}
className="ps-field resize-none"
placeholder={
selectedElem.type === 'image'
? '图片 URL 或 data URI也可使用 {变量名}'
: '输入文本,使用 {变量名} 引用变量'
}
/>
{templateVariables.length > 0 && (
<div className="space-y-1">
<div className="text-[9px] text-[#777] font-medium"></div>
<div className="flex flex-wrap gap-1">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => insertVariable(v)}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
</div>
{selectedElem.type !== 'table' && (
<>
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
rows={selectedElem.type === 'image' ? 3 : 2}
className="ps-field resize-none"
placeholder={
selectedElem.type === 'image'
? '图片 URL 或 data URI也可使用 {变量名}'
: '输入文本,使用 {变量名} 引用变量'
}
/>
{templateVariables.length > 0 && (
<div className="space-y-1">
<div className="text-[9px] text-[#777] font-medium"></div>
<div className="flex flex-wrap gap-1">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => insertVariable(v)}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
</div>
)}
</>
)}
{selectedElem.type === 'table' && (
<p className="text-[10px] text-[#777] leading-normal">
</p>
)}
{(selectedElem.type === 'text' ||
selectedElem.type === 'barcode' ||
selectedElem.type === 'qrcode' ||
selectedElem.type === 'image') && (
selectedElem.type === 'image' ||
selectedElem.type === 'table') && (
<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} />
{selectedElem.type === 'text' && (
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
)}
</>
)}
</div>
)}
{renderVariableManagementBlock()}
@@ -954,6 +995,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
{/* Text Settings */}
{selectedElem.type === 'table' && selectedElementIds.length === 1 && (
<TablePropertyFields
elem={selectedElem}
selectedCells={selectedTableCells}
onChange={handleElemChange}
onSelectTableCells={onSelectTableCells}
templateVariables={templateVariables}
/>
)}
{selectedElem.type === 'text' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
@@ -1406,9 +1456,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
value={selectedElem.width}
onCommit={(val) => {
const wVal = Math.max(2, Number(val) || 2);
const updated = { ...selectedElem, width: wVal };
let updated = { ...selectedElem, width: wVal };
if (selectedElem.type === 'qrcode') {
updated.height = wVal;
} else if (selectedElem.type === 'table') {
updated = scaleTableElementSize(updated, wVal, updated.height);
}
handleElemChange(updated);
}}
@@ -1425,7 +1477,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
disabled={selectedElem.type === 'qrcode'}
onCommit={(val) => {
const hVal = Math.max(2, Number(val) || 2);
handleElemChange({ ...selectedElem, height: hVal });
if (selectedElem.type === 'table') {
handleElemChange(scaleTableElementSize(selectedElem, selectedElem.width, hVal));
} else {
handleElemChange({ ...selectedElem, height: hVal });
}
}}
className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
}`}
@@ -1508,7 +1564,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (v === 'transparent' || v === '') {
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
});
} else {
@@ -1573,7 +1629,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
step="0.5"
value={selectedElem.padding ?? 0}
onCommit={(val) =>
handleElemChange({
commitTableAwareChange({
...selectedElem,
padding: Math.max(0, Math.min(10, Number(val) || 0)),
})
@@ -1606,7 +1662,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
} else {
updated[key] = Math.max(0, Math.min(10, Number(val) || 0));
}
handleElemChange(updated);
commitTableAwareChange(updated);
}}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
@@ -1863,6 +1919,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{elem.type === 'barcode' && <Barcode 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 === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
</span>
<span className="truncate" title={elem.name}>
{elem.name}

View 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>
);
};

View 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)
);
}