/**
* Streaming Spreadsheet MCP App (Skybridge)
* CSS Grid for cell-based sparse tables.
*/
import "@/index.css";
import React, { useEffect, useMemo, useState, memo, useCallback, useRef } from "react";
import { mountWidget, useDisplayMode } from "skybridge/web";
import { useToolInfo } from "../helpers.js";
// ============ ICONS ============
const ExpandIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 1.5H12.5V5.5" />
<path d="M5.5 12.5H1.5V8.5" />
<path d="M12.5 1.5L8 6" />
<path d="M1.5 12.5L6 8" />
</svg>
);
// ============ TYPES ============
interface CellData {
cell: string;
value: string;
style?: string;
}
// ============ UTILITIES ============
function parseAddress(addr: string): { col: number; row: number } | null {
const match = addr.toUpperCase().match(/^([A-Z]+)(\d+)$/);
if (!match) return null;
let col = 0;
for (const char of match[1]) {
col = col * 26 + (char.charCodeAt(0) - 64);
}
return { col: col - 1, row: parseInt(match[2], 10) - 1 };
}
function colToLetter(col: number): string {
let result = "";
let c = col + 1;
while (c > 0) {
c--;
result = String.fromCharCode(65 + (c % 26)) + result;
c = Math.floor(c / 26);
}
return result;
}
function parseLine(line: string): CellData | null {
if (!line.trim()) return null;
const firstComma = line.indexOf(",");
if (firstComma === -1) return null;
const cell = line.slice(0, firstComma).trim().toUpperCase();
if (!/^[A-Z]+\d+$/.test(cell)) return null;
const rest = line.slice(firstComma + 1);
let value: string;
let styleStr: string;
if (rest.startsWith('"')) {
// Quoted value: find matching close quote
let i = 1;
while (i < rest.length) {
if (rest[i] === '"') {
if (rest[i + 1] === '"') { i += 2; continue; }
break;
}
i++;
}
value = rest.slice(1, i).replace(/""/g, '"');
styleStr = rest.slice(i + 1).replace(/^,/, "").trim();
} else {
// Simple value: up to next comma
const nextComma = rest.indexOf(",");
if (nextComma === -1) {
value = rest.trim();
styleStr = "";
} else {
value = rest.slice(0, nextComma).trim();
styleStr = rest.slice(nextComma + 1).trim();
}
}
return { cell, value, style: styleStr || undefined };
}
function parseCsvLines(lines: string[]): CellData[] {
const cells: CellData[] = [];
for (const line of lines) {
const parsed = parseLine(line);
if (parsed) cells.push(parsed);
}
return cells;
}
function parseCsv(csv: string, isStreaming: boolean): { displayCells: CellData[]; allCells: CellData[] } {
if (!csv || typeof csv !== "string") return { displayCells: [], allCells: [] };
const lines = csv.trim().split("\n");
const safeLines = isStreaming && lines.length > 1 ? lines.slice(0, -1) : lines;
return {
displayCells: parseCsvLines(safeLines),
allCells: parseCsvLines(lines),
};
}
let measureCtx: CanvasRenderingContext2D | null = null;
function measureTextWidth(text: string): number {
if (!measureCtx) {
const canvas = document.createElement("canvas");
measureCtx = canvas.getContext("2d");
if (measureCtx) {
measureCtx.font = "13px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
}
}
return measureCtx?.measureText(text).width ?? text.length * 7;
}
function parseCssStyle(css?: string): React.CSSProperties {
if (!css) return {};
const style: Record<string, string> = {};
css.split(";").forEach(rule => {
const [prop, val] = rule.split(":").map(s => s.trim());
if (prop && val) {
const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
style[camelProp] = val;
}
});
return style;
}
function linkifyValue(value: string): React.ReactNode {
const pattern = /(https?:\/\/[^\s]+)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(value)) !== null) {
if (match.index > lastIndex) {
parts.push(value.slice(lastIndex, match.index));
}
const text = match[0];
const href = match[2] ? `mailto:${text}` : text;
parts.push(
<a key={match.index} href={href} className="cell-link" target="_blank" rel="noopener">
{text}
</a>
);
lastIndex = pattern.lastIndex;
}
if (lastIndex < value.length) {
parts.push(value.slice(lastIndex));
}
return parts.length > 0 ? parts : value;
}
// ============ CELL COMPONENT ============
const Cell = memo(function Cell({ value, style, canOverflow }: { value: string; style?: string; canOverflow?: boolean }) {
const className = `cell data-cell${canOverflow ? " can-overflow" : ""}`;
return (
<div className={className} style={parseCssStyle(style)}>
{value && <span key={value} className="cell-value">{linkifyValue(value)}</span>}
</div>
);
});
// ============ MAIN APP ============
function SpreadsheetApp() {
const { input, isPending } = useToolInfo<"spreadsheet">();
const [displayMode, setDisplayMode] = useDisplayMode();
const rawCsv = input?.cells ?? "";
const isStreaming = isPending;
const toggleFullscreen = useCallback(() => {
setDisplayMode(displayMode === "fullscreen" ? "inline" : "fullscreen");
}, [displayMode, setDisplayMode]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && displayMode === "fullscreen") toggleFullscreen();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [displayMode, toggleFullscreen]);
return (
<SpreadsheetGrid
rawCsv={rawCsv}
isStreaming={isStreaming}
displayMode={displayMode}
onToggleFullscreen={toggleFullscreen}
/>
);
}
// ============ GRID COMPONENT ============
interface GridProps {
rawCsv: string;
isStreaming: boolean;
displayMode: string;
onToggleFullscreen: () => void;
}
function SpreadsheetGrid({ rawCsv, isStreaming, displayMode, onToggleFullscreen }: GridProps) {
const [manualWidths, setManualWidths] = useState<Record<number, number>>({});
const [isResizing, setIsResizing] = useState(false);
const resizingCol = useRef<{ col: number; startX: number; startWidth: number } | null>(null);
const mainRef = useRef<HTMLElement | null>(null);
const [extraRows, setExtraRows] = useState(0);
const handleMouseDown = useCallback((col: number, startWidth: number, e: React.MouseEvent) => {
e.preventDefault();
resizingCol.current = { col, startX: e.clientX, startWidth };
setIsResizing(true);
const handleMouseMove = (e: MouseEvent) => {
const ref = resizingCol.current;
if (!ref) return;
const diff = e.clientX - ref.startX;
const newWidth = Math.max(50, Math.min(300, ref.startWidth + diff));
setManualWidths(prev => ({ ...prev, [ref.col]: newWidth }));
};
const handleMouseUp = () => {
resizingCol.current = null;
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
const { displayCells, allCells } = useMemo(() => parseCsv(rawCsv, isStreaming), [rawCsv, isStreaming]);
const isFullscreen = displayMode === "fullscreen";
const { maxRow, maxCol } = useMemo(() => {
let maxR = 0, maxC = 0;
for (const c of allCells) {
const addr = parseAddress(c.cell);
if (addr) {
maxR = Math.max(maxR, addr.row + 1);
maxC = Math.max(maxC, addr.col + 1);
}
}
if (isFullscreen) {
const viewportRows = Math.ceil(window.innerHeight / 22) + 2;
const viewportCols = Math.ceil(window.innerWidth / 64) + 2;
return {
maxRow: Math.max(maxR + 10, viewportRows) + extraRows,
maxCol: Math.max(maxC + 5, viewportCols),
};
}
return { maxRow: maxR + 3, maxCol: maxC + 10 };
}, [allCells, isFullscreen, extraRows]);
// Infinite scroll in fullscreen
useEffect(() => {
if (!isFullscreen) { setExtraRows(0); return; }
const el = mainRef.current;
if (!el) return;
const handleScroll = () => {
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
setExtraRows(prev => prev + 30);
}
};
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [isFullscreen]);
const gridRef = useRef<HTMLDivElement | null>(null);
const cellMap = useMemo(() => {
const map = new Map<string, CellData>();
for (const c of displayCells) map.set(c.cell, c);
return map;
}, [displayCells]);
const colWidths = useMemo(() => {
const widths: number[] = Array(maxCol).fill(64);
const MIN_WIDTH = 64;
const MAX_WIDTH = 180;
const PADDING = 18;
let lastDataCol = -1;
for (const c of allCells) {
const addr = parseAddress(c.cell);
if (addr) lastDataCol = Math.max(lastDataCol, addr.col);
}
for (const c of allCells) {
const addr = parseAddress(c.cell);
if (!addr) continue;
const maxW = addr.col === lastDataCol ? Infinity : MAX_WIDTH;
const contentWidth = Math.ceil(measureTextWidth(c.value)) + PADDING;
widths[addr.col] = Math.max(widths[addr.col], Math.min(contentWidth, maxW));
}
return widths.map((w, i) => manualWidths[i] ?? Math.max(w, MIN_WIDTH));
}, [allCells, maxCol, manualWidths]);
const gridCells = useMemo(() => {
const result: React.ReactNode[] = [];
result.push(<div key="corner" className="cell corner-cell" />);
for (let col = 0; col < maxCol; col++) {
result.push(
<div key={`h-${col}`} className="cell col-header">
{colToLetter(col)}
<div className="resize-handle" onMouseDown={(e) => handleMouseDown(col, colWidths[col], e)} />
</div>
);
}
for (let row = 0; row < maxRow; row++) {
result.push(
<div key={`r-${row}`} className="cell row-header">{row + 1}</div>
);
for (let col = 0; col < maxCol; col++) {
const addr = `${colToLetter(col)}${row + 1}`;
const cellData = cellMap.get(addr);
const value = cellData?.value ?? "";
let canOverflow = false;
if (value) {
canOverflow = true;
for (let c = col + 1; c < maxCol; c++) {
const rightAddr = `${colToLetter(c)}${row + 1}`;
if (cellMap.get(rightAddr)?.value) { canOverflow = false; break; }
}
}
result.push(<Cell key={addr} value={value} style={cellData?.style} canOverflow={canOverflow} />);
}
}
return result;
}, [maxRow, maxCol, cellMap, handleMouseDown, colWidths]);
const colTemplate = isFullscreen
? `42px ${colWidths.map((w, i) => i === colWidths.length - 1 ? `minmax(${w}px, 1fr)` : `${w}px`).join(" ")}`
: `42px ${colWidths.map(w => `${w}px`).join(" ")}`;
const mainClass = `main${isFullscreen ? " fullscreen" : ""}`;
if (maxRow === 0 && maxCol === 0) {
return (
<main ref={mainRef} className={mainClass}>
<div className="loading">Loading spreadsheet...</div>
</main>
);
}
return (
<main ref={mainRef} className={mainClass}>
{!isFullscreen && (
<div className="toolbar">
<button className="fullscreen-btn" onClick={onToggleFullscreen} title="Enter fullscreen">
<ExpandIcon />
</button>
</div>
)}
<div
ref={gridRef}
className={`spreadsheet-container${isResizing ? " resizing" : ""}`}
style={{
gridTemplateColumns: colTemplate,
gridTemplateRows: `22px repeat(${maxRow}, 22px)`,
}}
>
{gridCells}
</div>
</main>
);
}
export default SpreadsheetApp;
mountWidget(<SpreadsheetApp />);