/** * useTableResize.ts - 表格调整大小Hook * * 实现高性能的表格列宽/行高拖拽调整功能 * 参考Notion、飞书等主流编辑器的最佳实践 */ import { useState, useEffect, useCallback } from 'react'; import type { RefObject } from 'react'; import { getResizeBoundaries, isNearBoundary, pixelToPt, normalizePercents, percentToPtWidths, type ResizeBoundary } from '../utils/tableUtils'; // ══════════════════════════════════════════════════════════════════════════════ // Types // ══════════════════════════════════════════════════════════════════════════════ /** * 拖拽状态 */ export interface ResizeState { isResizing: boolean; // 是否正在拖拽 resizeType: 'column' | 'row'; // 拖拽类型 targetIndex: number; // 目标列/行索引 startPos: { x: number; y: number }; // 拖拽起始坐标 currentPos: { x: number; y: number }; // 当前鼠标坐标 originalSize: number; // 原始尺寸(百分比或pt) minSize: number; // 最小约束(px) startLinePosition: number; // 拖拽线起始位置(px) tableWidthPx: number; // 拖拽开始时的表格宽度(px) } /** * Hover状态 */ export interface HoverState { isHovering: boolean; boundary: ResizeBoundary | null; } /** * Hook参数 */ export interface UseTableResizeOptions { /** 表格元素引用 */ tableRef: RefObject; /** 列宽数组(百分比) */ colWidths: number[]; /** 行高数组(pt) */ rowHeights: number[]; /** 表格总宽度百分比 */ tableWidth: number; /** 是否只读 */ readOnly?: boolean; /** 列宽变更回调 */ onColumnResize?: (colWidths: number[], colWidthsPt: number[]) => void; /** 行高变更回调 */ onRowResize?: (rowIndex: number, heightPt: number) => void; } /** * Hook返回值 */ export interface UseTableResizeReturn { /** 拖拽状态 */ resizeState: ResizeState | null; /** Hover状态 */ hoverState: HoverState; /** 虚拟拖拽线位置(px) */ resizeLinePosition: number | null; /** 当前尺寸提示 */ currentSizeTooltip: string | null; } // ══════════════════════════════════════════════════════════════════════════════ // Constants // ══════════════════════════════════════════════════════════════════════════════ const MIN_CELL_SIZE = 40; // 最小单元格尺寸(px) const HOVER_THRESHOLD = 5; // Hover检测阈值(px) // ══════════════════════════════════════════════════════════════════════════════ // Hook Implementation // ══════════════════════════════════════════════════════════════════════════════ /** * 表格调整大小Hook */ export function useTableResize(options: UseTableResizeOptions): UseTableResizeReturn { const { tableRef, colWidths, rowHeights, tableWidth, readOnly = false, onColumnResize, onRowResize, } = options; // ──────────────────────────────────────────────────────────────────────────── // State // ──────────────────────────────────────────────────────────────────────────── const [resizeState, setResizeState] = useState(null); const [hoverState, setHoverState] = useState({ isHovering: false, boundary: null, }); const [resizeLinePosition, setResizeLinePosition] = useState(null); // ──────────────────────────────────────────────────────────────────────────── // Hover Detection // ──────────────────────────────────────────────────────────────────────────── /** * 处理鼠标移动 - 检测边界Hover */ const handleTableMouseMove = useCallback( (e: MouseEvent) => { if (readOnly || resizeState?.isResizing) return; const table = tableRef.current; if (!table) return; const rect = table.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // 获取所有边界 const boundaries = getResizeBoundaries(table); // 查找鼠标附近的边界 const hoveredBoundary = boundaries.find((boundary: ResizeBoundary) => isNearBoundary({ x: mouseX, y: mouseY }, boundary, HOVER_THRESHOLD) ); if (hoveredBoundary) { setHoverState({ isHovering: true, boundary: hoveredBoundary, }); // 边界位置需要加上表格的偏移量(考虑padding等) setResizeLinePosition(hoveredBoundary.position); table.style.cursor = hoveredBoundary.type === 'column' ? 'col-resize' : 'row-resize'; } else { setHoverState({ isHovering: false, boundary: null, }); setResizeLinePosition(null); table.style.cursor = 'default'; } }, [tableRef, readOnly, resizeState] ); /** * 处理鼠标离开表格 */ const handleTableMouseLeave = useCallback(() => { if (!resizeState?.isResizing) { setHoverState({ isHovering: false, boundary: null, }); setResizeLinePosition(null); if (tableRef.current) { tableRef.current.style.cursor = 'default'; } } }, [tableRef, resizeState]); // ──────────────────────────────────────────────────────────────────────────── // Resize Start // ──────────────────────────────────────────────────────────────────────────── /** * 开始拖拽 */ const handleResizeStart = useCallback( (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (readOnly || !hoverState.boundary) return; const table = tableRef.current; if (!table) return; const boundary = hoverState.boundary; const originalSize = boundary.type === 'column' ? colWidths[boundary.index] : rowHeights[boundary.index]; setResizeState({ isResizing: true, resizeType: boundary.type, targetIndex: boundary.index, startPos: { x: e.clientX, y: e.clientY }, currentPos: { x: e.clientX, y: e.clientY }, originalSize, minSize: MIN_CELL_SIZE, startLinePosition: boundary.position, tableWidthPx: table.offsetWidth, }); // 更新拖拽线初始位置 setResizeLinePosition(boundary.position); // 添加全局样式 document.body.style.cursor = boundary.type === 'column' ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; }, [tableRef, hoverState, colWidths, rowHeights, readOnly] ); // ──────────────────────────────────────────────────────────────────────────── // Resize Move // ──────────────────────────────────────────────────────────────────────────── const handleResizeMove = useCallback((e: MouseEvent) => { const currentPos = { x: e.clientX, y: e.clientY }; setResizeState((currentState) => currentState?.isResizing ? { ...currentState, currentPos } : currentState ); }, []); // ──────────────────────────────────────────────────────────────────────────── // Resize End // ──────────────────────────────────────────────────────────────────────────── /** * 结束拖拽 - 计算最终尺寸并更新 */ const handleResizeEnd = useCallback( (e: MouseEvent) => { if (!resizeState?.isResizing) return; const table = tableRef.current; if (!table) return; // 计算最终偏移量 const finalOffset = resizeState.resizeType === 'column' ? e.clientX - resizeState.startPos.x : e.clientY - resizeState.startPos.y; if (resizeState.resizeType === 'column') { // ────────────────────────────────────────────────────────────────────── // 列宽调整 // ────────────────────────────────────────────────────────────────────── const tableWidthPx = table.offsetWidth; if (tableWidthPx <= 0) { setResizeState(null); setResizeLinePosition(null); document.body.style.cursor = ''; document.body.style.userSelect = ''; return; } const deltaPercent = (finalOffset / tableWidthPx) * 100; const newColWidths = [...colWidths]; let newWidth = resizeState.originalSize + deltaPercent; // 调整相邻列以保持总宽度不变 if (resizeState.targetIndex < newColWidths.length - 1) { const nextColWidth = newColWidths[resizeState.targetIndex + 1]; const widthDiff = newWidth - resizeState.originalSize; const newNextWidth = nextColWidth - widthDiff; // 最小宽度约束(5%) if (newWidth < 5) { newWidth = 5; } else if (newNextWidth < 5) { newWidth = resizeState.originalSize + (nextColWidth - 5); } newColWidths[resizeState.targetIndex] = newWidth; newColWidths[resizeState.targetIndex + 1] = nextColWidth - (newWidth - resizeState.originalSize); } else { // 最后一列 newColWidths[resizeState.targetIndex] = Math.max(5, newWidth); } // 归一化百分比 const normalizedWidths = normalizePercents(newColWidths); // 计算pt单位的列宽 const colWidthsPt = percentToPtWidths(normalizedWidths, tableWidth); // 触发回调 onColumnResize?.(normalizedWidths, colWidthsPt); } else { // ────────────────────────────────────────────────────────────────────── // 行高调整 // ────────────────────────────────────────────────────────────────────── const deltaPt = pixelToPt(finalOffset); const newHeight = Math.max(15, resizeState.originalSize + deltaPt); // 最小15pt const roundedHeight = Math.round(newHeight * 10) / 10; // 触发回调 onRowResize?.(resizeState.targetIndex, roundedHeight); } // 清理状态 setResizeState(null); setResizeLinePosition(null); document.body.style.cursor = ''; document.body.style.userSelect = ''; }, [resizeState, tableRef, colWidths, tableWidth, onColumnResize, onRowResize] ); // ──────────────────────────────────────────────────────────────────────────── // Event Binding // ──────────────────────────────────────────────────────────────────────────── /** * 绑定表格Hover事件 */ useEffect(() => { const table = tableRef.current; if (!table || readOnly) return; table.addEventListener('mousemove', handleTableMouseMove); table.addEventListener('mouseleave', handleTableMouseLeave); return () => { table.removeEventListener('mousemove', handleTableMouseMove); table.removeEventListener('mouseleave', handleTableMouseLeave); }; }, [tableRef, readOnly, handleTableMouseMove, handleTableMouseLeave]); /** * 绑定全局拖拽事件 */ useEffect(() => { if (resizeState?.isResizing) { // 开始拖拽时添加mousedown监听 const table = tableRef.current; if (table && hoverState.boundary) { table.addEventListener('mousedown', handleResizeStart); } window.addEventListener('mousemove', handleResizeMove); window.addEventListener('mouseup', handleResizeEnd); return () => { if (table) { table.removeEventListener('mousedown', handleResizeStart); } window.removeEventListener('mousemove', handleResizeMove); window.removeEventListener('mouseup', handleResizeEnd); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; } return undefined; }, [resizeState, tableRef, hoverState, handleResizeStart, handleResizeMove, handleResizeEnd]); /** * Hover状态变化时绑定mousedown */ useEffect(() => { const table = tableRef.current; if (!table || readOnly || !hoverState.isHovering) return; table.addEventListener('mousedown', handleResizeStart); return () => { table.removeEventListener('mousedown', handleResizeStart); }; }, [tableRef, readOnly, hoverState, handleResizeStart]); // ──────────────────────────────────────────────────────────────────────────── // Return // ──────────────────────────────────────────────────────────────────────────── // 计算尺寸提示 const activeResizeLinePosition = resizeState?.isResizing ? resizeState.startLinePosition + ( resizeState.resizeType === 'column' ? resizeState.currentPos.x - resizeState.startPos.x : resizeState.currentPos.y - resizeState.startPos.y ) : resizeLinePosition; const currentSizeTooltip = resizeState?.isResizing ? (() => { if (resizeState.resizeType === 'column') { const offset = resizeState.currentPos.x - resizeState.startPos.x; const deltaPercent = (offset / resizeState.tableWidthPx) * 100; const newWidth = resizeState.originalSize + deltaPercent; // 计算pt值 const widthPt = Math.round((tableWidth / 100) * newWidth * 478 / 100 * 10) / 10; return `宽度: ${widthPt}pt`; } else { const offset = resizeState.currentPos.y - resizeState.startPos.y; const deltaPt = pixelToPt(offset); const newHeight = Math.max(15, resizeState.originalSize + deltaPt); const roundedHeight = Math.round(newHeight * 10) / 10; return `高度: ${roundedHeight}pt`; } })() : null; return { resizeState, hoverState, resizeLinePosition: activeResizeLinePosition, currentSizeTooltip, }; }