"use client"; import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties, } from "react"; import Spreadsheet, { type CellBase, type Matrix, type Selection, type Point, RangeSelection, PointRange, createEmptyMatrix, } from "react-spreadsheet"; import { read, utils, write } from "xlsx"; import { fileExt, isTextSpreadsheet, columnLabel, cellRef, sheetToMatrix, matrixToSheet, matrixToCsv, selectionStats, } from "./spreadsheet-utils"; import { fileWriteUrl } from "@/lib/workspace-paths"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type SpreadsheetEditorProps = { url: string; filename: string; filePath: string; compact?: boolean; }; type SheetState = { data: Matrix; name: string; }; type UndoEntry = { sheetIdx: number; data: Matrix; }; // --------------------------------------------------------------------------- // Context menu // --------------------------------------------------------------------------- type ContextMenuState = { x: number; y: number; row: number; col: number; } | null; function ContextMenu({ state, onClose, onInsertRowAbove, onInsertRowBelow, onInsertColLeft, onInsertColRight, onDeleteRow, onDeleteCol, onClearCells, }: { state: ContextMenuState; onClose: () => void; onInsertRowAbove: () => void; onInsertRowBelow: () => void; onInsertColLeft: () => void; onInsertColRight: () => void; onDeleteRow: () => void; onDeleteCol: () => void; onClearCells: () => void; }) { const ref = useRef(null); useEffect(() => { if (!state) {return;} const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) {onClose();} }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [state, onClose]); if (!state) {return null;} const itemStyle: CSSProperties = { padding: "6px 12px", fontSize: "12px", cursor: "pointer", color: "var(--color-text)", borderRadius: "4px", }; const items: { label: string; action: () => void; danger?: boolean }[] = [ { label: "Insert row above", action: onInsertRowAbove }, { label: "Insert row below", action: onInsertRowBelow }, { label: "Insert column left", action: onInsertColLeft }, { label: "Insert column right", action: onInsertColRight }, { label: "Delete row", action: onDeleteRow, danger: true }, { label: "Delete column", action: onDeleteCol, danger: true }, { label: "Clear cells", action: onClearCells, danger: true }, ]; return (
{items.map((item, i) => (
{i === 4 && (
)}
))}
); } // --------------------------------------------------------------------------- // Search bar // --------------------------------------------------------------------------- function SearchBar({ data, onNavigate, onClose, }: { data: Matrix; onNavigate: (point: Point) => void; onClose: () => void; }) { const [query, setQuery] = useState(""); const [matches, setMatches] = useState([]); const [currentMatch, setCurrentMatch] = useState(0); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { if (!query.trim()) { setMatches([]); setCurrentMatch(0); return; } const q = query.toLowerCase(); const found: Point[] = []; data.forEach((row, r) => { row?.forEach((cell, c) => { if (cell && String(cell.value).toLowerCase().includes(q)) { found.push({ row: r, column: c }); } }); }); setMatches(found); setCurrentMatch(0); if (found.length > 0) {onNavigate(found[0]);} }, [query, data, onNavigate]); const goTo = useCallback( (idx: number) => { if (matches.length === 0) {return;} const wrapped = ((idx % matches.length) + matches.length) % matches.length; setCurrentMatch(wrapped); onNavigate(matches[wrapped]); }, [matches, onNavigate], ); return (
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") {onClose();} if (e.key === "Enter") { e.preventDefault(); goTo(e.shiftKey ? currentMatch - 1 : currentMatch + 1); } }} placeholder="Find in sheet..." style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "12px", color: "var(--color-text)", }} /> {query && ( {matches.length > 0 ? `${currentMatch + 1} of ${matches.length}` : "No results"} )}
); } // --------------------------------------------------------------------------- // Toolbar // --------------------------------------------------------------------------- function ToolbarButton({ onClick, title, disabled, children, accent, }: { onClick: () => void; title: string; disabled?: boolean; children: React.ReactNode; accent?: boolean; }) { return ( ); } function ToolbarSep() { return (
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function SpreadsheetEditor({ url, filename, filePath, compact = false, }: SpreadsheetEditorProps) { const [sheets, setSheets] = useState(null); const [activeSheet, setActiveSheet] = useState(0); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [saveFlash, setSaveFlash] = useState(false); const [activeCell, setActiveCell] = useState(null); const [selection, setSelection] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [showSearch, setShowSearch] = useState(false); const spreadsheetRef = useRef<{ activate: (p: Point) => void } | null>(null); // Undo stack (simple: stores full sheet snapshots) const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); // ------------------------------------------------------------------------- // Load // ------------------------------------------------------------------------- useEffect(() => { let cancelled = false; setSheets(null); setActiveSheet(0); setError(null); setDirty(false); setUndoStack([]); setRedoStack([]); fetch(url) .then((res) => { if (!res.ok) {throw new Error(`Failed to load file (${res.status})`);} return res.arrayBuffer(); }) .then((buf) => { if (cancelled) {return;} const wb = read(buf, { type: "array" }); const loaded: SheetState[] = wb.SheetNames.map((name) => ({ name, data: sheetToMatrix(wb.Sheets[name]), })); if (loaded.length === 0) { loaded.push({ name: "Sheet1", data: createEmptyMatrix(50, 26), }); } setSheets(loaded); }) .catch((err) => { if (!cancelled) {setError(String(err));} }); return () => { cancelled = true; }; }, [url]); // ------------------------------------------------------------------------- // Current sheet helpers // ------------------------------------------------------------------------- const currentData = sheets?.[activeSheet]?.data ?? []; const colCount = useMemo(() => { let max = 0; for (const row of currentData) { if (row) {max = Math.max(max, row.length);} } return max; }, [currentData]); const columnLabels = useMemo( () => Array.from({ length: colCount }, (_, i) => columnLabel(i)), [colCount], ); // ------------------------------------------------------------------------- // Mutations // ------------------------------------------------------------------------- const pushUndo = useCallback(() => { if (!sheets) {return;} setUndoStack((prev) => [ ...prev.slice(-49), { sheetIdx: activeSheet, data: sheets[activeSheet].data }, ]); setRedoStack([]); }, [sheets, activeSheet]); const updateCurrentSheet = useCallback( (newData: Matrix) => { setSheets((prev) => { if (!prev) {return prev;} const next = [...prev]; next[activeSheet] = { ...next[activeSheet], data: newData }; return next; }); setDirty(true); }, [activeSheet], ); const handleChange = useCallback( (newData: Matrix) => { pushUndo(); updateCurrentSheet(newData); }, [pushUndo, updateCurrentSheet], ); const undo = useCallback(() => { if (undoStack.length === 0 || !sheets) {return;} const entry = undoStack[undoStack.length - 1]; setRedoStack((prev) => [ ...prev, { sheetIdx: activeSheet, data: sheets[activeSheet].data }, ]); setUndoStack((prev) => prev.slice(0, -1)); setSheets((prev) => { if (!prev) {return prev;} const next = [...prev]; next[entry.sheetIdx] = { ...next[entry.sheetIdx], data: entry.data }; return next; }); if (entry.sheetIdx !== activeSheet) {setActiveSheet(entry.sheetIdx);} }, [undoStack, sheets, activeSheet]); const redo = useCallback(() => { if (redoStack.length === 0 || !sheets) {return;} const entry = redoStack[redoStack.length - 1]; setUndoStack((prev) => [ ...prev, { sheetIdx: activeSheet, data: sheets[activeSheet].data }, ]); setRedoStack((prev) => prev.slice(0, -1)); setSheets((prev) => { if (!prev) {return prev;} const next = [...prev]; next[entry.sheetIdx] = { ...next[entry.sheetIdx], data: entry.data }; return next; }); if (entry.sheetIdx !== activeSheet) {setActiveSheet(entry.sheetIdx);} }, [redoStack, sheets, activeSheet]); // Row/col operations const insertRow = useCallback( (at: number) => { pushUndo(); const newRow = Array.from({ length: colCount }, () => ({ value: "" }) as CellBase); const next = [...currentData]; next.splice(at, 0, newRow); updateCurrentSheet(next); }, [pushUndo, updateCurrentSheet, currentData, colCount], ); const deleteRow = useCallback( (at: number) => { if (currentData.length <= 1) {return;} pushUndo(); const next = [...currentData]; next.splice(at, 1); updateCurrentSheet(next); }, [pushUndo, updateCurrentSheet, currentData], ); const insertCol = useCallback( (at: number) => { pushUndo(); const next = currentData.map((row) => { const r = [...(row ?? [])]; r.splice(at, 0, { value: "" }); return r; }); updateCurrentSheet(next); }, [pushUndo, updateCurrentSheet, currentData], ); const deleteCol = useCallback( (at: number) => { if (colCount <= 1) {return;} pushUndo(); const next = currentData.map((row) => { const r = [...(row ?? [])]; r.splice(at, 1); return r; }); updateCurrentSheet(next); }, [pushUndo, updateCurrentSheet, currentData, colCount], ); const clearSelection = useCallback(() => { if (!selection) {return;} const range = selection.toRange(currentData); if (!range) {return;} pushUndo(); const next = currentData.map((row) => (row ? [...row] : [])); for (const pt of range) { if (next[pt.row]) { next[pt.row][pt.column] = { value: "" }; } } updateCurrentSheet(next); }, [selection, currentData, pushUndo, updateCurrentSheet]); // ------------------------------------------------------------------------- // Save // ------------------------------------------------------------------------- const save = useCallback(async () => { if (!sheets || saving) {return;} setSaving(true); try { if (isTextSpreadsheet(filename)) { const sep = fileExt(filename) === "tsv" ? "\t" : ","; const text = matrixToCsv(sheets[activeSheet].data, sep); const res = await fetch(fileWriteUrl(filePath), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: filePath, content: text }), }); if (!res.ok) {throw new Error("Save failed");} } else { const wb = utils.book_new(); for (const s of sheets) { utils.book_append_sheet(wb, matrixToSheet(s.data), s.name); } const buf = write(wb, { type: "array", bookType: fileExt(filename) as "xlsx" }); const res = await fetch( `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}`, { method: "POST", body: buf }, ); if (!res.ok) {throw new Error("Save failed");} } setDirty(false); setSaveFlash(true); setTimeout(() => setSaveFlash(false), 1500); } catch (err) { console.error("Spreadsheet save error:", err); } finally { setSaving(false); } }, [sheets, saving, filename, filePath, activeSheet]); // ------------------------------------------------------------------------- // Keyboard shortcuts // ------------------------------------------------------------------------- useEffect(() => { const handler = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; if (mod && e.key === "s") { e.preventDefault(); void save(); } if (mod && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } if (mod && e.key === "z" && e.shiftKey) { e.preventDefault(); redo(); } if (mod && e.key === "f") { e.preventDefault(); setShowSearch((p) => !p); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [save, undo, redo]); // Context menu on spreadsheet area const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); const target = e.target as HTMLElement; const td = target.closest("td"); if (!td) {return;} const tr = td.closest("tr"); if (!tr) {return;} const tbody = tr.closest("tbody"); if (!tbody) {return;} const rowIdx = Array.from(tbody.children).indexOf(tr); const colIdx = Array.from(tr.children).indexOf(td) - 1; // subtract row indicator if (rowIdx < 0 || colIdx < 0) {return;} setContextMenu({ x: e.clientX, y: e.clientY, row: rowIdx, col: colIdx }); }, [], ); // Navigate to cell (for search) const navigateToCell = useCallback((point: Point) => { setActiveCell(point); spreadsheetRef.current?.activate(point); setSelection(new RangeSelection(new PointRange(point, point))); }, []); // ------------------------------------------------------------------------- // Formula bar value // ------------------------------------------------------------------------- const activeCellValue = useMemo(() => { if (!activeCell) {return "";} const cell = currentData[activeCell.row]?.[activeCell.column]; return cell ? String(cell.value) : ""; }, [activeCell, currentData]); const handleFormulaChange = useCallback( (value: string) => { if (!activeCell) {return;} pushUndo(); const next = currentData.map((row) => (row ? [...row] : [])); if (!next[activeCell.row]) {next[activeCell.row] = [];} next[activeCell.row][activeCell.column] = { value }; updateCurrentSheet(next); }, [activeCell, currentData, pushUndo, updateCurrentSheet], ); // ------------------------------------------------------------------------- // Stats // ------------------------------------------------------------------------- const stats = useMemo( () => selectionStats(currentData, selection), [currentData, selection], ); // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- const ext = fileExt(filename).toUpperCase() || "SPREADSHEET"; if (error) { return (

Failed to load spreadsheet

{error}

); } if (!sheets) { return (

Loading spreadsheet...

); } return (
{/* -- Header / Toolbar -------------------------------------------- */}
{filename} {ext} {dirty && ( Unsaved )} {saveFlash && ( Saved )}
{/* Toolbar buttons */} {!compact && (
activeCell && insertRow(activeCell.row)} title="Insert row above" disabled={!activeCell} > activeCell && insertCol(activeCell.column)} title="Insert column left" disabled={!activeCell} > setShowSearch((p) => !p)} title="Find (⌘F)">
)} {/* Compact save button */} {compact && dirty && ( )}
{/* -- Formula bar ------------------------------------------------- */} {!compact && (
{activeCell ? cellRef(activeCell) : "\u00A0"}
fx handleFormulaChange(e.target.value)} placeholder="Select a cell" style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "12px", color: "var(--color-text)", fontFamily: "monospace", }} />
)} {/* -- Search bar -------------------------------------------------- */} {showSearch && ( setShowSearch(false)} /> )} {/* -- Spreadsheet grid -------------------------------------------- */}
} data={currentData} onChange={handleChange} columnLabels={columnLabels} onActivate={setActiveCell} onSelect={setSelection} darkMode={typeof document !== "undefined" && document.documentElement.classList.contains("dark")} className="spreadsheet-editor-grid" />
{/* -- Context menu ------------------------------------------------ */} setContextMenu(null)} onInsertRowAbove={() => contextMenu && insertRow(contextMenu.row)} onInsertRowBelow={() => contextMenu && insertRow(contextMenu.row + 1)} onInsertColLeft={() => contextMenu && insertCol(contextMenu.col)} onInsertColRight={() => contextMenu && insertCol(contextMenu.col + 1)} onDeleteRow={() => contextMenu && deleteRow(contextMenu.row)} onDeleteCol={() => contextMenu && deleteCol(contextMenu.col)} onClearCells={clearSelection} /> {/* -- Sheet tabs -------------------------------------------------- */} {sheets.length > 1 && (
{sheets.map((s, idx) => ( ))}
)} {/* -- Status bar -------------------------------------------------- */}
{currentData.length} row{currentData.length !== 1 ? "s" : ""} {" \u00d7 "} {colCount} col{colCount !== 1 ? "s" : ""} {sheets.length > 1 ? ` \u00b7 ${sheets.length} sheets` : ""} {stats && ( Count: {stats.count} {stats.numericCount > 0 && ( <> {" \u00b7 "}Sum: {stats.sum.toLocaleString()} {" \u00b7 "}Avg: {stats.avg.toLocaleString(undefined, { maximumFractionDigits: 2 })} )} )}
); } // --------------------------------------------------------------------------- // Icon // --------------------------------------------------------------------------- function SpreadsheetIcon({ size = 16 }: { size?: number }) { return ( ); }