表格支持
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user