Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useCallback, useRef } from 'react'; | |
| import { | |
| Undo2, Redo2, Trash2, | |
| Plus, Shrink, Expand, Heading, MousePointer2, Hand, Combine, Type, Palette, | |
| Scissors, Merge as MergeIcon, Maximize, ChevronRight, Replace, Download, Wand2, Sigma, X | |
| } from 'lucide-react'; | |
| import { cn } from './tableEditorUtils'; | |
| interface CellModel { | |
| id: string; | |
| value: string; | |
| rowSpan: number; | |
| colSpan: number; | |
| hidden: boolean; | |
| fontWeight?: string; | |
| fontFamily?: string; | |
| backgroundColor?: string; | |
| } | |
| const INIT_ROWS = 6; | |
| const INIT_COLS = 5; | |
| const genId = () => `c_${Math.random().toString(36).slice(2, 9)}`; | |
| function createDefaultGrid(): CellModel[][] { | |
| const initial: CellModel[][] = []; | |
| for (let r = 0; r < INIT_ROWS; r++) { | |
| const row: CellModel[] = []; | |
| for (let c = 0; c < INIT_COLS; c++) { | |
| row.push({ | |
| id: genId(), value: r === 0 ? `Header ${c + 1}` : `Data ${r},${c + 1}`, | |
| rowSpan: 1, colSpan: 1, hidden: false | |
| }); | |
| } | |
| initial.push(row); | |
| } | |
| return initial; | |
| } | |
| function cloneGridModel(g: CellModel[][]): CellModel[][] { | |
| return g.map(row => row.map(cell => ({...cell}))); | |
| } | |
| interface TableEditorProps { | |
| initialGrid?: CellModel[][]; | |
| onGridChange?: (grid: CellModel[][]) => void; | |
| persistKey?: string | number; | |
| hoveredCell?: { r: number; c: number } | null; | |
| onHoverCellChange?: (cell: { r: number; c: number } | null) => void; | |
| } | |
| const SYMBOL_PACKS = { | |
| Financial: ['$', '€', '£', '¥', '₹', '₽', '₩', '₺', '¢', '%', '‰', '↑', '↓', '±'], | |
| Scientific: ['θ', 'π', 'σ', 'μ', 'Δ', 'Ω', 'α', 'β', 'γ', 'λ', '∞', '≈', '≠', '≤', '≥', '√', '∫', '∑', '°', '±'], | |
| Math: ['+', '-', '×', '÷', '=', '≠', '<', '>', '≤', '≥', '±', '∑', '∏', '∫', '∞', '∴', '∵', '≈', '≅', '≡'], | |
| Arrows: ['←', '↑', '→', '↓', '↔', '↕', '↖', '↗', '↘', '↙', '↩', '↪'], | |
| }; | |
| export function computeClassifications(grid: CellModel[][]): string[][] { | |
| const R = grid.length; | |
| const C = grid[0].length; | |
| let roles: string[][] = Array(R).fill(null).map(() => Array(C).fill('DataCell')); | |
| if (C === 1) { | |
| for (let r=0; r<R; r++) roles[r][0] = r === 0 ? 'TerminalHeader' : 'DataCell'; | |
| return roles; | |
| } | |
| let topHeadersEnd = 1; | |
| let changed = true; | |
| while (changed && topHeadersEnd < R) { | |
| changed = false; | |
| for (let r = 0; r < topHeadersEnd; r++) { | |
| for (let c = 0; c < C; c++) { | |
| if (!grid[r][c].hidden) { | |
| const reach = r + grid[r][c].rowSpan; | |
| if (reach > topHeadersEnd) { | |
| topHeadersEnd = reach; | |
| changed = true; | |
| } | |
| } | |
| } | |
| } | |
| if (topHeadersEnd < R && !changed) { | |
| let hasData = false; | |
| for (let c = 0; c < C; c++) { | |
| if (!grid[topHeadersEnd][c].hidden && grid[topHeadersEnd][c].value.trim() !== '') { | |
| hasData = true; | |
| } | |
| } | |
| if (!hasData) { | |
| topHeadersEnd++; | |
| changed = true; | |
| } | |
| } | |
| if (topHeadersEnd < R && !changed) { | |
| let r = topHeadersEnd - 1; | |
| let hasColSpan = false; | |
| for (let c = 0; c < C; c++) { | |
| if (!grid[r][c].hidden && grid[r][c].colSpan > 1 && grid[r][c].value.trim() !== '') { | |
| if (grid[r][c].colSpan < C) { | |
| hasColSpan = true; | |
| } | |
| } | |
| } | |
| if (hasColSpan) { | |
| topHeadersEnd++; | |
| changed = true; | |
| } | |
| } | |
| } | |
| for (let r = 0; r < R; r++) { | |
| let numVisibleInRow = 0; | |
| let lastVisibleCell = null; | |
| let lastVisibleC = -1; | |
| for (let c = 0; c < C; c++) { | |
| if (!grid[r][c].hidden) { | |
| numVisibleInRow++; | |
| lastVisibleCell = grid[r][c]; | |
| lastVisibleC = c; | |
| } | |
| } | |
| for (let c = 0; c < C; c++) { | |
| const cell = grid[r][c]; | |
| if (cell.hidden) continue; | |
| const rs = cell.rowSpan; | |
| const cs = cell.colSpan; | |
| if (r === 0 && c === 0 && (cell.value.trim() === '' || (rs > 1 && cs > 1))) { | |
| roles[r][c] = 'StubHeader'; continue; | |
| } | |
| if (cs === C && cell.value.trim() === '') { | |
| roles[r][c] = 'Padding'; continue; | |
| } | |
| let isRowEmptyAnd1x1 = true; | |
| for(let checkC = 0; checkC < C; checkC++){ | |
| if(!grid[r][checkC].hidden && (grid[r][checkC].value.trim() !== '' || grid[r][checkC].rowSpan > 1 || grid[r][checkC].colSpan > 1)){ | |
| isRowEmptyAnd1x1 = false; break; | |
| } | |
| } | |
| if (isRowEmptyAnd1x1) { | |
| roles[r][c] = 'Padding'; continue; | |
| } | |
| if (r < topHeadersEnd) { | |
| if (cs > 1) roles[r][c] = 'SuperHeader'; | |
| else if (cs === 1 && rs === 1) roles[r][c] = 'TerminalHeader'; | |
| else roles[r][c] = 'SuperHeader'; | |
| continue; | |
| } | |
| if (r === R - 1 && c === 0 && cs > 1) { | |
| roles[r][c] = 'FooterSpanner'; continue; | |
| } | |
| if (r > 0 && cs === C && C > 1) { | |
| roles[r][c] = 'StrictProjectedHeader'; continue; | |
| } | |
| if (r > 0 && (numVisibleInRow === 1 || numVisibleInRow === 2) && lastVisibleCell && lastVisibleCell.colSpan >= C - 1) { | |
| if (lastVisibleC === c) { roles[r][c] = 'LooseProjectedHeader'; continue; } | |
| } | |
| if ((c === 0 || (c === 1 && grid[r][0].hidden)) && rs > 1) { | |
| roles[r][c] = 'SuperRowHeader'; continue; | |
| } | |
| if (c === 0 && rs === 1 && r >= topHeadersEnd) { | |
| roles[r][c] = 'StandardRowHeader'; continue; | |
| } | |
| let col0Empty = true; | |
| for(let scanR=0; scanR<R; scanR++) { | |
| if (!grid[scanR][0].hidden && grid[scanR][0].value.trim() !== '') { col0Empty = false; break; } | |
| } | |
| if (col0Empty && c === 1 && rs === 1 && r >= topHeadersEnd) { | |
| roles[r][c] = 'StandardRowHeader'; continue; | |
| } | |
| roles[r][c] = 'DataCell'; | |
| } | |
| } | |
| return roles; | |
| } | |
| export default function TableEditor({ | |
| initialGrid, | |
| onGridChange, | |
| persistKey, | |
| hoveredCell = null, | |
| onHoverCellChange, | |
| }: TableEditorProps) { | |
| const [grid, setGrid] = useState<CellModel[][]>(() => ( | |
| cloneGridModel(initialGrid?.length ? initialGrid : createDefaultGrid()) | |
| )); | |
| const [past, setPast] = useState<CellModel[][][]>([]); | |
| const [future, setFuture] = useState<CellModel[][][]>([]); | |
| // UI States | |
| const [spanMode, setSpanMode] = useState(false); | |
| const [showFind, setShowFind] = useState(false); | |
| const [heuristicsEnabled, setHeuristicsEnabled] = useState(false); | |
| const [showHeuristicsPanel, setShowHeuristicsPanel] = useState(false); | |
| const [showSymbols, setShowSymbols] = useState(false); | |
| const [customSymbols, setCustomSymbols] = useState<string[]>(() => { | |
| const saved = localStorage.getItem('agent-pdf-custom-symbols'); | |
| return saved ? JSON.parse(saved) : []; | |
| }); | |
| const [newSymbolText, setNewSymbolText] = useState(''); | |
| useEffect(() => { | |
| localStorage.setItem('agent-pdf-custom-symbols', JSON.stringify(customSymbols)); | |
| }, [customSymbols]); | |
| const addCustomSymbol = () => { | |
| if (newSymbolText.trim() && !customSymbols.includes(newSymbolText.trim())) { | |
| setCustomSymbols([...customSymbols, newSymbolText.trim()]); | |
| setNewSymbolText(''); | |
| } | |
| }; | |
| const removeCustomSymbol = (sym: string) => { | |
| setCustomSymbols(customSymbols.filter(s => s !== sym)); | |
| }; | |
| const [showTypography, setShowTypography] = useState(false); | |
| const [toolMode, setToolMode] = useState<'pointer' | 'hand'>('pointer'); | |
| const [categoryColors, setCategoryColors] = useState({ | |
| StubHeader: '#f1f5f9', SuperHeader: '#dbeafe', TerminalHeader: '#bfdbfe', | |
| SuperRowHeader: '#fef3c7', StandardRowHeader: '#fde68a', Padding: '#f3f4f6', | |
| FooterSpanner: '#d1fae5', StrictProjectedHeader: '#e0e7ff', LooseProjectedHeader: '#c7d2fe', | |
| DataCell: '#ffffff' | |
| }); | |
| const [findText, setFindText] = useState(''); | |
| const [replaceText, setReplaceText] = useState(''); | |
| const [isCompact, setIsCompact] = useState(false); | |
| const [hasHeader, setHasHeader] = useState(true); | |
| const [fontFamilyChoice, setFontFamilyChoice] = useState('Inter, ui-sans-serif, system-ui, sans-serif'); | |
| const [fontWeightChoice, setFontWeightChoice] = useState('400'); | |
| const [focusedCell, setFocusedCell] = useState<{r: number, c: number} | null>(null); | |
| // Drag States for Spanning/Reordering | |
| const [spanDrag, setSpanDrag] = useState<{sr: number, sc: number, er: number, ec: number} | null>(null); | |
| // Drag States for Splitting Nodes | |
| const [nodeMode, setNodeMode] = useState<'pointer'|'split'|'merge'>('pointer'); | |
| const [mergeFullSpan, setMergeFullSpan] = useState(false); | |
| const [nodeDrag, setNodeDrag] = useState<any>(null); | |
| const [dragPreview, setDragPreview] = useState<any>(null); | |
| const panViewportRef = useRef<HTMLDivElement | null>(null); | |
| // ref on the table wrapper so we can measure real column/row pixel positions | |
| const tableWrapRef = useRef<HTMLDivElement | null>(null); | |
| const panStateRef = useRef({ active: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 }); | |
| const showStructureTools = nodeMode !== 'pointer'; | |
| const isHandMode = toolMode === 'hand'; | |
| useEffect(() => { | |
| setGrid(cloneGridModel(initialGrid?.length ? initialGrid : createDefaultGrid())); | |
| setPast([]); | |
| setFuture([]); | |
| }, [persistKey]); | |
| const cloneGrid = cloneGridModel; | |
| const clearStructureState = useCallback(() => { | |
| setSpanMode(false); | |
| setSpanDrag(null); | |
| setNodeDrag(null); | |
| setDragPreview(null); | |
| }, []); | |
| const updateGrid = useCallback((newGrid: CellModel[][]) => { | |
| setPast(oldPast => [...oldPast, cloneGrid(grid)].slice(-50)); | |
| setFuture([]); | |
| setGrid(newGrid); | |
| }, [grid]); | |
| useEffect(() => { | |
| if (!grid.length) { | |
| return; | |
| } | |
| onGridChange?.(grid); | |
| }, [grid, onGridChange]); | |
| useEffect(() => { | |
| if (!isHandMode) { | |
| return | |
| } | |
| setShowFind(false) | |
| setShowSymbols(false) | |
| setShowTypography(false) | |
| setShowHeuristicsPanel(false) | |
| setNodeMode('pointer') | |
| setSpanMode(false) | |
| }, [isHandMode]) | |
| const handleUndo = useCallback(() => { | |
| setPast(oldPast => { | |
| if (!oldPast.length) return oldPast; | |
| const prev = oldPast[oldPast.length - 1]; | |
| setGrid(currentGrid => { | |
| setFuture(oldFuture => [cloneGrid(currentGrid), ...oldFuture]); | |
| return cloneGrid(prev); | |
| }); | |
| return oldPast.slice(0, -1); | |
| }); | |
| }, []); | |
| const handleRedo = useCallback(() => { | |
| setFuture(oldFuture => { | |
| if (!oldFuture.length) return oldFuture; | |
| const next = oldFuture[0]; | |
| setGrid(currentGrid => { | |
| setPast(oldPast => [...oldPast, cloneGrid(currentGrid)]); | |
| return cloneGrid(next); | |
| }); | |
| return oldFuture.slice(1); | |
| }); | |
| }, []); | |
| const updateCellValue = (r: number, c: number, val: string) => { | |
| if (isHandMode) return; | |
| if (grid[r][c].value === val) return; | |
| const newGrid = cloneGrid(grid); | |
| newGrid[r][c].value = val; | |
| updateGrid(newGrid); | |
| }; | |
| const getVisibleCell = (g: CellModel[][], targetR: number, targetC: number) => { | |
| for(let r=targetR; r>=0; r--) { | |
| for(let c=targetC; c>=0; c--) { | |
| const cell = g[r][c]; | |
| if(!cell.hidden && r + cell.rowSpan > targetR && c + cell.colSpan > targetC) { | |
| return cell; | |
| } | |
| } | |
| } | |
| return g[targetR][targetC]; | |
| }; | |
| const trimEmpty = () => { | |
| if (isHandMode) return; | |
| let g = cloneGrid(grid); | |
| for (let r = g.length - 1; r >= 0; r--) { | |
| let isEmpty = true; | |
| for (let c = 0; c < g[r].length; c++) { | |
| if (getVisibleCell(g, r, c).value.trim()) { isEmpty = false; break; } | |
| } | |
| if (isEmpty) { | |
| for (let r_i = 0; r_i < r; r_i++) { | |
| for (let c = 0; c < g[0].length; c++) { | |
| if (!g[r_i][c].hidden && g[r_i][c].rowSpan > r - r_i) g[r_i][c].rowSpan--; | |
| } | |
| } | |
| g.splice(r, 1); | |
| } | |
| } | |
| if (g.length > 0) { | |
| for (let c = g[0].length - 1; c >= 0; c--) { | |
| let isEmpty = true; | |
| for (let r = 0; r < g.length; r++) { | |
| if (getVisibleCell(g, r, c).value.trim()) { isEmpty = false; break; } | |
| } | |
| if (isEmpty) { | |
| for (let r = 0; r < g.length; r++) { | |
| for (let c_i = 0; c_i < c; c_i++) { | |
| if (!g[r][c_i].hidden && g[r][c_i].colSpan > c - c_i) g[r][c_i].colSpan--; | |
| } | |
| g[r].splice(c, 1); | |
| } | |
| } | |
| } | |
| } | |
| if (g.length && g[0].length) updateGrid(g); | |
| }; | |
| const addRow = (index: number) => { | |
| if (isHandMode) return; | |
| const newGrid = cloneGrid(grid); | |
| insertRow(newGrid, index); | |
| clearStructureState(); | |
| updateGrid(newGrid); | |
| }; | |
| const addCol = (index: number) => { | |
| if (isHandMode) return; | |
| const newGrid = cloneGrid(grid); | |
| insertCol(newGrid, index); | |
| clearStructureState(); | |
| updateGrid(newGrid); | |
| }; | |
| const isDraggingSelection = useRef(false); | |
| const handleCellMouseDown = (e: React.MouseEvent, r: number, c: number) => { | |
| if (!spanMode || isHandMode) return; | |
| e.preventDefault(); | |
| isDraggingSelection.current = true; | |
| setSpanDrag({ sr: r, sc: c, er: r, ec: c }); | |
| }; | |
| const handleCellMouseEnter = (r: number, c: number) => { | |
| if (isHandMode) return; | |
| if (spanMode && isDraggingSelection.current && spanDrag) { | |
| setSpanDrag({ ...spanDrag, er: r, ec: c }); | |
| } | |
| }; | |
| useEffect(() => { | |
| const handleMouseUp = () => { | |
| isDraggingSelection.current = false; | |
| }; | |
| window.addEventListener('mouseup', handleMouseUp); | |
| return () => window.removeEventListener('mouseup', handleMouseUp); | |
| }, []); | |
| const executeMerge = (r1: number, c1: number, r2: number, c2: number) => { | |
| if (isHandMode) return; | |
| let minR = Math.min(r1, r2), maxR = Math.max(r1, r2); | |
| let minC = Math.min(c1, c2), maxC = Math.max(c1, c2); | |
| const newGrid = cloneGrid(grid); | |
| // Expand bounding box to encompass all intersecting spanned cells | |
| let expanded = true; | |
| while(expanded) { | |
| expanded = false; | |
| for (let r = 0; r < newGrid.length; r++) { | |
| for (let c = 0; c < newGrid[0].length; c++) { | |
| const cell = newGrid[r][c]; | |
| if (!cell.hidden) { | |
| const maxCellR = r + cell.rowSpan - 1; | |
| const maxCellC = c + cell.colSpan - 1; | |
| if (r <= maxR && maxCellR >= minR && c <= maxC && maxCellC >= minC) { | |
| if (r < minR) { minR = r; expanded = true; } | |
| if (maxCellR > maxR) { maxR = maxCellR; expanded = true; } | |
| if (c < minC) { minC = c; expanded = true; } | |
| if (maxCellC > maxC) { maxC = maxCellC; expanded = true; } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (minR === maxR && minC === maxC) return; | |
| const combinedVals: string[] = []; | |
| for (let r = minR; r <= maxR; r++) { | |
| for (let c = minC; c <= maxC; c++) { | |
| const cell = newGrid[r][c]; | |
| if (!cell.hidden && cell.value.trim()) combinedVals.push(cell.value); | |
| cell.hidden = true; cell.rowSpan = 1; cell.colSpan = 1; | |
| } | |
| } | |
| const anchor = newGrid[minR][minC]; | |
| anchor.hidden = false; | |
| anchor.rowSpan = maxR - minR + 1; | |
| anchor.colSpan = maxC - minC + 1; | |
| anchor.value = combinedVals.join(' '); | |
| updateGrid(newGrid); | |
| }; | |
| const handleMergeFull = (r: number, c: number, edge: 'top'|'bottom'|'left'|'right') => { | |
| let tR = r; let tC = c; | |
| if (edge === 'right') tC = grid[0].length - 1; | |
| if (edge === 'left') tC = 0; | |
| if (edge === 'bottom') tR = grid.length - 1; | |
| if (edge === 'top') tR = 0; | |
| if (tR !== r || tC !== c) { | |
| executeMerge(r, c, tR, tC); | |
| } | |
| }; | |
| // --- SPLIT & DROP LOGIC --- | |
| const syncGridFromDOM = () => { | |
| const newGrid = cloneGrid(grid); | |
| document.querySelectorAll('[data-cell-r]').forEach((el) => { | |
| const r = parseInt(el.getAttribute('data-cell-r')!); | |
| const c = parseInt(el.getAttribute('data-cell-c')!); | |
| const val = (el as HTMLElement).innerText; | |
| if (newGrid[r] && newGrid[r][c] && newGrid[r][c].value !== val) { | |
| newGrid[r][c].value = val; | |
| } | |
| }); | |
| return newGrid; | |
| }; | |
| const insertRow = (g: CellModel[][], insertIndex: number, cValues: Record<number, string> | null = null) => { | |
| const isSplit = cValues !== null; | |
| const numCols = g[0].length; | |
| const newRow = Array.from({length: numCols}).map((_, i) => ({ | |
| id: genId(), value: cValues ? (cValues[i] || '') : '', rowSpan: 1, colSpan: 1, hidden: false | |
| })); | |
| g.splice(insertIndex, 0, newRow); | |
| for (let r = 0; r < insertIndex; r++) { | |
| for (let c = 0; c < numCols; c++) { | |
| const cell = g[r][c]; | |
| const spanCondition = isSplit ? | |
| (r + cell.rowSpan >= insertIndex) : | |
| (r + cell.rowSpan > insertIndex); | |
| if (!cell.hidden && spanCondition) { | |
| let explicitlySplit = false; | |
| for (let ci = c; ci < c + cell.colSpan; ci++) { | |
| if (cValues && cValues[ci] !== undefined) explicitlySplit = true; | |
| } | |
| if (!explicitlySplit) { | |
| cell.rowSpan++; | |
| for (let ci = c; ci < c + cell.colSpan; ci++) { | |
| g[insertIndex][ci].hidden = true; | |
| } | |
| } else if (r + cell.rowSpan > insertIndex) { | |
| const oldRowSpan = cell.rowSpan; | |
| const topSpan = insertIndex - r; | |
| const bottomSpan = oldRowSpan - topSpan; // corrected: no +1 | |
| cell.rowSpan = topSpan; | |
| const splitCell = g[insertIndex][c]; | |
| splitCell.hidden = false; | |
| splitCell.rowSpan = bottomSpan; | |
| splitCell.colSpan = cell.colSpan; | |
| for (let ci = c + 1; ci < c + cell.colSpan; ci++) { | |
| g[insertIndex][ci].hidden = true; | |
| } | |
| } else if (r + cell.rowSpan === insertIndex) { | |
| const splitCell = g[insertIndex][c]; | |
| splitCell.colSpan = cell.colSpan; | |
| for (let ci = c + 1; ci < c + cell.colSpan; ci++) { | |
| g[insertIndex][ci].hidden = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| const insertCol = (g: CellModel[][], insertIndex: number, rValues: Record<number, string> | null = null) => { | |
| const isSplit = rValues !== null; | |
| const numRows = g.length; | |
| for (let ri = 0; ri < numRows; ri++) { | |
| g[ri].splice(insertIndex, 0, { | |
| id: genId(), value: rValues ? (rValues[ri] || '') : '', rowSpan: 1, colSpan: 1, hidden: false | |
| }); | |
| } | |
| for (let r = 0; r < numRows; r++) { | |
| for (let c = 0; c < insertIndex; c++) { | |
| const cell = g[r][c]; | |
| const spanCondition = isSplit ? | |
| (c + cell.colSpan >= insertIndex) : | |
| (c + cell.colSpan > insertIndex); | |
| if (!cell.hidden && spanCondition) { | |
| let explicitlySplit = false; | |
| for (let ri = r; ri < r + cell.rowSpan; ri++) { | |
| if (rValues && rValues[ri] !== undefined) explicitlySplit = true; | |
| } | |
| if (!explicitlySplit) { | |
| cell.colSpan++; | |
| for (let ri = r; ri < r + cell.rowSpan; ri++) { | |
| g[ri][insertIndex].hidden = true; | |
| } | |
| } else if (c + cell.colSpan > insertIndex) { | |
| const oldColSpan = cell.colSpan; | |
| const leftSpan = insertIndex - c; | |
| const rightSpan = oldColSpan - leftSpan; // corrected: no +1 | |
| cell.colSpan = leftSpan; | |
| const splitCell = g[r][insertIndex]; | |
| splitCell.hidden = false; | |
| splitCell.colSpan = rightSpan; | |
| splitCell.rowSpan = cell.rowSpan; | |
| for (let ri = r + 1; ri < r + cell.rowSpan; ri++) { | |
| g[ri][insertIndex].hidden = true; | |
| } | |
| } else if (c + cell.colSpan === insertIndex) { | |
| const splitCell = g[r][insertIndex]; | |
| splitCell.rowSpan = cell.rowSpan; | |
| for (let ri = r + 1; ri < r + cell.rowSpan; ri++) { | |
| g[ri][insertIndex].hidden = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| const handleNodeCrossSplit = (dropR: number, dropC: number) => { | |
| if (isHandMode || !nodeDrag) return; | |
| const isVerticalLine = nodeDrag.edge === 'left' || nodeDrag.edge === 'right'; | |
| const minR = Math.min(nodeDrag.r, dropR); | |
| const maxR = Math.max(nodeDrag.r, dropR); | |
| const minC = Math.min(nodeDrag.c, dropC); | |
| const maxC = Math.max(nodeDrag.c, dropC); | |
| const currentGrid = syncGridFromDOM(); | |
| if (isVerticalLine) { | |
| const rValues: Record<number, string> = {}; | |
| for(let ri = minR; ri <= maxR; ri++) rValues[ri] = ''; | |
| const insertIndex = nodeDrag.edge === 'right' ? nodeDrag.c + 1 : nodeDrag.c; | |
| insertCol(currentGrid, insertIndex, rValues); | |
| } else { | |
| const cValues: Record<number, string> = {}; | |
| for(let ci = minC; ci <= maxC; ci++) cValues[ci] = ''; | |
| const insertIndex = nodeDrag.edge === 'bottom' ? nodeDrag.r + 1 : nodeDrag.r; | |
| insertRow(currentGrid, insertIndex, cValues); | |
| } | |
| updateGrid(currentGrid); | |
| setNodeDrag(null); | |
| setDragPreview(null); | |
| }; | |
| const splitCellAtEdge = (r: number, c: number, edge: 'top'|'bottom'|'left'|'right', text: string = '') => { | |
| if (isHandMode) return; | |
| const sel = window.getSelection(); | |
| if (sel && text) sel.deleteFromDocument(); // deletes natively dragged text | |
| const currentGrid = syncGridFromDOM(); // picks up DOM after deletion | |
| const cell = currentGrid[r][c]; | |
| if (edge === 'bottom') { | |
| if (cell.rowSpan > 1) { | |
| cell.rowSpan -= 1; | |
| const newCell = currentGrid[r + cell.rowSpan][c]; | |
| newCell.hidden = false; newCell.rowSpan = 1; newCell.colSpan = cell.colSpan; | |
| if (text) newCell.value = text; | |
| } else { | |
| insertRow(currentGrid, r + 1, { [c]: text }); | |
| } | |
| } else if (edge === 'top') { | |
| if (cell.rowSpan > 1) { | |
| const oldSpan = cell.rowSpan; cell.rowSpan = 1; | |
| const newCell = currentGrid[r+1][c]; | |
| newCell.hidden = false; newCell.rowSpan = oldSpan - 1; newCell.colSpan = cell.colSpan; | |
| newCell.value = cell.value; cell.value = text; | |
| } else { | |
| insertRow(currentGrid, r + 1, { [c]: cell.value }); | |
| currentGrid[r][c].value = text; | |
| } | |
| } else if (edge === 'right') { | |
| if (cell.colSpan > 1) { | |
| cell.colSpan -= 1; | |
| const newCell = currentGrid[r][c + cell.colSpan]; | |
| newCell.hidden = false; newCell.colSpan = 1; newCell.rowSpan = cell.rowSpan; | |
| if (text) newCell.value = text; | |
| } else { | |
| insertCol(currentGrid, c + 1, { [r]: text }); | |
| } | |
| } else if (edge === 'left') { | |
| if (cell.colSpan > 1) { | |
| const oldSpan = cell.colSpan; cell.colSpan = 1; | |
| const newCell = currentGrid[r][c+1]; | |
| newCell.hidden = false; newCell.colSpan = oldSpan - 1; newCell.rowSpan = cell.rowSpan; | |
| newCell.value = cell.value; cell.value = text; | |
| } else { | |
| insertCol(currentGrid, c + 1, { [r]: cell.value }); | |
| currentGrid[r][c].value = text; | |
| } | |
| } | |
| updateGrid(currentGrid); | |
| }; | |
| const executeReplaceAll = () => { | |
| if (isHandMode) return; | |
| if (!findText) return; | |
| const g = cloneGrid(grid); | |
| let minR = 0, maxR = g.length - 1; | |
| let minC = 0, maxC = g[0].length - 1; | |
| if (spanMode && spanDrag) { | |
| minR = Math.min(spanDrag.sr, spanDrag.er); maxR = Math.max(spanDrag.sr, spanDrag.er); | |
| minC = Math.min(spanDrag.sc, spanDrag.ec); maxC = Math.max(spanDrag.sc, spanDrag.ec); | |
| } | |
| for (let r = minR; r <= maxR; r++) { | |
| for (let c = minC; c <= maxC; c++) { | |
| if (!g[r][c].hidden) { | |
| g[r][c].value = g[r][c].value.split(findText).join(replaceText); | |
| } | |
| } | |
| } | |
| updateGrid(g); | |
| }; | |
| const applyTypography = () => { | |
| if (isHandMode) return; | |
| const g = cloneGrid(grid); | |
| if (spanMode && spanDrag) { | |
| const minR = Math.min(spanDrag.sr, spanDrag.er), maxR = Math.max(spanDrag.sr, spanDrag.er); | |
| const minC = Math.min(spanDrag.sc, spanDrag.ec), maxC = Math.max(spanDrag.sc, spanDrag.ec); | |
| for (let r = minR; r <= maxR; r++) { | |
| for (let c = minC; c <= maxC; c++) { | |
| if (!g[r][c].hidden) { | |
| g[r][c].fontFamily = fontFamilyChoice; | |
| g[r][c].fontWeight = fontWeightChoice; | |
| } | |
| } | |
| } | |
| } else if (focusedCell) { | |
| g[focusedCell.r][focusedCell.c].fontFamily = fontFamilyChoice; | |
| g[focusedCell.r][focusedCell.c].fontWeight = fontWeightChoice; | |
| } else return; | |
| updateGrid(g); | |
| }; | |
| const cycleColor = () => { | |
| if (isHandMode) return; | |
| const colors = ['', 'bg-blue-100', 'bg-emerald-100', 'bg-amber-100', 'bg-rose-100', 'bg-brand-500 text-white border-brand-600']; | |
| const g = cloneGrid(grid); | |
| if (spanMode && spanDrag) { | |
| const minR = Math.min(spanDrag.sr, spanDrag.er), minC = Math.min(spanDrag.sc, spanDrag.ec); | |
| const cell = g[minR][minC]; | |
| const currentIdx = colors.indexOf(cell.backgroundColor || ''); | |
| const nextColor = colors[(currentIdx + 1) % colors.length]; | |
| const maxR = Math.max(spanDrag.sr, spanDrag.er), maxC = Math.max(spanDrag.sc, spanDrag.ec); | |
| for (let r = minR; r <= maxR; r++) { | |
| for (let c = minC; c <= maxC; c++) { | |
| if (!g[r][c].hidden) g[r][c].backgroundColor = nextColor; | |
| } | |
| } | |
| } else if (focusedCell) { | |
| const cell = g[focusedCell.r][focusedCell.c]; | |
| const currentIdx = colors.indexOf(cell.backgroundColor || ''); | |
| cell.backgroundColor = colors[(currentIdx + 1) % colors.length]; | |
| } else return; | |
| updateGrid(g); | |
| }; | |
| const exportCSV = () => { | |
| const R = grid.length; | |
| const C = grid[0].length; | |
| let csvContent = ""; | |
| for (let r = 0; r < R; r++) { | |
| let rowCells = []; | |
| for (let c = 0; c < C; c++) { | |
| if (grid[r][c].hidden) { | |
| rowCells.push(""); | |
| } else { | |
| let cellVal = grid[r][c].value.replace(/"/g, '""'); | |
| rowCells.push(`"${cellVal}"`); | |
| } | |
| } | |
| csvContent += rowCells.join(",") + "\n"; | |
| } | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.setAttribute('download', 'table.csv'); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const handlePanPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!isHandMode) return; | |
| const node = panViewportRef.current; | |
| if (!node) return; | |
| panStateRef.current = { | |
| active: true, | |
| startX: event.clientX, | |
| startY: event.clientY, | |
| scrollLeft: node.scrollLeft, | |
| scrollTop: node.scrollTop, | |
| }; | |
| node.setPointerCapture?.(event.pointerId); | |
| }; | |
| const handlePanPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!isHandMode || !panStateRef.current.active) return; | |
| const node = panViewportRef.current; | |
| if (!node) return; | |
| const deltaX = event.clientX - panStateRef.current.startX; | |
| const deltaY = event.clientY - panStateRef.current.startY; | |
| node.scrollLeft = panStateRef.current.scrollLeft - deltaX; | |
| node.scrollTop = panStateRef.current.scrollTop - deltaY; | |
| }; | |
| const handlePanPointerUp = (event: React.PointerEvent<HTMLDivElement>) => { | |
| if (!isHandMode) return; | |
| panStateRef.current.active = false; | |
| panViewportRef.current?.releasePointerCapture?.(event.pointerId); | |
| }; | |
| const roles = React.useMemo(() => heuristicsEnabled ? computeClassifications(grid) : null, [heuristicsEnabled, grid]); | |
| if (!grid.length) return null; | |
| return ( | |
| <div className="h-full flex flex-col items-center relative"> | |
| <div className="mt-8 mb-4 px-4 py-2 bg-white rounded-xl shadow-lg border border-slate-200 flex items-center gap-1.5 ring-1 ring-black/5 z-50"> | |
| <ToolbarButton icon={Undo2} onClick={handleUndo} disabled={!past.length} title="Undo" /> | |
| <ToolbarButton icon={Redo2} onClick={handleRedo} disabled={!future.length} title="Redo" /> | |
| <div className="w-px h-6 bg-slate-200 mx-2" /> | |
| <ToolbarButton | |
| icon={MousePointer2} | |
| onClick={() => { | |
| setToolMode('pointer') | |
| setNodeMode('pointer') | |
| setSpanMode(false) | |
| }} | |
| active={toolMode === 'pointer' && nodeMode === 'pointer'} | |
| title="Pointer" | |
| /> | |
| <ToolbarButton | |
| icon={Hand} | |
| onClick={() => { | |
| setToolMode('hand') | |
| setNodeMode('pointer') | |
| setSpanMode(false) | |
| }} | |
| active={isHandMode} | |
| title="Hand / Pan" | |
| /> | |
| <ToolbarButton | |
| icon={Scissors} | |
| onClick={() => { | |
| setToolMode('pointer') | |
| setNodeMode('split') | |
| setSpanMode(false) | |
| }} | |
| active={nodeMode === 'split'} | |
| title="Split" | |
| /> | |
| <ToolbarButton | |
| icon={MergeIcon} | |
| onClick={() => { | |
| setToolMode('pointer') | |
| setNodeMode('merge') | |
| setSpanMode(false) | |
| }} | |
| active={nodeMode === 'merge'} | |
| title="Merge" | |
| /> | |
| {nodeMode === 'merge' && ( | |
| <ToolbarButton icon={Maximize} onClick={() => setMergeFullSpan(!mergeFullSpan)} active={mergeFullSpan} title="Merge to Full Row/Col" /> | |
| )} | |
| <ToolbarButton icon={Trash2} onClick={trimEmpty} title="Trim Empty Rows/Cols" /> | |
| <div className="w-px h-6 bg-slate-200 mx-2" /> | |
| <ToolbarButton icon={isCompact ? Shrink : Expand} onClick={() => setIsCompact(!isCompact)} active={isCompact} title="Compact Text" /> | |
| <ToolbarButton icon={Heading} onClick={() => setHasHeader(!hasHeader)} active={hasHeader} title="Header Styling" /> | |
| <div className="w-px h-6 bg-slate-200 mx-2" /> | |
| <ToolbarButton icon={Type} onClick={() => setShowTypography(!showTypography)} active={showTypography} title="Text Style" /> | |
| <ToolbarButton icon={Palette} onClick={cycleColor} active={focusedCell && !!grid[focusedCell.r]?.[focusedCell.c]?.backgroundColor} title="Colorize Cell" /> | |
| <ToolbarButton icon={Replace} onClick={() => setShowFind(!showFind)} active={showFind} title="Find & Replace" /> | |
| <ToolbarButton icon={Sigma} onClick={() => setShowSymbols(!showSymbols)} active={showSymbols} title="Symbols" /> | |
| <ToolbarButton | |
| icon={Wand2} | |
| onClick={() => { | |
| if (!heuristicsEnabled) { | |
| setHeuristicsEnabled(true); | |
| setShowHeuristicsPanel(true); | |
| return; | |
| } | |
| setShowHeuristicsPanel(!showHeuristicsPanel); | |
| }} | |
| active={heuristicsEnabled} | |
| title="Classify Cells (Heuristics X-Ray)" | |
| /> | |
| <div className="w-px h-6 bg-slate-200 mx-2" /> | |
| <ToolbarButton icon={Download} onClick={exportCSV} title="Export CSV" /> | |
| </div> | |
| {showTypography && ( | |
| <div className="absolute top-[88px] left-1/2 -translate-x-1/2 z-50 bg-white shadow-xl border border-slate-200 rounded-lg p-3 flex items-center gap-2"> | |
| <select | |
| value={fontFamilyChoice} | |
| onChange={(e) => setFontFamilyChoice(e.target.value)} | |
| className="px-2 py-1 border border-slate-200 rounded text-sm" | |
| > | |
| <option value="Inter, ui-sans-serif, system-ui, sans-serif">Inter</option> | |
| <option value="ui-monospace, SFMono-Regular, Menlo, monospace">Monospace</option> | |
| <option value="Georgia, Cambria, serif">Serif</option> | |
| <option value="Arial, Helvetica, sans-serif">Arial</option> | |
| </select> | |
| <select | |
| value={fontWeightChoice} | |
| onChange={(e) => setFontWeightChoice(e.target.value)} | |
| className="px-2 py-1 border border-slate-200 rounded text-sm" | |
| > | |
| <option value="300">Light</option> | |
| <option value="400">Regular</option> | |
| <option value="500">Medium</option> | |
| <option value="700">Bold</option> | |
| </select> | |
| <button | |
| onClick={applyTypography} | |
| className="px-3 py-1 bg-brand-500 text-white rounded text-sm hover:bg-brand-600 transition-colors" | |
| > | |
| Apply | |
| </button> | |
| <button | |
| onClick={() => setShowTypography(false)} | |
| className="px-2 py-1 border border-slate-200 rounded text-sm" | |
| > | |
| Close | |
| </button> | |
| </div> | |
| )} | |
| {showFind && ( | |
| <div className="absolute top-[88px] left-1/2 -translate-x-1/2 z-50 bg-white shadow-xl border border-slate-200 rounded-lg p-2 flex items-center gap-2"> | |
| <input | |
| type="text" | |
| placeholder="Find..." | |
| value={findText} | |
| onChange={e => setFindText(e.target.value)} | |
| className="px-2 py-1 border border-slate-200 rounded text-sm w-32 focus:outline-brand-500" | |
| /> | |
| <input | |
| type="text" | |
| placeholder="Replace..." | |
| value={replaceText} | |
| onChange={e => setReplaceText(e.target.value)} | |
| className="px-2 py-1 border border-slate-200 rounded text-sm w-32 focus:outline-brand-500" | |
| /> | |
| <button | |
| onClick={executeReplaceAll} | |
| disabled={!findText} | |
| className="px-3 py-1 bg-brand-500 text-white rounded text-sm hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" | |
| > | |
| Replace All | |
| </button> | |
| </div> | |
| )} | |
| {showHeuristicsPanel && ( | |
| <div className="absolute top-[88px] left-1/2 -translate-x-1/2 z-50 bg-white shadow-xl border border-slate-200 rounded-lg p-4 flex flex-col gap-3 w-80"> | |
| <div className="flex items-center justify-between border-b border-slate-100 pb-2 mb-1"> | |
| <h3 className="font-medium text-sm text-slate-800">Heuristics Coloring</h3> | |
| <div className="flex items-center gap-2"> | |
| <label className="text-xs text-slate-500 flex items-center gap-1"> | |
| <input | |
| type="checkbox" | |
| checked={heuristicsEnabled} | |
| onChange={(e) => setHeuristicsEnabled(e.target.checked)} | |
| /> | |
| Enabled | |
| </label> | |
| <button | |
| type="button" | |
| className="text-slate-500 hover:text-slate-900" | |
| onClick={() => setShowHeuristicsPanel(false)} | |
| title="Close panel" | |
| > | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 gap-x-4 gap-y-2 max-h-64 overflow-y-auto"> | |
| {Object.entries(categoryColors).map(([cat, color]) => ( | |
| <div key={cat} className="flex items-center justify-between gap-3 group"> | |
| <span className="text-xs text-slate-600 font-medium truncate group-hover:text-slate-900">{cat}</span> | |
| <input | |
| type="color" | |
| value={color} | |
| onChange={(e) => setCategoryColors(prev => ({...prev, [cat]: e.target.value}))} | |
| className="w-6 h-6 p-0 border-0 rounded cursor-pointer shrink-0 shadow-sm" | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {showSymbols && ( | |
| <div className="absolute top-[88px] left-1/2 -translate-x-1/2 z-50 bg-white shadow-xl border border-slate-200 rounded-lg p-4 flex flex-col gap-4 w-[480px]"> | |
| <div className="flex items-center justify-between border-b border-slate-100 pb-2"> | |
| <div className="flex flex-col"> | |
| <h3 className="font-medium text-sm text-slate-800">Quick Symbols</h3> | |
| <span className="text-xs text-slate-400">Click to copy to clipboard</span> | |
| </div> | |
| <button | |
| onClick={() => setShowSymbols(false)} | |
| className="p-1 hover:bg-slate-100 rounded-full transition-colors text-slate-400" | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| <div className="flex gap-2"> | |
| <input | |
| type="text" | |
| placeholder="Add custom text/symbol..." | |
| value={newSymbolText} | |
| onChange={e => setNewSymbolText(e.target.value)} | |
| onKeyDown={e => e.key === 'Enter' && addCustomSymbol()} | |
| className="flex-1 px-3 py-1.5 border border-slate-200 rounded text-sm focus:outline-brand-500" | |
| /> | |
| <button | |
| onClick={addCustomSymbol} | |
| disabled={!newSymbolText.trim()} | |
| className="px-3 py-1.5 bg-brand-500 text-white rounded text-sm hover:bg-brand-600 disabled:opacity-50 transition-colors" | |
| > | |
| Add | |
| </button> | |
| </div> | |
| <div className="flex flex-col gap-4 max-h-80 overflow-y-auto pr-1"> | |
| {customSymbols.length > 0 && ( | |
| <div className="flex flex-col gap-1.5"> | |
| <span className="text-xs font-semibold text-brand-600 uppercase tracking-wider">Custom Symbols</span> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {customSymbols.map(sym => ( | |
| <div key={sym} className="group relative"> | |
| <button | |
| onClick={() => navigator.clipboard.writeText(sym)} | |
| className="h-8 px-3 flex items-center justify-center rounded bg-brand-50 border border-brand-100 hover:bg-brand-100 hover:border-brand-300 hover:text-brand-800 active:scale-95 transition-all text-sm font-medium text-brand-700" | |
| > | |
| {sym} | |
| </button> | |
| <button | |
| onClick={() => removeCustomSymbol(sym)} | |
| className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600 shadow-sm" | |
| > | |
| <X size={10} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {Object.entries(SYMBOL_PACKS).map(([packName, symbols]) => ( | |
| <div key={packName} className="flex flex-col gap-1.5"> | |
| <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{packName}</span> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {symbols.map(sym => ( | |
| <button | |
| key={sym} | |
| onClick={() => navigator.clipboard.writeText(sym)} | |
| className="w-8 h-8 flex items-center justify-center rounded bg-slate-50 border border-slate-200 hover:bg-brand-50 hover:border-brand-300 hover:text-brand-700 active:scale-95 transition-all text-sm font-medium text-slate-700" | |
| > | |
| {sym} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div | |
| ref={panViewportRef} | |
| className={cn( | |
| "flex-1 w-full overflow-auto p-8 pt-0 pb-32", | |
| isHandMode ? "cursor-grab" : spanMode ? "cursor-crosshair" : "cursor-default", | |
| )} | |
| onPointerDown={handlePanPointerDown} | |
| onPointerMove={handlePanPointerMove} | |
| onPointerUp={handlePanPointerUp} | |
| onPointerLeave={handlePanPointerUp} | |
| > | |
| <div className="relative inline-block w-max min-w-full h-max" ref={tableWrapRef}> | |
| <table className={cn( | |
| "border-collapse bg-white shadow-md ring-1 ring-slate-300 rounded-sm transition-all duration-300 mx-auto", | |
| "w-max", | |
| isCompact ? "" : "min-w-full" | |
| )}> | |
| <thead> | |
| <tr> | |
| <th className="w-8 shrink-0 bg-transparent border-0" /> | |
| {grid[0].map((_, c) => ( | |
| <th key={`colhead-${c}`} | |
| className="relative group h-8 bg-slate-100 border-b border-r border-slate-300 select-none transition-colors" | |
| onClick={() => { | |
| if (showStructureTools) { | |
| setSpanMode(true); | |
| setSpanDrag({ sr: 0, sc: c, er: grid.length - 1, ec: c }); | |
| } | |
| }}> | |
| {showStructureTools && ( | |
| <button | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| addCol(c + 1); | |
| }} | |
| className="absolute -right-2 top-1/2 -translate-y-1/2 w-4 h-4 bg-brand-500 rounded-full text-white flex items-center justify-center opacity-0 group-hover:opacity-100 hover:scale-125 z-40 shadow-sm transition-all cursor-pointer shadow-brand-500/50"> | |
| <Plus size={10} strokeWidth={3} /> | |
| </button> | |
| )} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {grid.map((row, r) => ( | |
| <tr key={`row-${r}`}> | |
| <th className="relative group w-8 bg-slate-100 border-b border-r border-slate-300 select-none transition-colors" | |
| onClick={() => { | |
| if (showStructureTools) { | |
| setSpanMode(true); | |
| setSpanDrag({ sr: r, sc: 0, er: r, ec: grid[0].length - 1 }); | |
| } | |
| }}> | |
| {showStructureTools && ( | |
| <button | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| addRow(r + 1); | |
| }} | |
| className="absolute left-1/2 -translate-x-1/2 -bottom-2 w-4 h-4 bg-brand-500 rounded-full text-white flex items-center justify-center opacity-0 group-hover:opacity-100 hover:scale-125 z-40 shadow-sm transition-all cursor-pointer shadow-brand-500/50"> | |
| <Plus size={10} strokeWidth={3} /> | |
| </button> | |
| )} | |
| </th> | |
| {row.map((cell, c) => { | |
| if (cell.hidden) return null; | |
| let isTargeted = false; | |
| if (spanDrag && spanMode) { | |
| const minR = Math.min(spanDrag.sr, spanDrag.er), maxR = Math.max(spanDrag.sr, spanDrag.er); | |
| const minC = Math.min(spanDrag.sc, spanDrag.ec), maxC = Math.max(spanDrag.sc, spanDrag.ec); | |
| if (r >= minR && r <= maxR && c >= minC && c <= maxC) isTargeted = true; | |
| } | |
| const isHeaderCell = hasHeader && r === 0; | |
| const isHoveredCell = hoveredCell?.r === r && hoveredCell?.c === c; | |
| return ( | |
| <td key={cell.id} rowSpan={cell.rowSpan} colSpan={cell.colSpan} | |
| onMouseDown={(e) => handleCellMouseDown(e, r, c)} | |
| onMouseEnter={() => { | |
| handleCellMouseEnter(r, c); | |
| onHoverCellChange?.({ r, c }); | |
| }} | |
| onMouseLeave={() => onHoverCellChange?.(null)} | |
| onDragOver={(e) => { | |
| if (nodeDrag) { | |
| e.preventDefault(); | |
| if (nodeDrag.mode === 'split') { | |
| const isVerticalLine = nodeDrag.edge === 'left' || nodeDrag.edge === 'right'; | |
| // Measure the actual pixel boundary of the drag-source cell | |
| // so the preview line appears exactly on the real column/row edge. | |
| let pixelPos: number | null = null; | |
| if (tableWrapRef.current) { | |
| const wrapRect = tableWrapRef.current.getBoundingClientRect(); | |
| // Find the anchor div for the source cell | |
| const srcDiv = tableWrapRef.current.querySelector( | |
| `[data-cell-r="${nodeDrag.r}"][data-cell-c="${nodeDrag.c}"]` | |
| ); | |
| const srcTd = srcDiv?.closest('td'); | |
| if (srcTd) { | |
| const tdRect = (srcTd as HTMLElement).getBoundingClientRect(); | |
| if (isVerticalLine) { | |
| pixelPos = nodeDrag.edge === 'right' | |
| ? tdRect.right - wrapRect.left | |
| : tdRect.left - wrapRect.left; | |
| } else { | |
| pixelPos = nodeDrag.edge === 'bottom' | |
| ? tdRect.bottom - wrapRect.top | |
| : tdRect.top - wrapRect.top; | |
| } | |
| } | |
| } | |
| setDragPreview({ | |
| type: 'line', | |
| isVertical: isVerticalLine, | |
| pixelPos, | |
| // keep indices for legacy merge preview | |
| minR: Math.min(nodeDrag.r, r), | |
| maxR: Math.max(nodeDrag.r, r), | |
| minC: Math.min(nodeDrag.c, c), | |
| maxC: Math.max(nodeDrag.c, c), | |
| srcR: nodeDrag.r, | |
| srcC: nodeDrag.c | |
| }); | |
| } else if (nodeDrag.mode === 'merge') { | |
| let tR = r; let tC = c; | |
| if (mergeFullSpan) { | |
| if (nodeDrag.edge === 'right') tC = grid[0].length - 1; | |
| if (nodeDrag.edge === 'left') tC = 0; | |
| if (nodeDrag.edge === 'bottom') tR = grid.length - 1; | |
| if (nodeDrag.edge === 'top') tR = 0; | |
| if (nodeDrag.edge === 'left' || nodeDrag.edge === 'right') tR = nodeDrag.r; | |
| if (nodeDrag.edge === 'top' || nodeDrag.edge === 'bottom') tC = nodeDrag.c; | |
| } | |
| setDragPreview({ | |
| type: 'merge', | |
| minR: Math.min(nodeDrag.r, tR), | |
| maxR: Math.max(nodeDrag.r, tR), | |
| minC: Math.min(nodeDrag.c, tC), | |
| maxC: Math.max(nodeDrag.c, tC) | |
| }); | |
| } | |
| } | |
| }} | |
| onDragLeave={(e) => { | |
| if (nodeDrag) setDragPreview(null); | |
| }} | |
| onDrop={(e) => { | |
| if (nodeDrag) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (nodeDrag.mode === 'split') { | |
| handleNodeCrossSplit(r, c); | |
| } else if (nodeDrag.mode === 'merge') { | |
| let tR = r; let tC = c; | |
| if (mergeFullSpan) { | |
| if (nodeDrag.edge === 'right') tC = grid[0].length - 1; | |
| if (nodeDrag.edge === 'left') tC = 0; | |
| if (nodeDrag.edge === 'bottom') tR = grid.length - 1; | |
| if (nodeDrag.edge === 'top') tR = 0; | |
| if (nodeDrag.edge === 'left' || nodeDrag.edge === 'right') tR = nodeDrag.r; | |
| if (nodeDrag.edge === 'top' || nodeDrag.edge === 'bottom') tC = nodeDrag.c; | |
| } | |
| executeMerge(nodeDrag.r, nodeDrag.c, tR, tC); | |
| } | |
| setNodeDrag(null); | |
| setDragPreview(null); | |
| } | |
| }} | |
| className={cn("relative group/td border border-slate-300 min-w-[80px] p-0 align-top transition-colors duration-100", | |
| (roles && heuristicsEnabled) ? "text-slate-900" : [ | |
| isHeaderCell ? "bg-slate-800 text-white font-medium shadow-sm border-slate-700" : "text-slate-700 bg-white", | |
| cell.backgroundColor | |
| ], | |
| isHoveredCell && "ring-2 ring-inset ring-blue-500 bg-blue-50/70", | |
| isTargeted && "bg-brand-100/80 ring-2 ring-inset ring-brand-500 shadow-inner z-10", | |
| spanDrag && !isTargeted && "opacity-60", | |
| )} | |
| style={roles && heuristicsEnabled ? { backgroundColor: categoryColors[roles[r][c] as keyof typeof categoryColors] } : undefined}> | |
| {showStructureTools && !spanMode && ( | |
| <> | |
| {['top', 'bottom', 'left', 'right'].map((edge: any) => ( | |
| <EdgeZone key={edge} r={r} c={c} edge={edge} | |
| nodeDrag={nodeDrag} setNodeDrag={setNodeDrag} | |
| dragPreview={dragPreview} setDragPreview={setDragPreview} | |
| splitCellAtEdge={splitCellAtEdge} | |
| nodeMode={nodeMode} | |
| mergeFullSpan={mergeFullSpan} | |
| handleMergeFull={handleMergeFull} | |
| /> | |
| ))} | |
| {dragPreview && dragPreview.type === 'merge' && ( | |
| (r >= dragPreview.minR && r <= dragPreview.maxR && c >= dragPreview.minC && c <= dragPreview.maxC) && ( | |
| <div className={cn("absolute inset-0 bg-amber-500/20 pointer-events-none z-40", | |
| r === dragPreview.minR && "border-t-2 border-amber-500", | |
| r === dragPreview.maxR && "border-b-2 border-amber-500", | |
| c === dragPreview.minC && "border-l-2 border-amber-500", | |
| c === dragPreview.maxC && "border-r-2 border-amber-500" | |
| )} /> | |
| ) | |
| )} | |
| </> | |
| )} | |
| <div | |
| key={`${cell.id}-${cell.value}`} | |
| data-cell-r={r} data-cell-c={c} | |
| contentEditable={!spanMode && !isHandMode} | |
| suppressContentEditableWarning | |
| onFocus={() => setFocusedCell({r, c})} | |
| onBlur={(e) => updateCellValue(r, c, e.currentTarget.innerText)} | |
| className={cn("w-full h-full min-h-[44px] p-3 outline-none transition-shadow relative z-10", | |
| !spanMode && "focus:ring-2 focus:ring-inset focus:ring-brand-500", | |
| isCompact ? "whitespace-nowrap" : "whitespace-pre-wrap break-words" | |
| )} | |
| style={{ | |
| fontFamily: cell.fontFamily || undefined, | |
| fontWeight: cell.fontWeight || undefined, | |
| }} | |
| > | |
| {cell.value} | |
| </div> | |
| </td> | |
| ); | |
| })} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| {dragPreview && dragPreview.type === 'line' && ( | |
| dragPreview.isVertical ? ( | |
| <div | |
| className="absolute top-0 bottom-0 border-l-2 border-dashed border-brand-500 z-50 pointer-events-none" | |
| style={{ | |
| // Use real measured pixel offset; fall back to the rough % only if measurement failed. | |
| left: dragPreview.pixelPos != null | |
| ? `${dragPreview.pixelPos}px` | |
| : `${((dragPreview.srcC + 1) / Math.max(grid[0].length, 1)) * 100}%`, | |
| transform: 'translateX(-1px)', | |
| }} | |
| /> | |
| ) : ( | |
| <div | |
| className="absolute left-0 right-0 border-t-2 border-dashed border-brand-500 z-50 pointer-events-none" | |
| style={{ | |
| top: dragPreview.pixelPos != null | |
| ? `${dragPreview.pixelPos}px` | |
| : `${((dragPreview.srcR + 1) / Math.max(grid.length, 1)) * 100}%`, | |
| transform: 'translateY(-1px)', | |
| }} | |
| /> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const EdgeZone = ({ r, c, edge, nodeDrag, setNodeDrag, dragPreview, setDragPreview, splitCellAtEdge, nodeMode, mergeFullSpan, handleMergeFull }: any) => { | |
| const isPreview = dragPreview?.r === r && dragPreview?.c === c && dragPreview?.edge === edge; | |
| const isMerge = nodeMode === 'merge'; | |
| return ( | |
| <div | |
| className={cn("absolute z-30 flex items-center justify-center opacity-0 group-hover/td:opacity-100 transition-opacity", | |
| edge === 'top' && "left-4 right-4 top-0 h-4", | |
| edge === 'top' && (isMerge ? "translate-y-1" : "-translate-y-2"), | |
| edge === 'bottom' && "left-4 right-4 bottom-0 h-4", | |
| edge === 'bottom' && (isMerge ? "-translate-y-1" : "translate-y-2"), | |
| edge === 'left' && "top-4 bottom-4 left-0 w-4", | |
| edge === 'left' && (isMerge ? "translate-x-1" : "-translate-x-2"), | |
| edge === 'right' && "top-4 bottom-4 right-0 w-4", | |
| edge === 'right' && (isMerge ? "-translate-x-1" : "translate-x-2"), | |
| )} | |
| onDragEnter={(e) => { | |
| if (!nodeDrag) { e.preventDefault(); e.stopPropagation(); } | |
| }} | |
| onDragOver={(e) => { | |
| if (!nodeDrag) { // Only preview EdgeZone when dragging text, not nodes | |
| e.preventDefault(); e.stopPropagation(); setDragPreview({r, c, edge}); | |
| } | |
| }} | |
| onDragLeave={() => { if (!nodeDrag) setDragPreview(null); }} | |
| onDrop={(e) => { | |
| if (nodeDrag) return; // Nodes are dropped on td, not EdgeZone | |
| e.preventDefault(); e.stopPropagation(); setDragPreview(null); | |
| const text = e.dataTransfer.getData('text/plain'); | |
| if (text) { | |
| splitCellAtEdge(r, c, edge, text); | |
| } | |
| }} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (nodeMode === 'split') { | |
| splitCellAtEdge(r, c, edge); | |
| } else if (nodeMode === 'merge' && mergeFullSpan) { | |
| handleMergeFull(r, c, edge); | |
| } | |
| }} | |
| > | |
| <div | |
| draggable | |
| onDragStart={(e) => { | |
| e.dataTransfer.setData('node', 'true'); | |
| const img = new Image(); | |
| img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; | |
| e.dataTransfer.setDragImage(img, 0, 0); | |
| setNodeDrag({r, c, edge, mode: nodeMode}); | |
| }} | |
| onDragEnd={() => setNodeDrag(null)} | |
| style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} | |
| className={cn("flex-shrink-0 shadow-sm border transition-transform pointer-events-auto cursor-grab active:cursor-grabbing", | |
| isMerge ? "w-3.5 h-3.5 bg-amber-500 border-amber-400 hover:scale-125 rounded-md" : "w-2.5 h-2.5 bg-brand-500 border-brand-400 hover:scale-150 rounded-full" | |
| )} | |
| > | |
| {isMerge && ( | |
| <ChevronRight size={10} strokeWidth={3} className={cn("text-white", | |
| edge === 'top' && "-rotate-90", | |
| edge === 'bottom' && "rotate-90", | |
| edge === 'left' && "rotate-180", | |
| edge === 'right' && "" | |
| )} /> | |
| )} | |
| </div> | |
| {isPreview && ( | |
| <div className={cn("absolute border-brand-500 pointer-events-none z-50", | |
| edge === 'top' && "top-1 left-0 right-0 border-t-2 border-dashed", | |
| edge === 'bottom' && "bottom-1 left-0 right-0 border-b-2 border-dashed", | |
| edge === 'left' && "left-1 top-0 bottom-0 border-l-2 border-dashed", | |
| edge === 'right' && "right-1 top-0 bottom-0 border-r-2 border-dashed" | |
| )} /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const ToolbarButton = ({ icon: Icon, onClick, active, disabled, title }: any) => ( | |
| <button | |
| onClick={onClick} disabled={disabled} title={title} | |
| className={cn("w-9 h-9 rounded-md flex items-center justify-center transition-all duration-200 outline-none", | |
| disabled ? "text-slate-300 cursor-not-allowed" : "text-slate-600 hover:bg-slate-100 active:bg-slate-200", | |
| active && !disabled ? "bg-brand-100 text-brand-700 hover:bg-brand-200 shadow-sm" : "" | |
| )} | |
| > | |
| <Icon size={18} strokeWidth={active ? 2.5 : 2} /> | |
| </button> | |
| ); | |