TableToolbar.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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. getTableVisualCellPositions,
  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 selectedVisualPosition = getTableVisualCellPositions(block).get(
  113. `${selectedCell.row}-${selectedCell.col}`,
  114. );
  115. const operationRow = selectedVisualPosition?.rowStart ?? selectedCell.row;
  116. const operationCol = selectedVisualPosition?.colStart ?? selectedCell.col;
  117. const canEditTableStructure = hasValidSelectedCell;
  118. const isMergedCell = hasValidSelectedCell
  119. && ((selectedCellData?.rowspan ?? 1) > 1 || (selectedCellData?.colspan ?? 1) > 1);
  120. // 插入行
  121. const handleInsertRow = useCallback(() => {
  122. if (!canEditTableStructure) return;
  123. try {
  124. const newBlock = insertTableRow(block, operationRow);
  125. updateBlock(block.id, {
  126. content: newBlock.content,
  127. metadata: newBlock.metadata,
  128. });
  129. message.success('已插入行');
  130. } catch (error: unknown) {
  131. message.error(getOperationError(error, '插入行失败'));
  132. }
  133. }, [block, canEditTableStructure, operationRow, updateBlock]);
  134. // 在上方插入行
  135. const handleInsertRowBefore = useCallback(() => {
  136. if (!canEditTableStructure) return;
  137. try {
  138. const newBlock = insertTableRowBefore(block, operationRow);
  139. updateBlock(block.id, {
  140. content: newBlock.content,
  141. metadata: newBlock.metadata,
  142. });
  143. message.success('已在上方插入行');
  144. } catch (error: unknown) {
  145. message.error(getOperationError(error, '插入行失败'));
  146. }
  147. }, [block, canEditTableStructure, operationRow, updateBlock]);
  148. // 插入列
  149. const handleInsertColumn = useCallback(() => {
  150. if (!canEditTableStructure) return;
  151. try {
  152. const newBlock = insertTableColumn(block, operationCol);
  153. updateBlock(block.id, {
  154. content: newBlock.content,
  155. metadata: newBlock.metadata,
  156. });
  157. message.success('已插入列');
  158. } catch (error: unknown) {
  159. message.error(getOperationError(error, '插入列失败'));
  160. }
  161. }, [block, canEditTableStructure, operationCol, updateBlock]);
  162. // 在左侧插入列
  163. const handleInsertColumnBefore = useCallback(() => {
  164. if (!canEditTableStructure) return;
  165. try {
  166. const newBlock = insertTableColumnBefore(block, operationCol);
  167. updateBlock(block.id, {
  168. content: newBlock.content,
  169. metadata: newBlock.metadata,
  170. });
  171. message.success('已在左侧插入列');
  172. } catch (error: unknown) {
  173. message.error(getOperationError(error, '插入列失败'));
  174. }
  175. }, [block, canEditTableStructure, operationCol, updateBlock]);
  176. // 删除行
  177. const handleDeleteRow = useCallback(() => {
  178. if (!canEditTableStructure) return;
  179. try {
  180. const newBlock = deleteTableRow(block, operationRow);
  181. updateBlock(block.id, {
  182. content: newBlock.content,
  183. metadata: newBlock.metadata,
  184. });
  185. message.success('已删除行');
  186. onClose();
  187. } catch (error: unknown) {
  188. message.error(getOperationError(error, '删除行失败'));
  189. }
  190. }, [block, canEditTableStructure, operationRow, updateBlock, onClose]);
  191. // 删除列
  192. const handleDeleteColumn = useCallback(() => {
  193. if (!canEditTableStructure) return;
  194. try {
  195. const newBlock = deleteTableColumn(block, operationCol);
  196. updateBlock(block.id, {
  197. content: newBlock.content,
  198. metadata: newBlock.metadata,
  199. });
  200. message.success('已删除列');
  201. onClose();
  202. } catch (error: unknown) {
  203. message.error(getOperationError(error, '删除列失败'));
  204. }
  205. }, [block, canEditTableStructure, operationCol, updateBlock, onClose]);
  206. // 合并单元格
  207. const handleMergeCells = useCallback(() => {
  208. if (!isMultiCellRange || !visualCellRange) {
  209. message.warning('请先选择要合并的单元格范围(Shift+点击)');
  210. return;
  211. }
  212. try {
  213. const newBlock = mergeCellsByVisualBounds(block, selectionBounds);
  214. updateBlock(block.id, {
  215. content: newBlock.content,
  216. });
  217. message.success('已合并单元格');
  218. onClose();
  219. } catch (error: unknown) {
  220. message.error(getOperationError(error, '合并单元格失败'));
  221. }
  222. }, [block, isMultiCellRange, selectionBounds, visualCellRange, updateBlock, onClose]);
  223. // 拆分单元格
  224. const handleSplitCell = useCallback(() => {
  225. if (!hasValidSelectedCell || !isMergedCell) return;
  226. try {
  227. const newBlock = splitCell(block, selectedCell.row, selectedCell.col);
  228. updateBlock(block.id, {
  229. content: newBlock.content,
  230. });
  231. message.success('已拆分单元格');
  232. onClose();
  233. } catch (error: unknown) {
  234. message.error(getOperationError(error, '拆分单元格失败'));
  235. }
  236. }, [block, hasValidSelectedCell, isMergedCell, selectedCell, updateBlock, onClose]);
  237. // 删除整个表格
  238. const handleDeleteTable = useCallback(async () => {
  239. try {
  240. await deleteBlock(block.id);
  241. message.success('已删除表格');
  242. onClose();
  243. } catch (error: unknown) {
  244. message.error(getOperationError(error, '删除表格失败'));
  245. }
  246. }, [block.id, deleteBlock, onClose]);
  247. return (
  248. <div className="table-toolbar">
  249. <Space size="small">
  250. {/* 表格宽度控制 */}
  251. <TableWidthControl
  252. key={`${block.id}-${block.metadata.table_width}-${block.metadata.table_width_unit}`}
  253. block={block}
  254. />
  255. <Divider type="vertical" style={{ margin: '0 4px' }} />
  256. {/* 插入行 */}
  257. <Button
  258. type="text"
  259. size="small"
  260. icon={<ArrowUpOutlined />}
  261. onClick={handleInsertRowBefore}
  262. disabled={!canEditTableStructure}
  263. title={canEditTableStructure ? '在上方插入行' : '请选择未合并的可见单元格'}
  264. >
  265. 上方插入行
  266. </Button>
  267. <Button
  268. type="text"
  269. size="small"
  270. icon={<ArrowDownOutlined />}
  271. onClick={handleInsertRow}
  272. disabled={!canEditTableStructure}
  273. title={canEditTableStructure ? '在下方插入行' : '请选择未合并的可见单元格'}
  274. >
  275. 在下方插入行
  276. </Button>
  277. {/* 插入列 */}
  278. <Button
  279. type="text"
  280. size="small"
  281. icon={<ArrowLeftOutlined />}
  282. onClick={handleInsertColumnBefore}
  283. disabled={!canEditTableStructure}
  284. title={canEditTableStructure ? '在左侧插入列' : '请选择未合并的可见单元格'}
  285. >
  286. 左侧插入列
  287. </Button>
  288. <Button
  289. type="text"
  290. size="small"
  291. icon={<ArrowRightOutlined />}
  292. onClick={handleInsertColumn}
  293. disabled={!canEditTableStructure}
  294. title={canEditTableStructure ? '在右侧插入列' : '请选择未合并的可见单元格'}
  295. >
  296. 在右侧插入列
  297. </Button>
  298. <Divider type="vertical" style={{ margin: '0 4px' }} />
  299. {/* 合并单元格 */}
  300. <Button
  301. type="text"
  302. size="small"
  303. icon={<MergeCellsOutlined />}
  304. onClick={handleMergeCells}
  305. disabled={!isMultiCellRange}
  306. title={isMultiCellRange ? '合并选中的单元格' : '请选择连续且不截断已有合并的单元格'}
  307. >
  308. 合并单元格
  309. </Button>
  310. {/* 拆分单元格 */}
  311. <Button
  312. type="text"
  313. size="small"
  314. icon={<SplitCellsOutlined />}
  315. onClick={handleSplitCell}
  316. disabled={!isMergedCell}
  317. title={isMergedCell ? '拆分此单元格' : '此单元格未合并'}
  318. >
  319. 拆分单元格
  320. </Button>
  321. <Divider type="vertical" style={{ margin: '0 4px' }} />
  322. {/* 删除行 */}
  323. <Popconfirm
  324. title="确定删除此行?"
  325. onConfirm={handleDeleteRow}
  326. okText="确定"
  327. cancelText="取消"
  328. >
  329. <Button
  330. type="text"
  331. size="small"
  332. icon={<MinusOutlined />}
  333. danger
  334. disabled={!canEditTableStructure || rowCount <= 1}
  335. title={canEditTableStructure ? '删除当前行' : '请选择未合并的可见单元格'}
  336. >
  337. 删除行
  338. </Button>
  339. </Popconfirm>
  340. {/* 删除列 */}
  341. <Popconfirm
  342. title="确定删除此列?"
  343. onConfirm={handleDeleteColumn}
  344. okText="确定"
  345. cancelText="取消"
  346. >
  347. <Button
  348. type="text"
  349. size="small"
  350. icon={<MinusOutlined />}
  351. danger
  352. disabled={!canEditTableStructure || columnCount <= 1}
  353. title={canEditTableStructure ? '删除当前列' : '请选择未合并的可见单元格'}
  354. >
  355. 删除列
  356. </Button>
  357. </Popconfirm>
  358. <Divider type="vertical" style={{ margin: '0 4px' }} />
  359. {/* 删除表格 */}
  360. <Popconfirm
  361. title="确定删除整个表格?"
  362. description="此操作不可恢复,表格中的所有数据将被删除"
  363. onConfirm={handleDeleteTable}
  364. okText="确定"
  365. cancelText="取消"
  366. okButtonProps={{ danger: true }}
  367. >
  368. <Button
  369. type="text"
  370. size="small"
  371. icon={<DeleteOutlined />}
  372. danger
  373. title="删除整个表格"
  374. >
  375. 删除表格
  376. </Button>
  377. </Popconfirm>
  378. </Space>
  379. </div>
  380. );
  381. };
  382. export default TableToolbar;