Ag27 Deployer
Deploy Ag27 Table Extractor: 2026-04-29 19:38:38
df4a1a2
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<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() {
const [grid, setGrid] = useState<CellModel[][]>([]);
const [past, setPast] = useState<CellModel[][][]>([]);
const [future, setFuture] = useState<CellModel[][][]>([]);
// 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<any>(null);
const [dragPreview, setDragPreview] = useState<any>(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<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 + 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 + 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<number, string> = {};
for(let ri = minR; ri <= maxR; ri++) rValues[ri] = '';
const cTarget = nodeDrag.c;
insertCol(currentGrid, cTarget + 1, rValues);
} else {
const cValues: Record<number, string> = {};
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 (
<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={spanMode ? Combine : MousePointer2} onClick={() => setSpanMode(!spanMode)} active={spanMode} title="Select Cells Mode" />
{spanMode && <ToolbarButton icon={Combine} onClick={() => { if (spanDrag) executeMerge(spanDrag.sr, spanDrag.sc, spanDrag.er, spanDrag.ec); }} title="Merge Selected" />}
<ToolbarButton icon={Scissors} onClick={() => { setNodeMode('split'); setSpanMode(false); }} active={!spanMode && nodeMode === 'split'} title="Node Mode: Split" />
<ToolbarButton icon={MergeIcon} onClick={() => { setNodeMode('merge'); setSpanMode(false); }} active={!spanMode && nodeMode === 'merge'} title="Node Mode: Merge" />
{nodeMode === 'merge' && !spanMode && (
<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" />
<ToolbarButton icon={Columns} onClick={() => setHasStripes(!hasStripes)} active={hasStripes} title="Striped Rows" />
<div className="w-px h-6 bg-slate-200 mx-2" />
<ToolbarButton icon={Type} onClick={toggleBold} active={focusedCell && grid[focusedCell.r]?.[focusedCell.c]?.fontWeight === 'bold'} title="Bold" />
<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={() => setShowHeuristics(!showHeuristics)} active={showHeuristics} 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>
{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>
)}
{showHeuristics && (
<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>
<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">
<h3 className="font-medium text-sm text-slate-800">Quick Symbols</h3>
<span className="text-xs text-slate-400">Click to copy</span>
</div>
<div className="flex flex-col gap-4 max-h-80 overflow-y-auto">
{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 className={cn("flex-1 w-full overflow-auto p-8 pt-0 flex justify-center pb-32", spanMode && "cursor-crosshair")}>
<div className="relative inline-block w-full max-w-7xl mx-auto h-max flex justify-center">
<table className={cn(
"border-collapse bg-white shadow-md ring-1 ring-slate-300 rounded-sm transition-all duration-300 mx-auto",
isCompact ? "w-max" : "w-full table-fixed"
)}>
<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={() => { setSpanMode(true); setSpanDrag({ sr: 0, sc: c, er: grid.length - 1, ec: c }); }}>
<button onClick={() => 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={() => { setSpanMode(true); setSpanDrag({ sr: r, sc: 0, er: r, ec: grid[0].length - 1 }); }}>
<button onClick={() => 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 isStriped = hasStripes && (!hasHeader || r > 0) && r % 2 === 1;
return (
<td key={cell.id} rowSpan={cell.rowSpan} colSpan={cell.colSpan}
onMouseDown={(e) => 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) => (
<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 === 'line' && (
(dragPreview.isVertical && c === dragPreview.srcC && r >= dragPreview.minR && r <= dragPreview.maxR) ? (
<div className="absolute top-0 bottom-0 left-1/2 border-l-2 border-dashed border-brand-500 z-40 -translate-x-1/2 pointer-events-none" />
) : (!dragPreview.isVertical && r === dragPreview.srcR && c >= dragPreview.minC && c <= dragPreview.maxC) ? (
<div className="absolute left-0 right-0 top-1/2 border-t-2 border-dashed border-brand-500 z-40 -translate-y-1/2 pointer-events-none" />
) : null
)}
{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
data-cell-r={r} data-cell-c={c}
contentEditable={!spanMode}
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"
)}
>
{cell.value}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</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>
);