import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Undo2, Redo2, Trash2, Columns, Plus, Shrink, Expand, Heading, MousePointer2, Combine, Type, Palette, Scissors, Merge as MergeIcon, Maximize, ChevronRight, Replace, Download, Wand2, Sigma } from 'lucide-react'; import { cn } from '../lib/utils'; interface CellModel { id: string; value: string; rowSpan: number; colSpan: number; hidden: boolean; fontWeight?: string; backgroundColor?: string; } const INIT_ROWS = 6; const INIT_COLS = 5; const genId = () => `c_${Math.random().toString(36).slice(2, 9)}`; 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 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= topHeadersEnd) { roles[r][c] = 'StandardRowHeader'; continue; } roles[r][c] = 'DataCell'; } } return roles; } export default function TableEditor() { const [grid, setGrid] = useState([]); const [past, setPast] = useState([]); const [future, setFuture] = useState([]); // UI States const [spanMode, setSpanMode] = useState(false); const [showFind, setShowFind] = useState(false); const [showHeuristics, setShowHeuristics] = useState(false); const [showSymbols, setShowSymbols] = useState(false); 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 [hasStripes, setHasStripes] = useState(false); 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<'split'|'merge'>('split'); const [mergeFullSpan, setMergeFullSpan] = useState(false); const [nodeDrag, setNodeDrag] = useState(null); const [dragPreview, setDragPreview] = useState(null); useEffect(() => { 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); } setGrid(initial); }, []); const cloneGrid = (g: CellModel[][]) => g.map(row => row.map(cell => ({...cell}))); const updateGrid = useCallback((newGrid: CellModel[][]) => { setPast(oldPast => [...oldPast, cloneGrid(grid)].slice(-50)); setFuture([]); setGrid(newGrid); }, [grid]); 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 (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 = () => { 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) => { const newGrid = cloneGrid(grid); insertRow(newGrid, index); updateGrid(newGrid); }; const addCol = (index: number) => { const newGrid = cloneGrid(grid); insertCol(newGrid, index); updateGrid(newGrid); }; const isDraggingSelection = useRef(false); const handleCellMouseDown = (e: React.MouseEvent, r: number, c: number) => { if (!spanMode) return; e.preventDefault(); isDraggingSelection.current = true; setSpanDrag({ sr: r, sc: c, er: r, ec: c }); }; const handleCellMouseEnter = (r: number, c: number) => { 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) => { 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 | 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 + 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 | 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 + 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 (!nodeDrag) return; const isVerticalLine = nodeDrag.edge === 'top' || nodeDrag.edge === 'bottom'; 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 = {}; for(let ri = minR; ri <= maxR; ri++) rValues[ri] = ''; const cTarget = nodeDrag.c; insertCol(currentGrid, cTarget + 1, rValues); } else { const cValues: Record = {}; for(let ci = minC; ci <= maxC; ci++) cValues[ci] = ''; const rTarget = nodeDrag.r; insertRow(currentGrid, rTarget + 1, cValues); } updateGrid(currentGrid); setNodeDrag(null); setDragPreview(null); }; const splitCellAtEdge = (r: number, c: number, edge: 'top'|'bottom'|'left'|'right', text: string = '') => { 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 (!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 toggleBold = () => { 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); let allBold = true; for (let r = minR; r <= maxR; r++) { for (let c = minC; c <= maxC; c++) { if (!g[r][c].hidden && g[r][c].fontWeight !== 'bold') allBold = false; } } for (let r = minR; r <= maxR; r++) { for (let c = minC; c <= maxC; c++) { if (!g[r][c].hidden) g[r][c].fontWeight = allBold ? '' : 'bold'; } } } else if (focusedCell) { g[focusedCell.r][focusedCell.c].fontWeight = g[focusedCell.r][focusedCell.c].fontWeight ? '' : 'bold'; } else return; updateGrid(g); }; const cycleColor = () => { 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 roles = React.useMemo(() => showHeuristics ? computeClassifications(grid) : null, [showHeuristics, grid]); if (!grid.length) return null; return (
setSpanMode(!spanMode)} active={spanMode} title="Select Cells Mode" /> {spanMode && { if (spanDrag) executeMerge(spanDrag.sr, spanDrag.sc, spanDrag.er, spanDrag.ec); }} title="Merge Selected" />} { setNodeMode('split'); setSpanMode(false); }} active={!spanMode && nodeMode === 'split'} title="Node Mode: Split" /> { setNodeMode('merge'); setSpanMode(false); }} active={!spanMode && nodeMode === 'merge'} title="Node Mode: Merge" /> {nodeMode === 'merge' && !spanMode && ( setMergeFullSpan(!mergeFullSpan)} active={mergeFullSpan} title="Merge to Full Row/Col" /> )}
setIsCompact(!isCompact)} active={isCompact} title="Compact Text" /> setHasHeader(!hasHeader)} active={hasHeader} title="Header Styling" /> setHasStripes(!hasStripes)} active={hasStripes} title="Striped Rows" />
setShowFind(!showFind)} active={showFind} title="Find & Replace" /> setShowSymbols(!showSymbols)} active={showSymbols} title="Symbols" /> setShowHeuristics(!showHeuristics)} active={showHeuristics} title="Classify Cells (Heuristics X-Ray)" />
{showFind && (
setFindText(e.target.value)} className="px-2 py-1 border border-slate-200 rounded text-sm w-32 focus:outline-brand-500" /> setReplaceText(e.target.value)} className="px-2 py-1 border border-slate-200 rounded text-sm w-32 focus:outline-brand-500" />
)} {showHeuristics && (

Heuristics Coloring

{Object.entries(categoryColors).map(([cat, color]) => (
{cat} setCategoryColors(prev => ({...prev, [cat]: e.target.value}))} className="w-6 h-6 p-0 border-0 rounded cursor-pointer shrink-0 shadow-sm" />
))}
)} {showSymbols && (

Quick Symbols

Click to copy
{Object.entries(SYMBOL_PACKS).map(([packName, symbols]) => (
{packName}
{symbols.map(sym => ( ))}
))}
)}
))} {grid.map((row, r) => ( {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 isStriped = hasStripes && (!hasHeader || r > 0) && r % 2 === 1; return ( ); })} ))}
{grid[0].map((_, c) => ( { setSpanMode(true); setSpanDrag({ sr: 0, sc: c, er: grid.length - 1, ec: c }); }}>
{ setSpanMode(true); setSpanDrag({ sr: r, sc: 0, er: r, ec: grid[0].length - 1 }); }}> handleCellMouseDown(e, r, c)} onMouseEnter={() => handleCellMouseEnter(r, c)} onDragOver={(e) => { if (nodeDrag) { e.preventDefault(); if (nodeDrag.mode === 'split') { const isVerticalLine = nodeDrag.edge === 'top' || nodeDrag.edge === 'bottom'; setDragPreview({ type: 'line', isVertical: isVerticalLine, 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 && showHeuristics) ? "text-slate-900" : [isHeaderCell ? "bg-slate-800 text-white font-medium shadow-sm border-slate-700" : "text-slate-700 bg-white", isStriped && !isHeaderCell && "bg-slate-50", cell.backgroundColor], isTargeted && "bg-brand-100/80 ring-2 ring-inset ring-brand-500 shadow-inner z-10", spanDrag && !isTargeted && "opacity-60", cell.fontWeight === 'bold' && "font-bold" )} style={roles && showHeuristics ? { backgroundColor: categoryColors[roles[r][c] as keyof typeof categoryColors] } : undefined}> {!spanMode && ( <> {['top', 'bottom', 'left', 'right'].map((edge: any) => ( ))} {dragPreview && dragPreview.type === 'line' && ( (dragPreview.isVertical && c === dragPreview.srcC && r >= dragPreview.minR && r <= dragPreview.maxR) ? (
) : (!dragPreview.isVertical && r === dragPreview.srcR && c >= dragPreview.minC && c <= dragPreview.maxC) ? (
) : null )} {dragPreview && dragPreview.type === 'merge' && ( (r >= dragPreview.minR && r <= dragPreview.maxR && c >= dragPreview.minC && c <= dragPreview.maxC) && (
) )} )}
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" )} > {cell.value}
); } 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 (
{ 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); } }} >
{ 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 && ( )}
{isPreview && (
)}
); }; const ToolbarButton = ({ icon: Icon, onClick, active, disabled, title }: any) => ( );