TableToolbar.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. /**
  2. * TableToolbar.tsx - 表格工具栏
  3. *
  4. * @module components/Editor/blocks
  5. */
  6. import React, { useCallback } from 'react';
  7. import { Button, Space, Divider, message, Popconfirm } from 'antd';
  8. import {
  9. MinusOutlined,
  10. ArrowUpOutlined,
  11. ArrowDownOutlined,
  12. ArrowLeftOutlined,
  13. ArrowRightOutlined,
  14. MergeCellsOutlined,
  15. SplitCellsOutlined,
  16. DeleteOutlined,
  17. } from '@ant-design/icons';
  18. import type { TableBlock } from '../../../types/editor';
  19. import { useEditorStore } from '../../../stores/editorStore';
  20. import { TableWidthControl } from './TableWidthControl';
  21. import {
  22. insertTableRow,
  23. insertTableRowBefore,
  24. insertTableColumn,
  25. insertTableColumnBefore,
  26. deleteTableRow,
  27. deleteTableColumn,
  28. mergeCellsByVisualBounds,
  29. splitCell,
  30. getTableSelectionBounds,
  31. getTableCellRangeForVisualBounds,
  32. getTableCellReference,
  33. } from '../../../utils/blockOperations';
  34. import './TableToolbar.css';
  35. function getOperationError(error: unknown, fallback: string): string {
  36. return error instanceof Error ? error.message : fallback;
  37. }
  38. // ══════════════════════════════════════════════════════════════════════════════
  39. // Component Props
  40. // ══════════════════════════════════════════════════════════════════════════════
  41. export interface TableToolbarProps {
  42. /** 表格块 */
  43. block: TableBlock;
  44. /** 选中的单元格 */
  45. selectedCell: { row: number; col: number };
  46. /** 选中的范围 */
  47. selectedRange?: {
  48. startRow: number;
  49. startCol: number;
  50. endRow: number;
  51. endCol: number;
  52. } | null;
  53. selectedVisualRange?: {
  54. rowStart: number;
  55. rowEnd: number;
  56. colStart: number;
  57. colEnd: number;
  58. } | null;
  59. /** 关闭回调 */
  60. onClose: () => void;
  61. }
  62. // ══════════════════════════════════════════════════════════════════════════════
  63. // Component
  64. // ══════════════════════════════════════════════════════════════════════════════
  65. /**
  66. * TableToolbar - 表格工具栏
  67. *
  68. * 提供表格行列操作:
  69. * - 插入行/列
  70. * - 删除行/列
  71. * - 合并单元格
  72. */
  73. export const TableToolbar: React.FC<TableToolbarProps> = ({
  74. block,
  75. selectedCell,
  76. selectedRange,
  77. selectedVisualRange,
  78. onClose,
  79. }) => {
  80. const updateBlock = useEditorStore((state) => state.updateBlock);
  81. const deleteBlock = useEditorStore((state) => state.deleteBlock);
  82. const rowCount = block.content.rows.length;
  83. const columnCount = block.metadata.cols;
  84. const hasValidSelectedCell = Number.isInteger(selectedCell.row)
  85. && Number.isInteger(selectedCell.col)
  86. && selectedCell.row >= 0
  87. && selectedCell.row < rowCount
  88. && selectedCell.col >= 0
  89. && selectedCell.col < columnCount
  90. && !!block.content.rows[selectedCell.row]?.cells[selectedCell.col]
  91. && (block.content.rows[selectedCell.row]?.cells[selectedCell.col]?.rowspan ?? 1) > 0
  92. && (block.content.rows[selectedCell.row]?.cells[selectedCell.col]?.colspan ?? 1) > 0;
  93. const selectionBounds = selectedVisualRange
  94. || (selectedRange
  95. ? getTableSelectionBounds(
  96. block,
  97. selectedRange.startRow,
  98. selectedRange.startCol,
  99. selectedRange.endRow,
  100. selectedRange.endCol,
  101. )
  102. : null);
  103. const visualCellRange = selectionBounds
  104. ? getTableCellRangeForVisualBounds(block, selectionBounds)
  105. : null;
  106. const hasValidRange = !!selectionBounds && !!visualCellRange;
  107. const isMultiCellRange = hasValidRange
  108. && (selectionBounds.rowEnd > selectionBounds.rowStart || selectionBounds.colEnd > selectionBounds.colStart);
  109. const selectedCellData = hasValidSelectedCell
  110. ? block.content.rows[selectedCell.row]?.cells[selectedCell.col]
  111. : undefined;
  112. const selectedReference = getTableCellReference(block, selectedCell.row, selectedCell.col);
  113. const operationRow = selectedReference?.visual.rowStart ?? selectedCell.row;
  114. const operationCol = selectedReference?.visual.colStart ?? selectedCell.col;
  115. const canEditTableStructure = hasValidSelectedCell;
  116. const isMergedCell = hasValidSelectedCell
  117. && ((selectedCellData?.rowspan ?? 1) > 1 || (selectedCellData?.colspan ?? 1) > 1);
  118. // 插入行
  119. const handleInsertRow = useCallback(() => {
  120. if (!canEditTableStructure) return;
  121. try {
  122. const newBlock = insertTableRow(block, operationRow);
  123. updateBlock(block.id, {
  124. content: newBlock.content,
  125. metadata: newBlock.metadata,
  126. });
  127. message.success('已插入行');
  128. } catch (error: unknown) {
  129. message.error(getOperationError(error, '插入行失败'));
  130. }
  131. }, [block, canEditTableStructure, operationRow, updateBlock]);
  132. // 在上方插入行
  133. const handleInsertRowBefore = useCallback(() => {
  134. if (!canEditTableStructure) return;
  135. try {
  136. const newBlock = insertTableRowBefore(block, operationRow);
  137. updateBlock(block.id, {
  138. content: newBlock.content,
  139. metadata: newBlock.metadata,
  140. });
  141. message.success('已在上方插入行');
  142. } catch (error: unknown) {
  143. message.error(getOperationError(error, '插入行失败'));
  144. }
  145. }, [block, canEditTableStructure, operationRow, updateBlock]);
  146. // 插入列
  147. const handleInsertColumn = useCallback(() => {
  148. if (!canEditTableStructure) return;
  149. try {
  150. const newBlock = insertTableColumn(block, operationCol);
  151. updateBlock(block.id, {
  152. content: newBlock.content,
  153. metadata: newBlock.metadata,
  154. });
  155. message.success('已插入列');
  156. } catch (error: unknown) {
  157. message.error(getOperationError(error, '插入列失败'));
  158. }
  159. }, [block, canEditTableStructure, operationCol, updateBlock]);
  160. // 在左侧插入列
  161. const handleInsertColumnBefore = useCallback(() => {
  162. if (!canEditTableStructure) return;
  163. try {
  164. const newBlock = insertTableColumnBefore(block, operationCol);
  165. updateBlock(block.id, {
  166. content: newBlock.content,
  167. metadata: newBlock.metadata,
  168. });
  169. message.success('已在左侧插入列');
  170. } catch (error: unknown) {
  171. message.error(getOperationError(error, '插入列失败'));
  172. }
  173. }, [block, canEditTableStructure, operationCol, updateBlock]);
  174. // 删除行
  175. const handleDeleteRow = useCallback(() => {
  176. if (!canEditTableStructure) return;
  177. try {
  178. const newBlock = deleteTableRow(block, operationRow);
  179. updateBlock(block.id, {
  180. content: newBlock.content,
  181. metadata: newBlock.metadata,
  182. });
  183. message.success('已删除行');
  184. onClose();
  185. } catch (error: unknown) {
  186. message.error(getOperationError(error, '删除行失败'));
  187. }
  188. }, [block, canEditTableStructure, operationRow, updateBlock, onClose]);
  189. // 删除列
  190. const handleDeleteColumn = useCallback(() => {
  191. if (!canEditTableStructure) return;
  192. try {
  193. const newBlock = deleteTableColumn(block, operationCol);
  194. updateBlock(block.id, {
  195. content: newBlock.content,
  196. metadata: newBlock.metadata,
  197. });
  198. message.success('已删除列');
  199. onClose();
  200. } catch (error: unknown) {
  201. message.error(getOperationError(error, '删除列失败'));
  202. }
  203. }, [block, canEditTableStructure, operationCol, updateBlock, onClose]);
  204. // 合并单元格
  205. const handleMergeCells = useCallback(() => {
  206. if (!isMultiCellRange || !visualCellRange) {
  207. message.warning('请先选择要合并的单元格范围(Shift+点击)');
  208. return;
  209. }
  210. try {
  211. const newBlock = mergeCellsByVisualBounds(block, selectionBounds);
  212. updateBlock(block.id, {
  213. content: newBlock.content,
  214. });
  215. message.success('已合并单元格');
  216. onClose();
  217. } catch (error: unknown) {
  218. message.error(getOperationError(error, '合并单元格失败'));
  219. }
  220. }, [block, isMultiCellRange, selectionBounds, visualCellRange, updateBlock, onClose]);
  221. // 拆分单元格
  222. const handleSplitCell = useCallback(() => {
  223. if (!hasValidSelectedCell || !isMergedCell) return;
  224. try {
  225. const newBlock = splitCell(block, selectedCell.row, selectedCell.col);
  226. updateBlock(block.id, {
  227. content: newBlock.content,
  228. });
  229. message.success('已拆分单元格');
  230. onClose();
  231. } catch (error: unknown) {
  232. message.error(getOperationError(error, '拆分单元格失败'));
  233. }
  234. }, [block, hasValidSelectedCell, isMergedCell, selectedCell, updateBlock, onClose]);
  235. // 删除整个表格
  236. const handleDeleteTable = useCallback(async () => {
  237. try {
  238. await deleteBlock(block.id);
  239. message.success('已删除表格');
  240. onClose();
  241. } catch (error: unknown) {
  242. message.error(getOperationError(error, '删除表格失败'));
  243. }
  244. }, [block.id, deleteBlock, onClose]);
  245. return (
  246. <div className="table-toolbar">
  247. <Space size="small">
  248. {/* 表格宽度控制 */}
  249. <TableWidthControl
  250. key={`${block.id}-${block.metadata.table_width}-${block.metadata.table_width_unit}`}
  251. block={block}
  252. />
  253. <Divider type="vertical" style={{ margin: '0 4px' }} />
  254. {/* 插入行 */}
  255. <Button
  256. type="text"
  257. size="small"
  258. icon={<ArrowUpOutlined />}
  259. onClick={handleInsertRowBefore}
  260. disabled={!canEditTableStructure}
  261. title={canEditTableStructure ? '在上方插入行' : '请选择未合并的可见单元格'}
  262. >
  263. 上方插入行
  264. </Button>
  265. <Button
  266. type="text"
  267. size="small"
  268. icon={<ArrowDownOutlined />}
  269. onClick={handleInsertRow}
  270. disabled={!canEditTableStructure}
  271. title={canEditTableStructure ? '在下方插入行' : '请选择未合并的可见单元格'}
  272. >
  273. 在下方插入行
  274. </Button>
  275. {/* 插入列 */}
  276. <Button
  277. type="text"
  278. size="small"
  279. icon={<ArrowLeftOutlined />}
  280. onClick={handleInsertColumnBefore}
  281. disabled={!canEditTableStructure}
  282. title={canEditTableStructure ? '在左侧插入列' : '请选择未合并的可见单元格'}
  283. >
  284. 左侧插入列
  285. </Button>
  286. <Button
  287. type="text"
  288. size="small"
  289. icon={<ArrowRightOutlined />}
  290. onClick={handleInsertColumn}
  291. disabled={!canEditTableStructure}
  292. title={canEditTableStructure ? '在右侧插入列' : '请选择未合并的可见单元格'}
  293. >
  294. 在右侧插入列
  295. </Button>
  296. <Divider type="vertical" style={{ margin: '0 4px' }} />
  297. {/* 合并单元格 */}
  298. <Button
  299. type="text"
  300. size="small"
  301. icon={<MergeCellsOutlined />}
  302. onClick={handleMergeCells}
  303. disabled={!isMultiCellRange}
  304. title={isMultiCellRange ? '合并选中的单元格' : '请选择连续且不截断已有合并的单元格'}
  305. >
  306. 合并单元格
  307. </Button>
  308. {/* 拆分单元格 */}
  309. <Button
  310. type="text"
  311. size="small"
  312. icon={<SplitCellsOutlined />}
  313. onClick={handleSplitCell}
  314. disabled={!isMergedCell}
  315. title={isMergedCell ? '拆分此单元格' : '此单元格未合并'}
  316. >
  317. 拆分单元格
  318. </Button>
  319. <Divider type="vertical" style={{ margin: '0 4px' }} />
  320. {/* 删除行 */}
  321. <Popconfirm
  322. title="确定删除此行?"
  323. onConfirm={handleDeleteRow}
  324. okText="确定"
  325. cancelText="取消"
  326. >
  327. <Button
  328. type="text"
  329. size="small"
  330. icon={<MinusOutlined />}
  331. danger
  332. disabled={!canEditTableStructure || rowCount <= 1}
  333. title={canEditTableStructure ? '删除当前行' : '请选择未合并的可见单元格'}
  334. >
  335. 删除行
  336. </Button>
  337. </Popconfirm>
  338. {/* 删除列 */}
  339. <Popconfirm
  340. title="确定删除此列?"
  341. onConfirm={handleDeleteColumn}
  342. okText="确定"
  343. cancelText="取消"
  344. >
  345. <Button
  346. type="text"
  347. size="small"
  348. icon={<MinusOutlined />}
  349. danger
  350. disabled={!canEditTableStructure || columnCount <= 1}
  351. title={canEditTableStructure ? '删除当前列' : '请选择未合并的可见单元格'}
  352. >
  353. 删除列
  354. </Button>
  355. </Popconfirm>
  356. <Divider type="vertical" style={{ margin: '0 4px' }} />
  357. {/* 删除表格 */}
  358. <Popconfirm
  359. title="确定删除整个表格?"
  360. description="此操作不可恢复,表格中的所有数据将被删除"
  361. onConfirm={handleDeleteTable}
  362. okText="确定"
  363. cancelText="取消"
  364. okButtonProps={{ danger: true }}
  365. >
  366. <Button
  367. type="text"
  368. size="small"
  369. icon={<DeleteOutlined />}
  370. danger
  371. title="删除整个表格"
  372. >
  373. 删除表格
  374. </Button>
  375. </Popconfirm>
  376. </Space>
  377. </div>
  378. );
  379. };
  380. export default TableToolbar;