useTableResize.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. /**
  2. * useTableResize.ts - 表格调整大小Hook
  3. *
  4. * 实现高性能的表格列宽/行高拖拽调整功能
  5. * 参考Notion、飞书等主流编辑器的最佳实践
  6. */
  7. import { useState, useEffect, useCallback } from 'react';
  8. import type { RefObject } from 'react';
  9. import {
  10. getResizeBoundaries,
  11. isNearBoundary,
  12. pixelToPt,
  13. normalizePercents,
  14. percentToPtWidths,
  15. type ResizeBoundary
  16. } from '../utils/tableUtils';
  17. // ══════════════════════════════════════════════════════════════════════════════
  18. // Types
  19. // ══════════════════════════════════════════════════════════════════════════════
  20. /**
  21. * 拖拽状态
  22. */
  23. export interface ResizeState {
  24. isResizing: boolean; // 是否正在拖拽
  25. resizeType: 'column' | 'row'; // 拖拽类型
  26. targetIndex: number; // 目标列/行索引
  27. startPos: { x: number; y: number }; // 拖拽起始坐标
  28. currentPos: { x: number; y: number }; // 当前鼠标坐标
  29. originalSize: number; // 原始尺寸(百分比或pt)
  30. minSize: number; // 最小约束(px)
  31. startLinePosition: number; // 拖拽线起始位置(px)
  32. tableWidthPx: number; // 拖拽开始时的表格宽度(px)
  33. }
  34. /**
  35. * Hover状态
  36. */
  37. export interface HoverState {
  38. isHovering: boolean;
  39. boundary: ResizeBoundary | null;
  40. }
  41. /**
  42. * Hook参数
  43. */
  44. export interface UseTableResizeOptions {
  45. /** 表格元素引用 */
  46. tableRef: RefObject<HTMLTableElement>;
  47. /** 列宽数组(百分比) */
  48. colWidths: number[];
  49. /** 行高数组(pt) */
  50. rowHeights: number[];
  51. /** 表格总宽度百分比 */
  52. tableWidth: number;
  53. /** 是否只读 */
  54. readOnly?: boolean;
  55. /** 列宽变更回调 */
  56. onColumnResize?: (colWidths: number[], colWidthsPt: number[]) => void;
  57. /** 行高变更回调 */
  58. onRowResize?: (rowIndex: number, heightPt: number) => void;
  59. }
  60. /**
  61. * Hook返回值
  62. */
  63. export interface UseTableResizeReturn {
  64. /** 拖拽状态 */
  65. resizeState: ResizeState | null;
  66. /** Hover状态 */
  67. hoverState: HoverState;
  68. /** 虚拟拖拽线位置(px) */
  69. resizeLinePosition: number | null;
  70. /** 当前尺寸提示 */
  71. currentSizeTooltip: string | null;
  72. }
  73. // ══════════════════════════════════════════════════════════════════════════════
  74. // Constants
  75. // ══════════════════════════════════════════════════════════════════════════════
  76. const MIN_CELL_SIZE = 40; // 最小单元格尺寸(px)
  77. const HOVER_THRESHOLD = 5; // Hover检测阈值(px)
  78. // ══════════════════════════════════════════════════════════════════════════════
  79. // Hook Implementation
  80. // ══════════════════════════════════════════════════════════════════════════════
  81. /**
  82. * 表格调整大小Hook
  83. */
  84. export function useTableResize(options: UseTableResizeOptions): UseTableResizeReturn {
  85. const {
  86. tableRef,
  87. colWidths,
  88. rowHeights,
  89. tableWidth,
  90. readOnly = false,
  91. onColumnResize,
  92. onRowResize,
  93. } = options;
  94. // ────────────────────────────────────────────────────────────────────────────
  95. // State
  96. // ────────────────────────────────────────────────────────────────────────────
  97. const [resizeState, setResizeState] = useState<ResizeState | null>(null);
  98. const [hoverState, setHoverState] = useState<HoverState>({
  99. isHovering: false,
  100. boundary: null,
  101. });
  102. const [resizeLinePosition, setResizeLinePosition] = useState<number | null>(null);
  103. // ────────────────────────────────────────────────────────────────────────────
  104. // Hover Detection
  105. // ────────────────────────────────────────────────────────────────────────────
  106. /**
  107. * 处理鼠标移动 - 检测边界Hover
  108. */
  109. const handleTableMouseMove = useCallback(
  110. (e: MouseEvent) => {
  111. if (readOnly || resizeState?.isResizing) return;
  112. const table = tableRef.current;
  113. if (!table) return;
  114. const rect = table.getBoundingClientRect();
  115. const mouseX = e.clientX - rect.left;
  116. const mouseY = e.clientY - rect.top;
  117. // 获取所有边界
  118. const boundaries = getResizeBoundaries(table);
  119. // 查找鼠标附近的边界
  120. const hoveredBoundary = boundaries.find((boundary: ResizeBoundary) =>
  121. isNearBoundary({ x: mouseX, y: mouseY }, boundary, HOVER_THRESHOLD)
  122. );
  123. if (hoveredBoundary) {
  124. setHoverState({
  125. isHovering: true,
  126. boundary: hoveredBoundary,
  127. });
  128. // 边界位置需要加上表格的偏移量(考虑padding等)
  129. setResizeLinePosition(hoveredBoundary.position);
  130. table.style.cursor = hoveredBoundary.type === 'column' ? 'col-resize' : 'row-resize';
  131. } else {
  132. setHoverState({
  133. isHovering: false,
  134. boundary: null,
  135. });
  136. setResizeLinePosition(null);
  137. table.style.cursor = 'default';
  138. }
  139. },
  140. [tableRef, readOnly, resizeState]
  141. );
  142. /**
  143. * 处理鼠标离开表格
  144. */
  145. const handleTableMouseLeave = useCallback(() => {
  146. if (!resizeState?.isResizing) {
  147. setHoverState({
  148. isHovering: false,
  149. boundary: null,
  150. });
  151. setResizeLinePosition(null);
  152. if (tableRef.current) {
  153. tableRef.current.style.cursor = 'default';
  154. }
  155. }
  156. }, [tableRef, resizeState]);
  157. // ────────────────────────────────────────────────────────────────────────────
  158. // Resize Start
  159. // ────────────────────────────────────────────────────────────────────────────
  160. /**
  161. * 开始拖拽
  162. */
  163. const handleResizeStart = useCallback(
  164. (e: MouseEvent) => {
  165. e.preventDefault();
  166. e.stopPropagation();
  167. if (readOnly || !hoverState.boundary) return;
  168. const table = tableRef.current;
  169. if (!table) return;
  170. const boundary = hoverState.boundary;
  171. const originalSize =
  172. boundary.type === 'column'
  173. ? colWidths[boundary.index]
  174. : rowHeights[boundary.index];
  175. setResizeState({
  176. isResizing: true,
  177. resizeType: boundary.type,
  178. targetIndex: boundary.index,
  179. startPos: { x: e.clientX, y: e.clientY },
  180. currentPos: { x: e.clientX, y: e.clientY },
  181. originalSize,
  182. minSize: MIN_CELL_SIZE,
  183. startLinePosition: boundary.position,
  184. tableWidthPx: table.offsetWidth,
  185. });
  186. // 更新拖拽线初始位置
  187. setResizeLinePosition(boundary.position);
  188. // 添加全局样式
  189. document.body.style.cursor = boundary.type === 'column' ? 'col-resize' : 'row-resize';
  190. document.body.style.userSelect = 'none';
  191. },
  192. [tableRef, hoverState, colWidths, rowHeights, readOnly]
  193. );
  194. // ────────────────────────────────────────────────────────────────────────────
  195. // Resize Move
  196. // ────────────────────────────────────────────────────────────────────────────
  197. const handleResizeMove = useCallback((e: MouseEvent) => {
  198. const currentPos = { x: e.clientX, y: e.clientY };
  199. setResizeState((currentState) =>
  200. currentState?.isResizing
  201. ? { ...currentState, currentPos }
  202. : currentState
  203. );
  204. }, []);
  205. // ────────────────────────────────────────────────────────────────────────────
  206. // Resize End
  207. // ────────────────────────────────────────────────────────────────────────────
  208. /**
  209. * 结束拖拽 - 计算最终尺寸并更新
  210. */
  211. const handleResizeEnd = useCallback(
  212. (e: MouseEvent) => {
  213. if (!resizeState?.isResizing) return;
  214. const table = tableRef.current;
  215. if (!table) return;
  216. // 计算最终偏移量
  217. const finalOffset =
  218. resizeState.resizeType === 'column'
  219. ? e.clientX - resizeState.startPos.x
  220. : e.clientY - resizeState.startPos.y;
  221. if (resizeState.resizeType === 'column') {
  222. // ──────────────────────────────────────────────────────────────────────
  223. // 列宽调整
  224. // ──────────────────────────────────────────────────────────────────────
  225. const tableWidthPx = table.offsetWidth;
  226. if (tableWidthPx <= 0) {
  227. setResizeState(null);
  228. setResizeLinePosition(null);
  229. document.body.style.cursor = '';
  230. document.body.style.userSelect = '';
  231. return;
  232. }
  233. const deltaPercent = (finalOffset / tableWidthPx) * 100;
  234. const newColWidths = [...colWidths];
  235. let newWidth = resizeState.originalSize + deltaPercent;
  236. // 调整相邻列以保持总宽度不变
  237. if (resizeState.targetIndex < newColWidths.length - 1) {
  238. const nextColWidth = newColWidths[resizeState.targetIndex + 1];
  239. const widthDiff = newWidth - resizeState.originalSize;
  240. const newNextWidth = nextColWidth - widthDiff;
  241. // 最小宽度约束(5%)
  242. if (newWidth < 5) {
  243. newWidth = 5;
  244. } else if (newNextWidth < 5) {
  245. newWidth = resizeState.originalSize + (nextColWidth - 5);
  246. }
  247. newColWidths[resizeState.targetIndex] = newWidth;
  248. newColWidths[resizeState.targetIndex + 1] = nextColWidth - (newWidth - resizeState.originalSize);
  249. } else {
  250. // 最后一列
  251. newColWidths[resizeState.targetIndex] = Math.max(5, newWidth);
  252. }
  253. // 归一化百分比
  254. const normalizedWidths = normalizePercents(newColWidths);
  255. // 计算pt单位的列宽
  256. const colWidthsPt = percentToPtWidths(normalizedWidths, tableWidth);
  257. // 触发回调
  258. onColumnResize?.(normalizedWidths, colWidthsPt);
  259. } else {
  260. // ──────────────────────────────────────────────────────────────────────
  261. // 行高调整
  262. // ──────────────────────────────────────────────────────────────────────
  263. const deltaPt = pixelToPt(finalOffset);
  264. const newHeight = Math.max(15, resizeState.originalSize + deltaPt); // 最小15pt
  265. const roundedHeight = Math.round(newHeight * 10) / 10;
  266. // 触发回调
  267. onRowResize?.(resizeState.targetIndex, roundedHeight);
  268. }
  269. // 清理状态
  270. setResizeState(null);
  271. setResizeLinePosition(null);
  272. document.body.style.cursor = '';
  273. document.body.style.userSelect = '';
  274. },
  275. [resizeState, tableRef, colWidths, tableWidth, onColumnResize, onRowResize]
  276. );
  277. // ────────────────────────────────────────────────────────────────────────────
  278. // Event Binding
  279. // ────────────────────────────────────────────────────────────────────────────
  280. /**
  281. * 绑定表格Hover事件
  282. */
  283. useEffect(() => {
  284. const table = tableRef.current;
  285. if (!table || readOnly) return;
  286. table.addEventListener('mousemove', handleTableMouseMove);
  287. table.addEventListener('mouseleave', handleTableMouseLeave);
  288. return () => {
  289. table.removeEventListener('mousemove', handleTableMouseMove);
  290. table.removeEventListener('mouseleave', handleTableMouseLeave);
  291. };
  292. }, [tableRef, readOnly, handleTableMouseMove, handleTableMouseLeave]);
  293. /**
  294. * 绑定全局拖拽事件
  295. */
  296. useEffect(() => {
  297. if (resizeState?.isResizing) {
  298. // 开始拖拽时添加mousedown监听
  299. const table = tableRef.current;
  300. if (table && hoverState.boundary) {
  301. table.addEventListener('mousedown', handleResizeStart);
  302. }
  303. window.addEventListener('mousemove', handleResizeMove);
  304. window.addEventListener('mouseup', handleResizeEnd);
  305. return () => {
  306. if (table) {
  307. table.removeEventListener('mousedown', handleResizeStart);
  308. }
  309. window.removeEventListener('mousemove', handleResizeMove);
  310. window.removeEventListener('mouseup', handleResizeEnd);
  311. document.body.style.cursor = '';
  312. document.body.style.userSelect = '';
  313. };
  314. }
  315. return undefined;
  316. }, [resizeState, tableRef, hoverState, handleResizeStart, handleResizeMove, handleResizeEnd]);
  317. /**
  318. * Hover状态变化时绑定mousedown
  319. */
  320. useEffect(() => {
  321. const table = tableRef.current;
  322. if (!table || readOnly || !hoverState.isHovering) return;
  323. table.addEventListener('mousedown', handleResizeStart);
  324. return () => {
  325. table.removeEventListener('mousedown', handleResizeStart);
  326. };
  327. }, [tableRef, readOnly, hoverState, handleResizeStart]);
  328. // ────────────────────────────────────────────────────────────────────────────
  329. // Return
  330. // ────────────────────────────────────────────────────────────────────────────
  331. // 计算尺寸提示
  332. const activeResizeLinePosition = resizeState?.isResizing
  333. ? resizeState.startLinePosition + (
  334. resizeState.resizeType === 'column'
  335. ? resizeState.currentPos.x - resizeState.startPos.x
  336. : resizeState.currentPos.y - resizeState.startPos.y
  337. )
  338. : resizeLinePosition;
  339. const currentSizeTooltip = resizeState?.isResizing
  340. ? (() => {
  341. if (resizeState.resizeType === 'column') {
  342. const offset = resizeState.currentPos.x - resizeState.startPos.x;
  343. const deltaPercent = (offset / resizeState.tableWidthPx) * 100;
  344. const newWidth = resizeState.originalSize + deltaPercent;
  345. // 计算pt值
  346. const widthPt = Math.round((tableWidth / 100) * newWidth * 478 / 100 * 10) / 10;
  347. return `宽度: ${widthPt}pt`;
  348. } else {
  349. const offset = resizeState.currentPos.y - resizeState.startPos.y;
  350. const deltaPt = pixelToPt(offset);
  351. const newHeight = Math.max(15, resizeState.originalSize + deltaPt);
  352. const roundedHeight = Math.round(newHeight * 10) / 10;
  353. return `高度: ${roundedHeight}pt`;
  354. }
  355. })()
  356. : null;
  357. return {
  358. resizeState,
  359. hoverState,
  360. resizeLinePosition: activeResizeLinePosition,
  361. currentSizeTooltip,
  362. };
  363. }