botui/ui/suite/sheet/sheet.js
Rodrigo Rodriguez (Pragmatismo) 10299814b2 M365-like UI for Sheet, Docs, and Slides editors
- Clean editor experience without document list sidebars
- Theme-aware using --sentient-* CSS variables
- Modern toolbar design like Microsoft 365
- Sheet: Formula bar, spreadsheet grid, sheet tabs, zoom controls
- Docs: A4 page centered with shadow, formatting toolbar
- Slides: Thumbnails on left, canvas in center, properties panel
- AI chat panel (collapsible) for all editors
- Responsive design with mobile support
- Print styles for all editors
- Dark/light mode support via theme system
2026-01-11 09:56:44 -03:00

1706 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =============================================================================
GB SHEET - Modern Spreadsheet with AI Chat
============================================================================= */
(function () {
"use strict";
const CONFIG = {
COLS: 26,
ROWS: 100,
COL_WIDTH: 100,
ROW_HEIGHT: 24,
MAX_HISTORY: 50,
AUTOSAVE_DELAY: 3000,
WS_RECONNECT_DELAY: 3000,
};
const state = {
sheetId: null,
sheetName: "Untitled Spreadsheet",
worksheets: [{ name: "Sheet1", data: {} }],
activeWorksheet: 0,
selection: {
start: { row: 0, col: 0 },
end: { row: 0, col: 0 },
},
activeCell: { row: 0, col: 0 },
clipboard: null,
clipboardMode: null,
history: [],
historyIndex: -1,
zoom: 100,
collaborators: [],
ws: null,
isEditing: false,
isSelecting: false,
isDirty: false,
autoSaveTimer: null,
chatPanelOpen: true,
};
const elements = {};
function init() {
cacheElements();
renderGrid();
bindEvents();
loadFromUrlParams();
connectWebSocket();
connectChatWebSocket();
selectCell(0, 0);
updateCellAddress();
}
function cacheElements() {
elements.app = document.getElementById("sheet-app");
elements.sheetName = document.getElementById("sheetName");
elements.columnHeaders = document.getElementById("columnHeaders");
elements.rowHeaders = document.getElementById("rowHeaders");
elements.cells = document.getElementById("cells");
elements.cellsContainer = document.getElementById("cellsContainer");
elements.formulaInput = document.getElementById("formulaInput");
elements.cellAddress = document.getElementById("cellAddress");
elements.worksheetTabs = document.getElementById("worksheetTabs");
elements.collaborators = document.getElementById("collaborators");
elements.contextMenu = document.getElementById("contextMenu");
elements.shareModal = document.getElementById("shareModal");
elements.chartModal = document.getElementById("chartModal");
elements.cursorIndicators = document.getElementById("cursorIndicators");
elements.selectionBox = document.getElementById("selectionBox");
elements.selectionInfo = document.getElementById("selectionInfo");
elements.calculationResult = document.getElementById("calculationResult");
elements.saveStatus = document.getElementById("saveStatus");
elements.zoomLevel = document.getElementById("zoomLevel");
elements.chatPanel = document.getElementById("chatPanel");
elements.chatMessages = document.getElementById("chatMessages");
elements.chatInput = document.getElementById("chatInput");
elements.chatForm = document.getElementById("chatForm");
}
function renderGrid() {
elements.columnHeaders.innerHTML = "";
for (let col = 0; col < CONFIG.COLS; col++) {
const header = document.createElement("div");
header.className = "column-header";
header.textContent = getColName(col);
header.dataset.col = col;
elements.columnHeaders.appendChild(header);
}
elements.rowHeaders.innerHTML = "";
for (let row = 0; row < CONFIG.ROWS; row++) {
const header = document.createElement("div");
header.className = "row-header";
header.textContent = row + 1;
header.dataset.row = row;
elements.rowHeaders.appendChild(header);
}
elements.cells.innerHTML = "";
elements.cells.style.gridTemplateColumns = `repeat(${CONFIG.COLS}, ${CONFIG.COL_WIDTH}px)`;
for (let row = 0; row < CONFIG.ROWS; row++) {
for (let col = 0; col < CONFIG.COLS; col++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.dataset.row = row;
cell.dataset.col = col;
elements.cells.appendChild(cell);
}
}
renderAllCells();
}
function renderAllCells() {
const ws = state.worksheets[state.activeWorksheet];
if (!ws) return;
const cells = elements.cells.querySelectorAll(".cell");
cells.forEach((cell) => {
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
renderCell(row, col);
});
}
function renderCell(row, col) {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (!cell) return;
const data = getCellData(row, col);
let displayValue = "";
if (data) {
if (data.formula) {
displayValue = evaluateFormula(data.formula, row, col);
} else if (data.value !== undefined) {
displayValue = data.value;
}
applyFormatToCell(cell, data.style);
} else {
cell.style.cssText = "";
}
cell.textContent = displayValue;
}
function applyFormatToCell(cell, style) {
if (!style) return;
if (style.fontFamily) cell.style.fontFamily = style.fontFamily;
if (style.fontSize) cell.style.fontSize = style.fontSize + "px";
if (style.fontWeight) cell.style.fontWeight = style.fontWeight;
if (style.fontStyle) cell.style.fontStyle = style.fontStyle;
if (style.textDecoration) cell.style.textDecoration = style.textDecoration;
if (style.color) cell.style.color = style.color;
if (style.background) cell.style.backgroundColor = style.background;
if (style.textAlign) cell.style.textAlign = style.textAlign;
}
function getColName(col) {
let name = "";
col++;
while (col > 0) {
col--;
name = String.fromCharCode(65 + (col % 26)) + name;
col = Math.floor(col / 26);
}
return name;
}
function parseColName(name) {
let col = 0;
for (let i = 0; i < name.length; i++) {
col = col * 26 + (name.charCodeAt(i) - 64);
}
return col - 1;
}
function getCellRef(row, col) {
return getColName(col) + (row + 1);
}
function parseCellRef(ref) {
const match = ref.match(/^([A-Z]+)(\d+)$/i);
if (!match) return null;
return {
row: parseInt(match[2]) - 1,
col: parseColName(match[1].toUpperCase()),
};
}
function bindEvents() {
elements.cells.addEventListener("mousedown", handleCellMouseDown);
elements.cells.addEventListener("dblclick", handleCellDoubleClick);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("click", handleDocumentClick);
document.addEventListener("contextmenu", handleContextMenu);
elements.columnHeaders.addEventListener("click", handleColumnHeaderClick);
elements.rowHeaders.addEventListener("click", handleRowHeaderClick);
elements.formulaInput.addEventListener("keydown", handleFormulaKey);
elements.formulaInput.addEventListener("input", updateFormulaPreview);
document.getElementById("undoBtn")?.addEventListener("click", undo);
document.getElementById("redoBtn")?.addEventListener("click", redo);
document
.getElementById("boldBtn")
?.addEventListener("click", () => formatCells("bold"));
document
.getElementById("italicBtn")
?.addEventListener("click", () => formatCells("italic"));
document
.getElementById("underlineBtn")
?.addEventListener("click", () => formatCells("underline"));
document
.getElementById("strikeBtn")
?.addEventListener("click", () => formatCells("strikethrough"));
document
.getElementById("alignLeftBtn")
?.addEventListener("click", () => formatCells("alignLeft"));
document
.getElementById("alignCenterBtn")
?.addEventListener("click", () => formatCells("alignCenter"));
document
.getElementById("alignRightBtn")
?.addEventListener("click", () => formatCells("alignRight"));
document
.getElementById("mergeCellsBtn")
?.addEventListener("click", mergeCells);
document
.getElementById("formatCurrencyBtn")
?.addEventListener("click", () => formatCells("currency"));
document
.getElementById("formatPercentBtn")
?.addEventListener("click", () => formatCells("percent"));
document
.getElementById("textColorInput")
?.addEventListener("input", (e) => {
formatCells("color", e.target.value);
document.getElementById("textColorIndicator").style.background =
e.target.value;
});
document.getElementById("bgColorInput")?.addEventListener("input", (e) => {
formatCells("backgroundColor", e.target.value);
document.getElementById("bgColorIndicator").style.background =
e.target.value;
});
document
.getElementById("fontFamily")
?.addEventListener("change", (e) =>
formatCells("fontFamily", e.target.value),
);
document
.getElementById("fontSize")
?.addEventListener("change", (e) =>
formatCells("fontSize", e.target.value),
);
document
.getElementById("shareBtn")
?.addEventListener("click", showShareModal);
document
.getElementById("closeShareModal")
?.addEventListener("click", () => hideModal("shareModal"));
document
.getElementById("closeChartModal")
?.addEventListener("click", () => hideModal("chartModal"));
document
.getElementById("copyLinkBtn")
?.addEventListener("click", copyShareLink);
document
.getElementById("addSheetBtn")
?.addEventListener("click", addWorksheet);
document.getElementById("zoomInBtn")?.addEventListener("click", zoomIn);
document.getElementById("zoomOutBtn")?.addEventListener("click", zoomOut);
document
.getElementById("chatToggle")
?.addEventListener("click", toggleChatPanel);
document
.getElementById("chatClose")
?.addEventListener("click", toggleChatPanel);
elements.chatForm?.addEventListener("submit", handleChatSubmit);
document.querySelectorAll(".suggestion-btn").forEach((btn) => {
btn.addEventListener("click", () =>
handleSuggestionClick(btn.dataset.action),
);
});
document.querySelectorAll(".context-item").forEach((item) => {
item.addEventListener("click", () =>
handleContextAction(item.dataset.action),
);
});
elements.sheetName?.addEventListener("change", (e) => {
state.sheetName = e.target.value;
scheduleAutoSave();
});
window.addEventListener("beforeunload", handleBeforeUnload);
}
function handleCellMouseDown(e) {
const cell = e.target.closest(".cell");
if (!cell) return;
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
if (state.isEditing) {
finishEditing();
}
if (e.shiftKey) {
extendSelection(row, col);
} else {
selectCell(row, col);
state.isSelecting = true;
}
}
function handleMouseMove(e) {
if (!state.isSelecting) return;
const cell = document
.elementFromPoint(e.clientX, e.clientY)
?.closest(".cell");
if (cell) {
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
extendSelection(row, col);
}
}
function handleMouseUp() {
state.isSelecting = false;
}
function handleCellDoubleClick(e) {
const cell = e.target.closest(".cell");
if (!cell) return;
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
startEditing(row, col);
}
function selectCell(row, col) {
clearSelection();
state.activeCell = { row, col };
state.selection = {
start: { row, col },
end: { row, col },
};
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (cell) {
cell.classList.add("selected");
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
}
updateCellAddress();
updateFormulaBar();
updateSelectionInfo();
}
function extendSelection(row, col) {
clearSelection();
const start = state.activeCell;
state.selection = {
start: {
row: Math.min(start.row, row),
col: Math.min(start.col, col),
},
end: {
row: Math.max(start.row, row),
col: Math.max(start.col, col),
},
};
for (let r = state.selection.start.row; r <= state.selection.end.row; r++) {
for (
let c = state.selection.start.col;
c <= state.selection.end.col;
c++
) {
const cell = elements.cells.querySelector(
`[data-row="${r}"][data-col="${c}"]`,
);
if (cell) {
if (r === state.activeCell.row && c === state.activeCell.col) {
cell.classList.add("selected");
} else {
cell.classList.add("in-range");
}
}
}
}
updateSelectionInfo();
updateCalculationResult();
}
function clearSelection() {
elements.cells
.querySelectorAll(".cell.selected, .cell.in-range")
.forEach((cell) => {
cell.classList.remove("selected", "in-range");
});
}
function handleColumnHeaderClick(e) {
const header = e.target.closest(".column-header");
if (!header) return;
const col = parseInt(header.dataset.col);
clearSelection();
state.activeCell = { row: 0, col };
state.selection = {
start: { row: 0, col },
end: { row: CONFIG.ROWS - 1, col },
};
for (let row = 0; row < CONFIG.ROWS; row++) {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (cell) cell.classList.add("in-range");
}
header.classList.add("selected");
updateSelectionInfo();
}
function handleRowHeaderClick(e) {
const header = e.target.closest(".row-header");
if (!header) return;
const row = parseInt(header.dataset.row);
clearSelection();
state.activeCell = { row, col: 0 };
state.selection = {
start: { row, col: 0 },
end: { row, col: CONFIG.COLS - 1 },
};
for (let col = 0; col < CONFIG.COLS; col++) {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (cell) cell.classList.add("in-range");
}
header.classList.add("selected");
updateSelectionInfo();
}
function startEditing(row, col) {
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
if (!cell) return;
state.isEditing = true;
const data = getCellData(row, col);
const input = document.createElement("input");
input.type = "text";
input.className = "cell-input";
input.value = data?.formula || data?.value || "";
cell.textContent = "";
cell.classList.add("editing");
cell.appendChild(input);
input.focus();
input.select();
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
finishEditing(true);
navigateCell(1, 0);
} else if (e.key === "Tab") {
e.preventDefault();
finishEditing(true);
navigateCell(0, e.shiftKey ? -1 : 1);
} else if (e.key === "Escape") {
cancelEditing();
}
});
input.addEventListener("blur", () => {
if (state.isEditing) finishEditing(true);
});
}
function finishEditing(save = true) {
if (!state.isEditing) return;
const { row, col } = state.activeCell;
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
const input = cell?.querySelector(".cell-input");
if (input && save) {
const value = input.value.trim();
setCellValue(row, col, value);
}
state.isEditing = false;
cell?.classList.remove("editing");
renderCell(row, col);
updateFormulaBar();
}
function cancelEditing() {
state.isEditing = false;
const { row, col } = state.activeCell;
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
cell?.classList.remove("editing");
renderCell(row, col);
}
function setCellValue(row, col, value) {
const ws = state.worksheets[state.activeWorksheet];
const key = `${row},${col}`;
saveToHistory();
if (!value) {
delete ws.data[key];
} else if (value.startsWith("=")) {
ws.data[key] = { formula: value };
} else {
ws.data[key] = { value };
}
state.isDirty = true;
scheduleAutoSave();
broadcastChange("cell", { row, col, value });
}
function getCellData(row, col) {
const ws = state.worksheets[state.activeWorksheet];
return ws?.data[`${row},${col}`];
}
function getCellValue(row, col) {
const data = getCellData(row, col);
if (!data) return "";
if (data.formula) return evaluateFormula(data.formula, row, col);
return data.value || "";
}
function evaluateFormula(formula, sourceRow, sourceCol) {
if (!formula.startsWith("=")) return formula;
try {
let expr = formula.substring(1).toUpperCase();
expr = expr.replace(/([A-Z]+)(\d+)/g, (match, col, row) => {
const r = parseInt(row) - 1;
const c = parseColName(col);
const val = getCellValue(r, c);
const num = parseFloat(val);
return isNaN(num) ? `"${val}"` : num;
});
if (expr.startsWith("SUM(")) {
return evaluateSum(expr);
} else if (expr.startsWith("AVERAGE(")) {
return evaluateAverage(expr);
} else if (expr.startsWith("COUNT(")) {
return evaluateCount(expr);
} else if (expr.startsWith("MAX(")) {
return evaluateMax(expr);
} else if (expr.startsWith("MIN(")) {
return evaluateMin(expr);
} else if (expr.startsWith("IF(")) {
return evaluateIf(expr);
}
const result = new Function("return " + expr)();
return typeof result === "number"
? Math.round(result * 1000000) / 1000000
: result;
} catch (e) {
return "#ERROR";
}
}
function evaluateSum(expr) {
const match = expr.match(/SUM\(([^)]+)\)/i);
if (!match) return "#ERROR";
const values = parseRange(match[1]);
return values.reduce((a, b) => a + b, 0);
}
function evaluateAverage(expr) {
const match = expr.match(/AVERAGE\(([^)]+)\)/i);
if (!match) return "#ERROR";
const values = parseRange(match[1]);
return values.length
? values.reduce((a, b) => a + b, 0) / values.length
: 0;
}
function evaluateCount(expr) {
const match = expr.match(/COUNT\(([^)]+)\)/i);
if (!match) return "#ERROR";
const values = parseRange(match[1]);
return values.length;
}
function evaluateMax(expr) {
const match = expr.match(/MAX\(([^)]+)\)/i);
if (!match) return "#ERROR";
const values = parseRange(match[1]);
return values.length ? Math.max(...values) : 0;
}
function evaluateMin(expr) {
const match = expr.match(/MIN\(([^)]+)\)/i);
if (!match) return "#ERROR";
const values = parseRange(match[1]);
return values.length ? Math.min(...values) : 0;
}
function evaluateIf(expr) {
const match = expr.match(/IF\(([^,]+),([^,]+),([^)]+)\)/i);
if (!match) return "#ERROR";
try {
const condition = new Function("return " + match[1])();
return condition
? new Function("return " + match[2])()
: new Function("return " + match[3])();
} catch {
return "#ERROR";
}
}
function parseRange(rangeStr) {
const values = [];
const parts = rangeStr.split(":");
if (parts.length === 2) {
const start = parseCellRef(parts[0].trim());
const end = parseCellRef(parts[1].trim());
if (start && end) {
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
const val = parseFloat(getCellValue(r, c));
if (!isNaN(val)) values.push(val);
}
}
}
} else {
const ref = parseCellRef(parts[0].trim());
if (ref) {
const val = parseFloat(getCellValue(ref.row, ref.col));
if (!isNaN(val)) values.push(val);
}
}
return values;
}
function handleKeyDown(e) {
if (e.target.closest(".chat-input, .modal input, .sheet-name-input"))
return;
const { row, col } = state.activeCell;
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case "c":
copySelection();
return;
case "x":
cutSelection();
return;
case "v":
pasteSelection();
return;
case "z":
e.shiftKey ? redo() : undo();
e.preventDefault();
return;
case "y":
redo();
e.preventDefault();
return;
case "b":
formatCells("bold");
e.preventDefault();
return;
case "i":
formatCells("italic");
e.preventDefault();
return;
case "u":
formatCells("underline");
e.preventDefault();
return;
case "a":
selectAll();
e.preventDefault();
return;
}
}
if (state.isEditing) return;
switch (e.key) {
case "ArrowUp":
navigateCell(-1, 0);
e.preventDefault();
break;
case "ArrowDown":
navigateCell(1, 0);
e.preventDefault();
break;
case "ArrowLeft":
navigateCell(0, -1);
e.preventDefault();
break;
case "ArrowRight":
navigateCell(0, 1);
e.preventDefault();
break;
case "Tab":
navigateCell(0, e.shiftKey ? -1 : 1);
e.preventDefault();
break;
case "Enter":
if (e.shiftKey) {
navigateCell(-1, 0);
} else {
startEditing(row, col);
}
e.preventDefault();
break;
case "Delete":
case "Backspace":
clearCells();
e.preventDefault();
break;
case "F2":
startEditing(row, col);
e.preventDefault();
break;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
startEditing(row, col);
const cell = elements.cells.querySelector(
`[data-row="${row}"][data-col="${col}"]`,
);
const input = cell?.querySelector(".cell-input");
if (input) input.value = e.key;
}
}
}
function navigateCell(dRow, dCol) {
const newRow = Math.max(
0,
Math.min(CONFIG.ROWS - 1, state.activeCell.row + dRow),
);
const newCol = Math.max(
0,
Math.min(CONFIG.COLS - 1, state.activeCell.col + dCol),
);
selectCell(newRow, newCol);
}
function selectAll() {
clearSelection();
state.selection = {
start: { row: 0, col: 0 },
end: { row: CONFIG.ROWS - 1, col: CONFIG.COLS - 1 },
};
elements.cells.querySelectorAll(".cell").forEach((cell) => {
cell.classList.add("in-range");
});
const activeCell = elements.cells.querySelector(
`[data-row="${state.activeCell.row}"][data-col="${state.activeCell.col}"]`,
);
if (activeCell) {
activeCell.classList.remove("in-range");
activeCell.classList.add("selected");
}
updateSelectionInfo();
}
function handleFormulaKey(e) {
if (e.key === "Enter") {
e.preventDefault();
const value = elements.formulaInput.value;
const { row, col } = state.activeCell;
setCellValue(row, col, value);
renderCell(row, col);
elements.formulaInput.blur();
} else if (e.key === "Escape") {
updateFormulaBar();
elements.formulaInput.blur();
}
}
function updateFormulaPreview() {
const value = elements.formulaInput.value;
if (value.startsWith("=")) {
const result = evaluateFormula(
value,
state.activeCell.row,
state.activeCell.col,
);
elements.calculationResult.textContent = `= ${result}`;
} else {
elements.calculationResult.textContent = "";
}
}
function updateCellAddress() {
const ref = getCellRef(state.activeCell.row, state.activeCell.col);
elements.cellAddress.textContent = ref;
}
function updateFormulaBar() {
const data = getCellData(state.activeCell.row, state.activeCell.col);
elements.formulaInput.value = data?.formula || data?.value || "";
}
function updateSelectionInfo() {
const { start, end } = state.selection;
const rows = end.row - start.row + 1;
const cols = end.col - start.col + 1;
const count = rows * cols;
if (count === 1) {
elements.selectionInfo.textContent = "Ready";
} else {
elements.selectionInfo.textContent = `${rows}R × ${cols}C = ${count} cells`;
}
}
function updateCalculationResult() {
const { start, end } = state.selection;
const values = [];
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
const val = parseFloat(getCellValue(r, c));
if (!isNaN(val)) values.push(val);
}
}
if (values.length > 1) {
const sum = values.reduce((a, b) => a + b, 0);
const avg = sum / values.length;
elements.calculationResult.textContent = `Sum: ${sum.toFixed(2)} | Avg: ${avg.toFixed(2)} | Count: ${values.length}`;
} else {
elements.calculationResult.textContent = "";
}
}
function copySelection() {
state.clipboard = getSelectionData();
state.clipboardMode = "copy";
showCopyBox();
}
function cutSelection() {
state.clipboard = getSelectionData();
state.clipboardMode = "cut";
showCopyBox();
}
function pasteSelection() {
if (!state.clipboard) return;
saveToHistory();
const { row, col } = state.activeCell;
const ws = state.worksheets[state.activeWorksheet];
state.clipboard.forEach((rowData, rOffset) => {
rowData.forEach((cellData, cOffset) => {
const targetRow = row + rOffset;
const targetCol = col + cOffset;
const key = `${targetRow},${targetCol}`;
if (cellData) {
ws.data[key] = { ...cellData };
}
renderCell(targetRow, targetCol);
});
});
if (state.clipboardMode === "cut") {
clearSourceCells();
state.clipboardMode = null;
}
hideCopyBox();
state.isDirty = true;
scheduleAutoSave();
}
function getSelectionData() {
const { start, end } = state.selection;
const data = [];
for (let r = start.row; r <= end.row; r++) {
const rowData = [];
for (let c = start.col; c <= end.col; c++) {
rowData.push(getCellData(r, c) || null);
}
data.push(rowData);
}
return data;
}
function clearSourceCells() {
const { start, end } = state.selection;
const ws = state.worksheets[state.activeWorksheet];
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
delete ws.data[`${r},${c}`];
renderCell(r, c);
}
}
}
function clearCells() {
saveToHistory();
const { start, end } = state.selection;
const ws = state.worksheets[state.activeWorksheet];
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
delete ws.data[`${r},${c}`];
renderCell(r, c);
}
}
state.isDirty = true;
scheduleAutoSave();
}
function showCopyBox() {
const copyBox = document.getElementById("copyBox");
if (copyBox) copyBox.classList.remove("hidden");
}
function hideCopyBox() {
const copyBox = document.getElementById("copyBox");
if (copyBox) copyBox.classList.add("hidden");
}
function formatCells(format, value) {
saveToHistory();
const { start, end } = state.selection;
const ws = state.worksheets[state.activeWorksheet];
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
const key = `${r},${c}`;
if (!ws.data[key]) ws.data[key] = { value: "" };
if (!ws.data[key].style) ws.data[key].style = {};
const style = ws.data[key].style;
switch (format) {
case "bold":
style.fontWeight = style.fontWeight === "bold" ? "normal" : "bold";
break;
case "italic":
style.fontStyle =
style.fontStyle === "italic" ? "normal" : "italic";
break;
case "underline":
style.textDecoration =
style.textDecoration === "underline" ? "none" : "underline";
break;
case "strikethrough":
style.textDecoration =
style.textDecoration === "line-through" ? "none" : "line-through";
break;
case "alignLeft":
style.textAlign = "left";
break;
case "alignCenter":
style.textAlign = "center";
break;
case "alignRight":
style.textAlign = "right";
break;
case "fontFamily":
style.fontFamily = value;
break;
case "fontSize":
style.fontSize = value;
break;
case "color":
style.color = value;
break;
case "backgroundColor":
style.background = value;
break;
case "currency":
if (ws.data[key].value) {
const num = parseFloat(ws.data[key].value);
if (!isNaN(num)) ws.data[key].value = "$" + num.toFixed(2);
}
break;
case "percent":
if (ws.data[key].value) {
const num = parseFloat(ws.data[key].value);
if (!isNaN(num))
ws.data[key].value = (num * 100).toFixed(0) + "%";
}
break;
}
renderCell(r, c);
}
}
state.isDirty = true;
scheduleAutoSave();
}
function mergeCells() {
addChatMessage("assistant", "Merge cells feature coming soon!");
}
function saveToHistory() {
const snapshot = JSON.stringify(state.worksheets);
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(snapshot);
if (state.history.length > CONFIG.MAX_HISTORY) state.history.shift();
state.historyIndex = state.history.length - 1;
}
function undo() {
if (state.historyIndex > 0) {
state.historyIndex--;
state.worksheets = JSON.parse(state.history[state.historyIndex]);
renderAllCells();
state.isDirty = true;
}
}
function redo() {
if (state.historyIndex < state.history.length - 1) {
state.historyIndex++;
state.worksheets = JSON.parse(state.history[state.historyIndex]);
renderAllCells();
state.isDirty = true;
}
}
function handleContextMenu(e) {
const cell = e.target.closest(".cell");
if (!cell) return;
e.preventDefault();
elements.contextMenu.style.left = e.clientX + "px";
elements.contextMenu.style.top = e.clientY + "px";
elements.contextMenu.classList.remove("hidden");
}
function handleDocumentClick(e) {
if (!e.target.closest(".context-menu")) {
elements.contextMenu?.classList.add("hidden");
}
}
function handleContextAction(action) {
elements.contextMenu.classList.add("hidden");
switch (action) {
case "cut":
cutSelection();
break;
case "copy":
copySelection();
break;
case "paste":
pasteSelection();
break;
case "insertRowAbove":
insertRow(state.activeCell.row);
break;
case "insertRowBelow":
insertRow(state.activeCell.row + 1);
break;
case "insertColLeft":
insertColumn(state.activeCell.col);
break;
case "insertColRight":
insertColumn(state.activeCell.col + 1);
break;
case "deleteRow":
deleteRow(state.activeCell.row);
break;
case "deleteCol":
deleteColumn(state.activeCell.col);
break;
case "clearContents":
clearCells();
break;
case "clearFormatting":
clearFormatting();
break;
}
}
function insertRow(atRow) {
saveToHistory();
const ws = state.worksheets[state.activeWorksheet];
const newData = {};
for (const key in ws.data) {
const [r, c] = key.split(",").map(Number);
if (r >= atRow) {
newData[`${r + 1},${c}`] = ws.data[key];
} else {
newData[key] = ws.data[key];
}
}
ws.data = newData;
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
}
function insertColumn(atCol) {
saveToHistory();
const ws = state.worksheets[state.activeWorksheet];
const newData = {};
for (const key in ws.data) {
const [r, c] = key.split(",").map(Number);
if (c >= atCol) {
newData[`${r},${c + 1}`] = ws.data[key];
} else {
newData[key] = ws.data[key];
}
}
ws.data = newData;
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
}
function deleteRow(row) {
saveToHistory();
const ws = state.worksheets[state.activeWorksheet];
const newData = {};
for (const key in ws.data) {
const [r, c] = key.split(",").map(Number);
if (r < row) {
newData[key] = ws.data[key];
} else if (r > row) {
newData[`${r - 1},${c}`] = ws.data[key];
}
}
ws.data = newData;
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
}
function deleteColumn(col) {
saveToHistory();
const ws = state.worksheets[state.activeWorksheet];
const newData = {};
for (const key in ws.data) {
const [r, c] = key.split(",").map(Number);
if (c < col) {
newData[key] = ws.data[key];
} else if (c > col) {
newData[`${r},${c - 1}`] = ws.data[key];
}
}
ws.data = newData;
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
}
function clearFormatting() {
const { start, end } = state.selection;
const ws = state.worksheets[state.activeWorksheet];
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
const key = `${r},${c}`;
if (ws.data[key]) {
delete ws.data[key].style;
renderCell(r, c);
}
}
}
state.isDirty = true;
scheduleAutoSave();
}
function addWorksheet() {
const num = state.worksheets.length + 1;
state.worksheets.push({ name: `Sheet${num}`, data: {} });
state.activeWorksheet = state.worksheets.length - 1;
renderWorksheetTabs();
renderAllCells();
selectCell(0, 0);
state.isDirty = true;
scheduleAutoSave();
}
function switchWorksheet(index) {
if (index < 0 || index >= state.worksheets.length) return;
state.activeWorksheet = index;
renderWorksheetTabs();
renderAllCells();
selectCell(0, 0);
}
function renderWorksheetTabs() {
elements.worksheetTabs.innerHTML = state.worksheets
.map(
(ws, i) => `
<div class="sheet-tab ${i === state.activeWorksheet ? "active" : ""}" data-index="${i}">
<span>${escapeHtml(ws.name)}</span>
<button class="tab-menu-btn">▼</button>
</div>
`,
)
.join("");
elements.worksheetTabs.querySelectorAll(".sheet-tab").forEach((tab) => {
tab.addEventListener("click", () =>
switchWorksheet(parseInt(tab.dataset.index)),
);
});
}
function zoomIn() {
state.zoom = Math.min(200, state.zoom + 10);
applyZoom();
}
function zoomOut() {
state.zoom = Math.max(50, state.zoom - 10);
applyZoom();
}
function applyZoom() {
const scale = state.zoom / 100;
elements.cells.style.transform = `scale(${scale})`;
elements.cells.style.transformOrigin = "top left";
elements.zoomLevel.textContent = state.zoom + "%";
}
function showModal(id) {
document.getElementById(id)?.classList.remove("hidden");
}
function hideModal(id) {
document.getElementById(id)?.classList.add("hidden");
}
function showShareModal() {
const link = document.getElementById("shareLink");
if (link) link.value = window.location.href;
showModal("shareModal");
}
function copyShareLink() {
const input = document.getElementById("shareLink");
if (input) {
navigator.clipboard.writeText(input.value);
}
}
function scheduleAutoSave() {
if (state.autoSaveTimer) clearTimeout(state.autoSaveTimer);
state.autoSaveTimer = setTimeout(() => {
if (state.isDirty) saveSheet();
}, CONFIG.AUTOSAVE_DELAY);
}
async function saveSheet() {
elements.saveStatus.textContent = "Saving...";
try {
const response = await fetch("/api/sheet/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: state.sheetId,
name: state.sheetName,
worksheets: state.worksheets,
}),
});
if (response.ok) {
const result = await response.json();
if (result.id) {
state.sheetId = result.id;
window.history.replaceState({}, "", `#id=${state.sheetId}`);
}
state.isDirty = false;
elements.saveStatus.textContent = "Saved";
} else {
elements.saveStatus.textContent = "Save failed";
}
} catch (e) {
elements.saveStatus.textContent = "Save failed";
}
}
async function loadFromUrlParams() {
const hash = window.location.hash;
if (!hash) return;
const params = new URLSearchParams(hash.substring(1));
const sheetId = params.get("id");
if (sheetId) {
try {
const response = await fetch(`/api/sheet/${sheetId}`);
if (response.ok) {
const data = await response.json();
state.sheetId = sheetId;
state.sheetName = data.name || "Untitled Spreadsheet";
state.worksheets = data.worksheets || [{ name: "Sheet1", data: {} }];
if (elements.sheetName) elements.sheetName.value = state.sheetName;
renderWorksheetTabs();
renderAllCells();
}
} catch (e) {
console.error("Load failed:", e);
}
}
}
function handleBeforeUnload(e) {
if (state.isDirty) {
e.preventDefault();
e.returnValue = "";
}
}
function connectWebSocket() {
if (!state.sheetId) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/sheet/ws/${state.sheetId}`;
try {
state.ws = new WebSocket(wsUrl);
state.ws.onopen = () => {
state.ws.send(
JSON.stringify({
type: "join",
sheetId: state.sheetId,
userId: getUserId(),
userName: getUserName(),
}),
);
};
state.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleWebSocketMessage(msg);
};
state.ws.onclose = () => {
setTimeout(connectWebSocket, CONFIG.WS_RECONNECT_DELAY);
};
} catch (e) {
console.error("WebSocket failed:", e);
}
}
function handleWebSocketMessage(msg) {
switch (msg.type) {
case "cellChange":
if (msg.userId !== getUserId()) {
const ws = state.worksheets[state.activeWorksheet];
const key = `${msg.row},${msg.col}`;
if (msg.value) {
ws.data[key] = { value: msg.value };
} else {
delete ws.data[key];
}
renderCell(msg.row, msg.col);
}
break;
case "cursor":
updateRemoteCursor(msg);
break;
case "userJoined":
addCollaborator(msg.user);
break;
case "userLeft":
removeCollaborator(msg.userId);
break;
}
}
function broadcastChange(type, data) {
if (state.ws?.readyState === WebSocket.OPEN) {
state.ws.send(
JSON.stringify({
type,
sheetId: state.sheetId,
userId: getUserId(),
...data,
}),
);
}
}
function updateRemoteCursor(msg) {
let cursor = document.getElementById(`cursor-${msg.userId}`);
if (!cursor) {
cursor = document.createElement("div");
cursor.id = `cursor-${msg.userId}`;
cursor.className = "cursor-indicator";
cursor.style.borderColor = msg.color || "#4285f4";
cursor.innerHTML = `<div class="cursor-label" style="background:${msg.color || "#4285f4"}">${escapeHtml(msg.userName)}</div>`;
elements.cursorIndicators?.appendChild(cursor);
}
const cell = elements.cells.querySelector(
`[data-row="${msg.row}"][data-col="${msg.col}"]`,
);
if (cell) {
const rect = cell.getBoundingClientRect();
const container = elements.cellsContainer.getBoundingClientRect();
cursor.style.left = rect.left - container.left + "px";
cursor.style.top = rect.top - container.top + "px";
cursor.style.width = rect.width + "px";
cursor.style.height = rect.height + "px";
}
}
function addCollaborator(user) {
if (!state.collaborators.find((u) => u.id === user.id)) {
state.collaborators.push(user);
renderCollaborators();
}
}
function removeCollaborator(userId) {
state.collaborators = state.collaborators.filter((u) => u.id !== userId);
document.getElementById(`cursor-${userId}`)?.remove();
renderCollaborators();
}
function renderCollaborators() {
elements.collaborators.innerHTML = state.collaborators
.slice(0, 4)
.map(
(u) => `
<div class="collaborator-avatar" style="background:${u.color || "#4285f4"}" title="${escapeHtml(u.name)}">
${u.name.charAt(0).toUpperCase()}
</div>
`,
)
.join("");
}
function getUserId() {
let id = localStorage.getItem("gb-user-id");
if (!id) {
id = "user-" + Math.random().toString(36).substr(2, 9);
localStorage.setItem("gb-user-id", id);
}
return id;
}
function getUserName() {
return localStorage.getItem("gb-user-name") || "Anonymous";
}
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function toggleChatPanel() {
state.chatPanelOpen = !state.chatPanelOpen;
elements.chatPanel.classList.toggle("collapsed", !state.chatPanelOpen);
}
function handleChatSubmit(e) {
e.preventDefault();
const message = elements.chatInput.value.trim();
if (!message) return;
addChatMessage("user", message);
elements.chatInput.value = "";
processAICommand(message);
}
function handleSuggestionClick(action) {
const commands = {
sum: "Sum column B",
format: "Format selected cells as currency",
chart: "Create a bar chart from selected data",
sort: "Sort selected column A to Z",
};
const message = commands[action] || action;
addChatMessage("user", message);
processAICommand(message);
}
function addChatMessage(role, content) {
const div = document.createElement("div");
div.className = `chat-message ${role}`;
div.innerHTML = `<div class="message-bubble">${escapeHtml(content)}</div>`;
elements.chatMessages.appendChild(div);
elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
}
async function processAICommand(command) {
const lower = command.toLowerCase();
let response = "";
if (lower.includes("sum")) {
const { start, end } = state.selection;
const colLetter = getColName(start.col);
const formula = `=SUM(${colLetter}${start.row + 1}:${colLetter}${end.row + 1})`;
const resultRow = end.row + 1;
if (resultRow < CONFIG.ROWS) {
setCellValue(resultRow, start.col, formula);
renderCell(resultRow, start.col);
selectCell(resultRow, start.col);
response = `Done! Added SUM formula in cell ${getColName(start.col)}${resultRow + 1}`;
} else {
response = "Cannot add sum - no row available below selection";
}
} else if (lower.includes("currency") || lower.includes("$")) {
formatCells("currency");
response = "Formatted selected cells as currency";
} else if (lower.includes("percent") || lower.includes("%")) {
formatCells("percent");
response = "Formatted selected cells as percentage";
} else if (lower.includes("bold")) {
formatCells("bold");
response = "Applied bold formatting to selected cells";
} else if (lower.includes("italic")) {
formatCells("italic");
response = "Applied italic formatting to selected cells";
} else if (lower.includes("sort") && lower.includes("z")) {
sortDescending();
response = "Sorted selection Z to A";
} else if (lower.includes("sort")) {
sortAscending();
response = "Sorted selection A to Z";
} else if (lower.includes("chart")) {
showModal("chartModal");
response =
"Opening chart dialog. Select chart type and configure options.";
} else if (lower.includes("clear")) {
clearCells();
response = "Cleared selected cells";
} else if (lower.includes("average") || lower.includes("avg")) {
const { start, end } = state.selection;
const colLetter = getColName(start.col);
const formula = `=AVERAGE(${colLetter}${start.row + 1}:${colLetter}${end.row + 1})`;
const resultRow = end.row + 1;
if (resultRow < CONFIG.ROWS) {
setCellValue(resultRow, start.col, formula);
renderCell(resultRow, start.col);
selectCell(resultRow, start.col);
response = `Done! Added AVERAGE formula in cell ${getColName(start.col)}${resultRow + 1}`;
}
} else {
try {
const res = await fetch("/api/sheet/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command,
selection: state.selection,
activeCell: state.activeCell,
sheetId: state.sheetId,
}),
});
const data = await res.json();
response = data.response || "I processed your request";
} catch {
response =
"I can help you with:\n• Sum/Average a column\n• Format as currency or percent\n• Bold/Italic formatting\n• Sort data\n• Create charts\n• Clear cells";
}
}
addChatMessage("assistant", response);
}
function sortAscending() {
sortSelection(true);
}
function sortDescending() {
sortSelection(false);
}
function sortSelection(ascending) {
saveToHistory();
const { start, end } = state.selection;
const ws = state.worksheets[state.activeWorksheet];
const rows = [];
for (let r = start.row; r <= end.row; r++) {
const rowData = [];
for (let c = start.col; c <= end.col; c++) {
rowData.push(getCellData(r, c));
}
rows.push({ row: r, data: rowData });
}
rows.sort((a, b) => {
const valA = a.data[0]?.value || a.data[0]?.formula || "";
const valB = b.data[0]?.value || b.data[0]?.formula || "";
const numA = parseFloat(valA);
const numB = parseFloat(valB);
if (!isNaN(numA) && !isNaN(numB)) {
return ascending ? numA - numB : numB - numA;
}
return ascending
? String(valA).localeCompare(String(valB))
: String(valB).localeCompare(String(valA));
});
rows.forEach((rowObj, i) => {
const targetRow = start.row + i;
rowObj.data.forEach((cellData, j) => {
const targetCol = start.col + j;
const key = `${targetRow},${targetCol}`;
if (cellData) {
ws.data[key] = cellData;
} else {
delete ws.data[key];
}
});
});
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
}
function connectChatWebSocket() {
// Chat uses main WebSocket connection
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();