1164 lines
35 KiB
TypeScript
1164 lines
35 KiB
TypeScript
"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";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type SpreadsheetEditorProps = {
|
|
url: string;
|
|
filename: string;
|
|
filePath: string;
|
|
compact?: boolean;
|
|
};
|
|
|
|
type SheetState = {
|
|
data: Matrix<CellBase>;
|
|
name: string;
|
|
};
|
|
|
|
type UndoEntry = {
|
|
sheetIdx: number;
|
|
data: Matrix<CellBase>;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={ref}
|
|
style={{
|
|
position: "fixed",
|
|
top: state.y,
|
|
left: state.x,
|
|
zIndex: 9999,
|
|
background: "var(--color-surface-raised)",
|
|
border: "1px solid var(--color-border)",
|
|
borderRadius: "8px",
|
|
padding: "4px",
|
|
boxShadow: "var(--shadow-lg)",
|
|
minWidth: "180px",
|
|
}}
|
|
>
|
|
{items.map((item, i) => (
|
|
<div key={i}>
|
|
{i === 4 && (
|
|
<div
|
|
style={{
|
|
height: "1px",
|
|
background: "var(--color-border)",
|
|
margin: "4px 0",
|
|
}}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
item.action();
|
|
onClose();
|
|
}}
|
|
style={{
|
|
...itemStyle,
|
|
color: item.danger ? "var(--color-error)" : "var(--color-text)",
|
|
display: "block",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
background: "transparent",
|
|
border: "none",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background =
|
|
"var(--color-surface-hover)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
}}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search bar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SearchBar({
|
|
data,
|
|
onNavigate,
|
|
onClose,
|
|
}: {
|
|
data: Matrix<CellBase>;
|
|
onNavigate: (point: Point) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const [matches, setMatches] = useState<Point[]>([]);
|
|
const [currentMatch, setCurrentMatch] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 12px",
|
|
background: "var(--color-surface)",
|
|
borderBottom: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="var(--color-text-muted)"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => 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 && (
|
|
<span style={{ fontSize: "11px", color: "var(--color-text-muted)", whiteSpace: "nowrap" }}>
|
|
{matches.length > 0
|
|
? `${currentMatch + 1} of ${matches.length}`
|
|
: "No results"}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(currentMatch - 1)}
|
|
disabled={matches.length === 0}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
padding: "2px",
|
|
color: "var(--color-text-muted)",
|
|
opacity: matches.length === 0 ? 0.3 : 1,
|
|
}}
|
|
title="Previous (Shift+Enter)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m18 15-6-6-6 6"/></svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(currentMatch + 1)}
|
|
disabled={matches.length === 0}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
padding: "2px",
|
|
color: "var(--color-text-muted)",
|
|
opacity: matches.length === 0 ? 0.3 : 1,
|
|
}}
|
|
title="Next (Enter)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m6 9 6 6 6-6"/></svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
padding: "2px",
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
title="Close (Esc)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toolbar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ToolbarButton({
|
|
onClick,
|
|
title,
|
|
disabled,
|
|
children,
|
|
accent,
|
|
}: {
|
|
onClick: () => void;
|
|
title: string;
|
|
disabled?: boolean;
|
|
children: React.ReactNode;
|
|
accent?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: "28px",
|
|
height: "28px",
|
|
borderRadius: "6px",
|
|
border: "none",
|
|
background: accent ? "var(--color-accent)" : "transparent",
|
|
color: accent ? "#fff" : "var(--color-text-muted)",
|
|
cursor: disabled ? "default" : "pointer",
|
|
opacity: disabled ? 0.35 : 1,
|
|
flexShrink: 0,
|
|
transition: "background 0.1s, color 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!disabled && !accent)
|
|
{(e.currentTarget as HTMLElement).style.background =
|
|
"var(--color-surface-hover)";}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!accent)
|
|
{(e.currentTarget as HTMLElement).style.background = "transparent";}
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ToolbarSep() {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: "1px",
|
|
height: "18px",
|
|
background: "var(--color-border)",
|
|
margin: "0 4px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function SpreadsheetEditor({
|
|
url,
|
|
filename,
|
|
filePath,
|
|
compact = false,
|
|
}: SpreadsheetEditorProps) {
|
|
const [sheets, setSheets] = useState<SheetState[] | null>(null);
|
|
const [activeSheet, setActiveSheet] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [dirty, setDirty] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveFlash, setSaveFlash] = useState(false);
|
|
const [activeCell, setActiveCell] = useState<Point | null>(null);
|
|
const [selection, setSelection] = useState<Selection | null>(null);
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(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<UndoEntry[]>([]);
|
|
const [redoStack, setRedoStack] = useState<UndoEntry[]>([]);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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<CellBase>(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<CellBase>) => {
|
|
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<CellBase>) => {
|
|
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("/api/workspace/file", {
|
|
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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: "100%",
|
|
gap: "12px",
|
|
padding: "24px",
|
|
}}
|
|
>
|
|
<SpreadsheetIcon size={32} />
|
|
<p style={{ color: "var(--color-text)", fontWeight: 500, fontSize: "14px" }}>
|
|
Failed to load spreadsheet
|
|
</p>
|
|
<p style={{ color: "var(--color-text-muted)", fontSize: "12px" }}>{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!sheets) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: "100%",
|
|
gap: "12px",
|
|
}}
|
|
>
|
|
<div
|
|
className="animate-spin"
|
|
style={{
|
|
width: "20px",
|
|
height: "20px",
|
|
border: "2px solid var(--color-border)",
|
|
borderTopColor: "var(--color-accent)",
|
|
borderRadius: "50%",
|
|
}}
|
|
/>
|
|
<p style={{ color: "var(--color-text-muted)", fontSize: "12px" }}>
|
|
Loading spreadsheet...
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
height: "100%",
|
|
width: "100%",
|
|
background: "var(--color-bg)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* -- Header / Toolbar -------------------------------------------- */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
padding: compact ? "6px 10px" : "8px 16px",
|
|
borderBottom: "1px solid var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<SpreadsheetIcon size={16} />
|
|
<span
|
|
style={{
|
|
fontSize: compact ? "12px" : "13px",
|
|
fontWeight: 600,
|
|
color: "var(--color-text)",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{filename}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: "10px",
|
|
padding: "1px 6px",
|
|
borderRadius: "4px",
|
|
background: "var(--color-surface-hover)",
|
|
color: "var(--color-text-muted)",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{ext}
|
|
</span>
|
|
{dirty && (
|
|
<span
|
|
style={{
|
|
fontSize: "10px",
|
|
padding: "1px 6px",
|
|
borderRadius: "4px",
|
|
background: "var(--color-warning)",
|
|
color: "#fff",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
Unsaved
|
|
</span>
|
|
)}
|
|
{saveFlash && (
|
|
<span
|
|
style={{
|
|
fontSize: "10px",
|
|
padding: "1px 6px",
|
|
borderRadius: "4px",
|
|
background: "var(--color-success)",
|
|
color: "#fff",
|
|
fontWeight: 500,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
Saved
|
|
</span>
|
|
)}
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* Toolbar buttons */}
|
|
{!compact && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "2px" }}>
|
|
<ToolbarButton onClick={save} title="Save (⌘S)" disabled={!dirty || saving} accent={dirty}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
|
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/>
|
|
<path d="M7 3v4a1 1 0 0 0 1 1h7"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarSep />
|
|
<ToolbarButton onClick={undo} title="Undo (⌘Z)" disabled={undoStack.length === 0}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarButton onClick={redo} title="Redo (⌘⇧Z)" disabled={redoStack.length === 0}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarSep />
|
|
<ToolbarButton
|
|
onClick={() => activeCell && insertRow(activeCell.row)}
|
|
title="Insert row above"
|
|
disabled={!activeCell}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 5v14"/><path d="M5 12h14"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => activeCell && insertCol(activeCell.column)}
|
|
title="Insert column left"
|
|
disabled={!activeCell}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<path d="M12 8v8"/><path d="M8 12h8"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarSep />
|
|
<ToolbarButton onClick={() => setShowSearch((p) => !p)} title="Find (⌘F)">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
</div>
|
|
)}
|
|
|
|
{/* Compact save button */}
|
|
{compact && dirty && (
|
|
<ToolbarButton onClick={save} title="Save (⌘S)" disabled={saving} accent>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
|
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/>
|
|
<path d="M7 3v4a1 1 0 0 0 1 1h7"/>
|
|
</svg>
|
|
</ToolbarButton>
|
|
)}
|
|
</div>
|
|
|
|
{/* -- Formula bar ------------------------------------------------- */}
|
|
{!compact && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
padding: "4px 16px",
|
|
borderBottom: "1px solid var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: "11px",
|
|
fontWeight: 600,
|
|
color: "var(--color-text-muted)",
|
|
minWidth: "36px",
|
|
textAlign: "center",
|
|
padding: "2px 6px",
|
|
borderRadius: "4px",
|
|
background: "var(--color-surface-hover)",
|
|
fontFamily: "monospace",
|
|
}}
|
|
>
|
|
{activeCell ? cellRef(activeCell) : "\u00A0"}
|
|
</span>
|
|
<div
|
|
style={{
|
|
width: "1px",
|
|
height: "18px",
|
|
background: "var(--color-border)",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: "11px",
|
|
fontWeight: 500,
|
|
color: "var(--color-text-muted)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
fx
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={activeCellValue}
|
|
onChange={(e) => 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",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* -- Search bar -------------------------------------------------- */}
|
|
{showSearch && (
|
|
<SearchBar
|
|
data={currentData}
|
|
onNavigate={navigateToCell}
|
|
onClose={() => setShowSearch(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* -- Spreadsheet grid -------------------------------------------- */}
|
|
<div
|
|
style={{ flex: 1, overflow: "auto", position: "relative" }}
|
|
onContextMenu={handleContextMenu}
|
|
>
|
|
<Spreadsheet
|
|
ref={spreadsheetRef as React.Ref<unknown>}
|
|
data={currentData}
|
|
onChange={handleChange}
|
|
columnLabels={columnLabels}
|
|
onActivate={setActiveCell}
|
|
onSelect={setSelection}
|
|
darkMode={typeof document !== "undefined" && document.documentElement.classList.contains("dark")}
|
|
className="spreadsheet-editor-grid"
|
|
/>
|
|
</div>
|
|
|
|
{/* -- Context menu ------------------------------------------------ */}
|
|
<ContextMenu
|
|
state={contextMenu}
|
|
onClose={() => 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 && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 0,
|
|
borderTop: "1px solid var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
overflow: "auto",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{sheets.map((s, idx) => (
|
|
<button
|
|
key={s.name}
|
|
type="button"
|
|
onClick={() => setActiveSheet(idx)}
|
|
style={{
|
|
padding: compact ? "4px 10px" : "6px 16px",
|
|
fontSize: compact ? "10px" : "11px",
|
|
fontWeight: idx === activeSheet ? 600 : 400,
|
|
color:
|
|
idx === activeSheet
|
|
? "var(--color-text)"
|
|
: "var(--color-text-muted)",
|
|
background:
|
|
idx === activeSheet
|
|
? "var(--color-bg)"
|
|
: "transparent",
|
|
border: "none",
|
|
borderRight: "1px solid var(--color-border)",
|
|
cursor: "pointer",
|
|
whiteSpace: "nowrap",
|
|
transition: "background 0.1s",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{s.name}
|
|
{idx === activeSheet && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: "2px",
|
|
background: "var(--color-accent)",
|
|
borderRadius: "0 0 2px 2px",
|
|
}}
|
|
/>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* -- Status bar -------------------------------------------------- */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: compact ? "3px 10px" : "4px 16px",
|
|
borderTop: "1px solid var(--color-border)",
|
|
background: "var(--color-surface)",
|
|
fontSize: compact ? "10px" : "11px",
|
|
color: "var(--color-text-muted)",
|
|
flexShrink: 0,
|
|
gap: "12px",
|
|
}}
|
|
>
|
|
<span>
|
|
{currentData.length} row{currentData.length !== 1 ? "s" : ""}
|
|
{" \u00d7 "}
|
|
{colCount} col{colCount !== 1 ? "s" : ""}
|
|
{sheets.length > 1
|
|
? ` \u00b7 ${sheets.length} sheets`
|
|
: ""}
|
|
</span>
|
|
{stats && (
|
|
<span style={{ textAlign: "right" }}>
|
|
Count: {stats.count}
|
|
{stats.numericCount > 0 && (
|
|
<>
|
|
{" \u00b7 "}Sum: {stats.sum.toLocaleString()}
|
|
{" \u00b7 "}Avg: {stats.avg.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
|
</>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Icon
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SpreadsheetIcon({ size = 16 }: { size?: number }) {
|
|
return (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={{ color: "#22c55e", flexShrink: 0 }}
|
|
>
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
<path d="M8 13h2" />
|
|
<path d="M14 13h2" />
|
|
<path d="M8 17h2" />
|
|
<path d="M14 17h2" />
|
|
</svg>
|
|
);
|
|
}
|