Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |