botui/ui/suite/sheet/sheet.js

3220 lines
93 KiB
JavaScript
Raw Permalink 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,
findMatches: [],
findMatchIndex: -1,
decimalPlaces: 2,
};
const elements = {};
function init() {
cacheElements();
renderGrid();
bindEvents();
loadFromUrlParams();
connectWebSocket();
selectCell(0, 0);
updateCellAddress();
renderCharts();
renderImages();
}
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.findReplaceModal = document.getElementById("findReplaceModal");
elements.conditionalFormatModal = document.getElementById(
"conditionalFormatModal",
);
elements.dataValidationModal = document.getElementById(
"dataValidationModal",
);
elements.printPreviewModal = document.getElementById("printPreviewModal");
elements.customNumberFormatModal = document.getElementById(
"customNumberFormatModal",
);
elements.insertImageModal = document.getElementById("insertImageModal");
}
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("numberFormat")
?.addEventListener("change", handleNumberFormatChange);
document
.getElementById("decreaseDecimalBtn")
?.addEventListener("click", decreaseDecimal);
document
.getElementById("increaseDecimalBtn")
?.addEventListener("click", increaseDecimal);
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("findReplaceBtn")
?.addEventListener("click", showFindReplaceModal);
document
.getElementById("closeFindReplaceModal")
?.addEventListener("click", () => hideModal("findReplaceModal"));
document.getElementById("findNextBtn")?.addEventListener("click", findNext);
document.getElementById("findPrevBtn")?.addEventListener("click", findPrev);
document
.getElementById("replaceBtn")
?.addEventListener("click", replaceOne);
document
.getElementById("replaceAllBtn")
?.addEventListener("click", replaceAll);
document
.getElementById("findInput")
?.addEventListener("input", performFind);
document
.getElementById("conditionalFormatBtn")
?.addEventListener("click", showConditionalFormatModal);
document
.getElementById("closeConditionalFormatModal")
?.addEventListener("click", () => hideModal("conditionalFormatModal"));
document
.getElementById("applyCfBtn")
?.addEventListener("click", applyConditionalFormat);
document
.getElementById("cancelCfBtn")
?.addEventListener("click", () => hideModal("conditionalFormatModal"));
document
.getElementById("cfRuleType")
?.addEventListener("change", handleCfRuleTypeChange);
document
.getElementById("cfBgColor")
?.addEventListener("input", updateCfPreview);
document
.getElementById("cfTextColor")
?.addEventListener("input", updateCfPreview);
document
.getElementById("cfBold")
?.addEventListener("change", updateCfPreview);
document
.getElementById("cfItalic")
?.addEventListener("change", updateCfPreview);
document
.getElementById("dataValidationBtn")
?.addEventListener("click", showDataValidationModal);
document
.getElementById("closeDataValidationModal")
?.addEventListener("click", () => hideModal("dataValidationModal"));
document
.getElementById("applyDvBtn")
?.addEventListener("click", applyDataValidation);
document
.getElementById("cancelDvBtn")
?.addEventListener("click", () => hideModal("dataValidationModal"));
document
.getElementById("clearDvBtn")
?.addEventListener("click", clearDataValidation);
document
.getElementById("dvType")
?.addEventListener("change", handleDvTypeChange);
document
.getElementById("dvOperator")
?.addEventListener("change", handleDvOperatorChange);
document.querySelectorAll(".dv-tab").forEach((tab) => {
tab.addEventListener("click", () => switchDvTab(tab.dataset.tab));
});
document
.getElementById("printPreviewBtn")
?.addEventListener("click", showPrintPreview);
document
.getElementById("closePrintPreviewModal")
?.addEventListener("click", () => hideModal("printPreviewModal"));
document.getElementById("printBtn")?.addEventListener("click", printSheet);
document
.getElementById("cancelPrintBtn")
?.addEventListener("click", () => hideModal("printPreviewModal"));
document
.getElementById("printOrientation")
?.addEventListener("change", updatePrintPreview);
document
.getElementById("printPaperSize")
?.addEventListener("change", updatePrintPreview);
document
.getElementById("printScale")
?.addEventListener("change", updatePrintPreview);
document
.getElementById("printGridlines")
?.addEventListener("change", updatePrintPreview);
document
.getElementById("printHeaders")
?.addEventListener("change", updatePrintPreview);
document
.getElementById("insertChartBtn")
?.addEventListener("click", () => showModal("chartModal"));
document
.getElementById("insertChartBtnConfirm")
?.addEventListener("click", insertChart);
document
.getElementById("cancelChartBtn")
?.addEventListener("click", () => hideModal("chartModal"));
document
.getElementById("insertImageBtn")
?.addEventListener("click", showInsertImageModal);
document
.getElementById("closeInsertImageModal")
?.addEventListener("click", () => hideModal("insertImageModal"));
document
.getElementById("insertImgBtn")
?.addEventListener("click", insertImage);
document
.getElementById("cancelImgBtn")
?.addEventListener("click", () => hideModal("insertImageModal"));
document.querySelectorAll(".img-tab").forEach((tab) => {
tab.addEventListener("click", () => switchImgTab(tab.dataset.tab));
});
document
.getElementById("filterBtn")
?.addEventListener("click", toggleFilter);
document
.getElementById("sortAscBtn")
?.addEventListener("click", sortAscending);
document
.getElementById("sortDescBtn")
?.addEventListener("click", sortDescending);
document
.getElementById("closeCustomFormatModal")
?.addEventListener("click", () => hideModal("customNumberFormatModal"));
document
.getElementById("applyCnfBtn")
?.addEventListener("click", applyCustomNumberFormat);
document
.getElementById("cancelCnfBtn")
?.addEventListener("click", () => hideModal("customNumberFormatModal"));
document.querySelectorAll(".cnf-format-item").forEach((item) => {
item.addEventListener("click", () =>
selectCustomFormat(item.dataset.format),
);
});
document
.getElementById("cnfFormatCode")
?.addEventListener("input", updateCnfPreview);
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() {
const { start, end } = state.selection;
if (start.row === end.row && start.col === end.col) {
addChatMessage("assistant", "Select multiple cells to merge.");
return;
}
saveToHistory();
const ws = state.worksheets[state.activeWorksheet];
const firstKey = `${start.row},${start.col}`;
let mergedValue = "";
for (let r = start.row; r <= end.row; r++) {
for (let c = start.col; c <= end.col; c++) {
const key = `${r},${c}`;
const cellData = ws.data[key];
if (cellData?.value && !mergedValue) {
mergedValue = cellData.value;
}
if (r !== start.row || c !== start.col) {
delete ws.data[key];
}
}
}
if (!ws.data[firstKey]) ws.data[firstKey] = {};
ws.data[firstKey].value = mergedValue;
ws.data[firstKey].merged = {
rowSpan: end.row - start.row + 1,
colSpan: end.col - start.col + 1,
};
renderAllCells();
state.isDirty = true;
scheduleAutoSave();
addChatMessage("assistant", "Cells merged successfully!");
}
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
}
function handleNumberFormatChange(e) {
const format = e.target.value;
if (format === "custom") {
showModal("customNumberFormatModal");
return;
}
applyNumberFormat(format);
}
function applyNumberFormat(format) {
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: "" };
ws.data[key].format = format;
const rawValue = ws.data[key].rawValue || ws.data[key].value;
if (rawValue) {
ws.data[key].rawValue = rawValue;
ws.data[key].value = formatValue(rawValue, format);
}
renderCell(r, c);
}
}
state.isDirty = true;
scheduleAutoSave();
}
function formatValue(value, format) {
const num = parseFloat(value);
if (isNaN(num) && format !== "text") return value;
switch (format) {
case "number":
return num.toLocaleString("en-US", {
minimumFractionDigits: state.decimalPlaces,
maximumFractionDigits: state.decimalPlaces,
});
case "currency":
return num.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: state.decimalPlaces,
});
case "accounting":
const formatted = Math.abs(num).toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
return num < 0 ? `(${formatted})` : formatted;
case "percent":
return (num * 100).toFixed(state.decimalPlaces) + "%";
case "scientific":
return num.toExponential(state.decimalPlaces);
case "date_short":
const d1 = new Date(num);
return isNaN(d1.getTime()) ? value : d1.toLocaleDateString("en-US");
case "date_long":
const d2 = new Date(num);
return isNaN(d2.getTime())
? value
: d2.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
case "time":
const d3 = new Date(num);
return isNaN(d3.getTime())
? value
: d3.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
case "datetime":
const d4 = new Date(num);
return isNaN(d4.getTime()) ? value : d4.toLocaleString("en-US");
case "fraction":
return toFraction(num);
case "text":
return String(value);
default:
return value;
}
}
function toFraction(decimal) {
const tolerance = 1e-6;
let h1 = 1,
h2 = 0,
k1 = 0,
k2 = 1;
let b = decimal;
do {
const a = Math.floor(b);
let aux = h1;
h1 = a * h1 + h2;
h2 = aux;
aux = k1;
k1 = a * k1 + k2;
k2 = aux;
b = 1 / (b - a);
} while (Math.abs(decimal - h1 / k1) > decimal * tolerance);
if (k1 === 1) return String(h1);
const whole = Math.floor(h1 / k1);
const remainder = h1 % k1;
if (whole === 0) return `${remainder}/${k1}`;
return `${whole} ${remainder}/${k1}`;
}
function decreaseDecimal() {
if (state.decimalPlaces > 0) {
state.decimalPlaces--;
reapplyFormats();
}
}
function increaseDecimal() {
if (state.decimalPlaces < 10) {
state.decimalPlaces++;
reapplyFormats();
}
}
function reapplyFormats() {
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}`;
const cellData = ws.data[key];
if (cellData?.format && cellData?.rawValue) {
cellData.value = formatValue(cellData.rawValue, cellData.format);
renderCell(r, c);
}
}
}
}
function showFindReplaceModal() {
showModal("findReplaceModal");
document.getElementById("findInput")?.focus();
state.findMatches = [];
state.findMatchIndex = -1;
}
function performFind() {
const searchText = document.getElementById("findInput")?.value || "";
const matchCase = document.getElementById("findMatchCase")?.checked;
const wholeCell = document.getElementById("findWholeCell")?.checked;
const useRegex = document.getElementById("findRegex")?.checked;
state.findMatches = [];
state.findMatchIndex = -1;
if (!searchText) {
updateFindResults();
return;
}
const ws = state.worksheets[state.activeWorksheet];
let pattern;
if (useRegex) {
try {
pattern = new RegExp(searchText, matchCase ? "" : "i");
} catch (e) {
updateFindResults();
return;
}
}
for (let r = 0; r < CONFIG.ROWS; r++) {
for (let c = 0; c < CONFIG.COLS; c++) {
const key = `${r},${c}`;
const cellData = ws.data[key];
const cellValue = cellData?.value || "";
if (!cellValue) continue;
let matches = false;
const compareValue = matchCase ? cellValue : cellValue.toLowerCase();
const compareSearch = matchCase ? searchText : searchText.toLowerCase();
if (useRegex) {
matches = pattern.test(cellValue);
} else if (wholeCell) {
matches = compareValue === compareSearch;
} else {
matches = compareValue.includes(compareSearch);
}
if (matches) {
state.findMatches.push({ row: r, col: c });
}
}
}
updateFindResults();
if (state.findMatches.length > 0) {
state.findMatchIndex = 0;
highlightFindMatch();
}
}
function updateFindResults() {
const resultsEl = document.getElementById("findResults");
if (resultsEl) {
const count = state.findMatches.length;
resultsEl.querySelector("span").textContent =
count === 0
? "0 matches found"
: `${state.findMatchIndex + 1} of ${count} matches`;
}
}
function highlightFindMatch() {
if (state.findMatches.length === 0) return;
const match = state.findMatches[state.findMatchIndex];
selectCell(match.row, match.col);
updateFindResults();
}
function findNext() {
if (state.findMatches.length === 0) return;
state.findMatchIndex =
(state.findMatchIndex + 1) % state.findMatches.length;
highlightFindMatch();
}
function findPrev() {
if (state.findMatches.length === 0) return;
state.findMatchIndex =
(state.findMatchIndex - 1 + state.findMatches.length) %
state.findMatches.length;
highlightFindMatch();
}
function replaceOne() {
if (state.findMatches.length === 0 || state.findMatchIndex < 0) return;
const replaceText = document.getElementById("replaceInput")?.value || "";
const match = state.findMatches[state.findMatchIndex];
const ws = state.worksheets[state.activeWorksheet];
const key = `${match.row},${match.col}`;
saveToHistory();
const searchText = document.getElementById("findInput")?.value || "";
const matchCase = document.getElementById("findMatchCase")?.checked;
const useRegex = document.getElementById("findRegex")?.checked;
const cellValue = ws.data[key]?.value || "";
let newValue;
if (useRegex) {
const pattern = new RegExp(searchText, matchCase ? "g" : "gi");
newValue = cellValue.replace(pattern, replaceText);
} else {
const flags = matchCase ? "g" : "gi";
const escapedSearch = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
newValue = cellValue.replace(
new RegExp(escapedSearch, flags),
replaceText,
);
}
if (!ws.data[key]) ws.data[key] = {};
ws.data[key].value = newValue;
renderCell(match.row, match.col);
state.findMatches.splice(state.findMatchIndex, 1);
if (state.findMatches.length > 0) {
state.findMatchIndex = state.findMatchIndex % state.findMatches.length;
highlightFindMatch();
} else {
state.findMatchIndex = -1;
updateFindResults();
}
state.isDirty = true;
scheduleAutoSave();
}
function replaceAll() {
if (state.findMatches.length === 0) return;
const replaceText = document.getElementById("replaceInput")?.value || "";
const searchText = document.getElementById("findInput")?.value || "";
const matchCase = document.getElementById("findMatchCase")?.checked;
const useRegex = document.getElementById("findRegex")?.checked;
const ws = state.worksheets[state.activeWorksheet];
saveToHistory();
let count = 0;
for (const match of state.findMatches) {
const key = `${match.row},${match.col}`;
const cellValue = ws.data[key]?.value || "";
let newValue;
if (useRegex) {
const pattern = new RegExp(searchText, matchCase ? "g" : "gi");
newValue = cellValue.replace(pattern, replaceText);
} else {
const flags = matchCase ? "g" : "gi";
const escapedSearch = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
newValue = cellValue.replace(
new RegExp(escapedSearch, flags),
replaceText,
);
}
if (!ws.data[key]) ws.data[key] = {};
ws.data[key].value = newValue;
renderCell(match.row, match.col);
count++;
}
state.findMatches = [];
state.findMatchIndex = -1;
updateFindResults();
state.isDirty = true;
scheduleAutoSave();
addChatMessage("assistant", `Replaced ${count} occurrences.`);
}
function showConditionalFormatModal() {
const { start, end } = state.selection;
const range = `${getColName(start.col)}${start.row + 1}:${getColName(end.col)}${end.row + 1}`;
const rangeInput = document.getElementById("cfRange");
if (rangeInput) rangeInput.value = range;
showModal("conditionalFormatModal");
handleCfRuleTypeChange();
updateCfPreview();
}
function handleCfRuleTypeChange() {
const ruleType = document.getElementById("cfRuleType")?.value;
const value2 = document.getElementById("cfValue2");
const valuesSection = document.getElementById("cfValuesSection");
if (value2) {
if (ruleType === "between") {
value2.classList.remove("hidden");
value2.placeholder = "and";
} else {
value2.classList.add("hidden");
}
}
const noValueTypes = [
"duplicate",
"unique",
"blank",
"not_blank",
"above_average",
"below_average",
"color_scale",
"data_bar",
"icon_set",
];
if (valuesSection) {
if (noValueTypes.includes(ruleType)) {
valuesSection.style.display = "none";
} else {
valuesSection.style.display = "flex";
}
}
}
function updateCfPreview() {
const bgColor = document.getElementById("cfBgColor")?.value || "#ffeb3b";
const textColor =
document.getElementById("cfTextColor")?.value || "#000000";
const bold = document.getElementById("cfBold")?.checked;
const italic = document.getElementById("cfItalic")?.checked;
const previewCell = document.getElementById("cfPreviewCell");
if (previewCell) {
previewCell.style.background = bgColor;
previewCell.style.color = textColor;
previewCell.style.fontWeight = bold ? "bold" : "normal";
previewCell.style.fontStyle = italic ? "italic" : "normal";
}
}
function applyConditionalFormat() {
const rangeStr = document.getElementById("cfRange")?.value;
if (!rangeStr) {
alert("Please specify a range.");
return;
}
const ruleType = document.getElementById("cfRuleType")?.value;
const value1 = document.getElementById("cfValue1")?.value;
const value2 = document.getElementById("cfValue2")?.value;
const bgColor = document.getElementById("cfBgColor")?.value;
const textColor = document.getElementById("cfTextColor")?.value;
const bold = document.getElementById("cfBold")?.checked;
const italic = document.getElementById("cfItalic")?.checked;
const ws = state.worksheets[state.activeWorksheet];
if (!ws.conditionalFormats) ws.conditionalFormats = [];
const rule = {
id: `cf_${Date.now()}`,
range: rangeStr,
ruleType,
value1,
value2,
style: {
background: bgColor,
color: textColor,
fontWeight: bold ? "bold" : "normal",
fontStyle: italic ? "italic" : "normal",
},
};
ws.conditionalFormats.push(rule);
applyConditionalFormatsToRange(rule);
hideModal("conditionalFormatModal");
state.isDirty = true;
scheduleAutoSave();
addChatMessage("assistant", "Conditional formatting applied!");
}
function applyConditionalFormatsToRange(rule) {
const ws = state.worksheets[state.activeWorksheet];
const rangeParts = rule.range.split(":");
if (rangeParts.length !== 2) return;
const startRef = parseCellRef(rangeParts[0]);
const endRef = parseCellRef(rangeParts[1]);
if (!startRef || !endRef) return;
for (let r = startRef.row; r <= endRef.row; r++) {
for (let c = startRef.col; c <= endRef.col; c++) {
const key = `${r},${c}`;
const cellData = ws.data[key];
const cellValue = parseFloat(cellData?.value) || 0;
let conditionMet = false;
switch (rule.ruleType) {
case "greater_than":
conditionMet = cellValue > parseFloat(rule.value1);
break;
case "less_than":
conditionMet = cellValue < parseFloat(rule.value1);
break;
case "equal_to":
conditionMet = cellValue === parseFloat(rule.value1);
break;
case "between":
conditionMet =
cellValue >= parseFloat(rule.value1) &&
cellValue <= parseFloat(rule.value2);
break;
case "text_contains":
conditionMet = (cellData?.value || "")
.toLowerCase()
.includes(rule.value1.toLowerCase());
break;
case "blank":
conditionMet = !cellData?.value;
break;
case "not_blank":
conditionMet = !!cellData?.value;
break;
default:
conditionMet = false;
}
if (conditionMet && cellData) {
if (!cellData.style) cellData.style = {};
Object.assign(cellData.style, rule.style);
renderCell(r, c);
}
}
}
}
function showDataValidationModal() {
const { start, end } = state.selection;
const range = `${getColName(start.col)}${start.row + 1}:${getColName(end.col)}${end.row + 1}`;
const rangeInput = document.getElementById("dvRange");
if (rangeInput) rangeInput.value = range;
showModal("dataValidationModal");
handleDvTypeChange();
}
function switchDvTab(tabName) {
document.querySelectorAll(".dv-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === tabName);
});
document.querySelectorAll(".dv-tab-content").forEach((content) => {
const contentId = content.id
.replace("dv", "")
.replace("Tab", "")
.toLowerCase();
content.classList.toggle("active", contentId === tabName);
});
}
function handleDvTypeChange() {
const dvType = document.getElementById("dvType")?.value;
const criteriaSection = document.getElementById("dvCriteriaSection");
const valuesSection = document.getElementById("dvValuesSection");
const listSection = document.getElementById("dvListSection");
const value2Row = document.getElementById("dvValue2Row");
const value1Label = document.getElementById("dvValue1Label");
if (criteriaSection) {
criteriaSection.style.display =
dvType === "any" || dvType === "list" || dvType === "custom"
? "none"
: "block";
}
if (valuesSection) {
valuesSection.style.display =
dvType === "any" || dvType === "list" ? "none" : "block";
}
if (listSection) {
listSection.classList.toggle("hidden", dvType !== "list");
}
if (value1Label) {
value1Label.textContent = dvType === "custom" ? "Formula:" : "Minimum:";
}
}
function handleDvOperatorChange() {
const operator = document.getElementById("dvOperator")?.value;
const value2Row = document.getElementById("dvValue2Row");
const value1Label = document.getElementById("dvValue1Label");
if (value2Row) {
value2Row.style.display =
operator === "between" || operator === "not_between" ? "block" : "none";
}
if (value1Label) {
if (operator === "between" || operator === "not_between") {
value1Label.textContent = "Minimum:";
} else {
value1Label.textContent = "Value:";
}
}
}
function applyDataValidation() {
const rangeStr = document.getElementById("dvRange")?.value;
if (!rangeStr) {
alert("Please specify a range.");
return;
}
const dvType = document.getElementById("dvType")?.value;
const operator = document.getElementById("dvOperator")?.value;
const value1 = document.getElementById("dvValue1")?.value;
const value2 = document.getElementById("dvValue2")?.value;
const listSource = document.getElementById("dvListSource")?.value;
const showInput = document.getElementById("dvShowInput")?.checked;
const inputTitle = document.getElementById("dvInputTitle")?.value;
const inputMessage = document.getElementById("dvInputMessage")?.value;
const showError = document.getElementById("dvShowError")?.checked;
const errorStyle = document.getElementById("dvErrorStyle")?.value;
const errorTitle = document.getElementById("dvErrorTitle")?.value;
const errorMessage = document.getElementById("dvErrorMessage")?.value;
const ws = state.worksheets[state.activeWorksheet];
if (!ws.validations) ws.validations = {};
const validation = {
type: dvType,
operator,
value1,
value2,
listValues: listSource ? listSource.split(",").map((s) => s.trim()) : [],
showInput,
inputTitle,
inputMessage,
showError,
errorStyle,
errorTitle,
errorMessage,
};
const rangeParts = rangeStr.split(":");
const startRef = parseCellRef(rangeParts[0]);
const endRef =
rangeParts.length > 1 ? parseCellRef(rangeParts[1]) : startRef;
if (startRef && endRef) {
for (let r = startRef.row; r <= endRef.row; r++) {
for (let c = startRef.col; c <= endRef.col; c++) {
ws.validations[`${r},${c}`] = validation;
}
}
}
hideModal("dataValidationModal");
state.isDirty = true;
scheduleAutoSave();
addChatMessage("assistant", "Data validation applied!");
}
function clearDataValidation() {
const rangeStr = document.getElementById("dvRange")?.value;
if (!rangeStr) return;
const ws = state.worksheets[state.activeWorksheet];
if (!ws.validations) return;
const rangeParts = rangeStr.split(":");
const startRef = parseCellRef(rangeParts[0]);
const endRef =
rangeParts.length > 1 ? parseCellRef(rangeParts[1]) : startRef;
if (startRef && endRef) {
for (let r = startRef.row; r <= endRef.row; r++) {
for (let c = startRef.col; c <= endRef.col; c++) {
delete ws.validations[`${r},${c}`];
}
}
}
hideModal("dataValidationModal");
state.isDirty = true;
scheduleAutoSave();
}
function showPrintPreview() {
showModal("printPreviewModal");
updatePrintPreview();
}
function updatePrintPreview() {
const orientation =
document.getElementById("printOrientation")?.value || "portrait";
const showGridlines = document.getElementById("printGridlines")?.checked;
const showHeaders = document.getElementById("printHeaders")?.checked;
const printPage = document.getElementById("printPage");
const printContent = document.getElementById("printContent");
if (printPage) {
printPage.className = `print-page ${orientation}`;
}
if (!printContent) return;
const ws = state.worksheets[state.activeWorksheet];
let html = "<table>";
if (showHeaders) {
html += "<thead><tr><th></th>";
for (let c = 0; c < CONFIG.COLS; c++) {
html += `<th>${getColName(c)}</th>`;
}
html += "</tr></thead>";
}
html += "<tbody>";
let hasData = false;
let maxRow = 0;
let maxCol = 0;
for (const key in ws.data) {
if (ws.data[key]?.value) {
hasData = true;
const [r, c] = key.split(",").map(Number);
maxRow = Math.max(maxRow, r);
maxCol = Math.max(maxCol, c);
}
}
if (!hasData) {
maxRow = 10;
maxCol = 5;
}
for (let r = 0; r <= maxRow; r++) {
html += "<tr>";
if (showHeaders) {
html += `<th>${r + 1}</th>`;
}
for (let c = 0; c <= maxCol; c++) {
const key = `${r},${c}`;
const cellData = ws.data[key];
const value = cellData?.value || "";
const style = cellData?.style || {};
let styleStr = "";
if (style.fontWeight) styleStr += `font-weight:${style.fontWeight};`;
if (style.fontStyle) styleStr += `font-style:${style.fontStyle};`;
if (style.textAlign) styleStr += `text-align:${style.textAlign};`;
if (style.color) styleStr += `color:${style.color};`;
if (style.background) styleStr += `background:${style.background};`;
const borderStyle = showGridlines ? "" : "border:none;";
html += `<td style="${styleStr}${borderStyle}">${escapeHtml(value)}</td>`;
}
html += "</tr>";
}
html += "</tbody></table>";
printContent.innerHTML = html;
}
function printSheet() {
const printContent = document.getElementById("printContent")?.innerHTML;
if (!printContent) return;
const orientation =
document.getElementById("printOrientation")?.value || "portrait";
const printWindow = window.open("", "_blank");
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${state.sheetName}</title>
<style>
@page { size: ${orientation}; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 10pt; }
table { width: 100%; border-collapse: collapse; }
td, th { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
th { background: #f5f5f5; font-weight: 600; }
</style>
</head>
<body>
${printContent}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
hideModal("printPreviewModal");
}
function insertChart() {
const chartType =
document.querySelector(".chart-type-btn.active")?.dataset.type || "bar";
const dataRange = document.getElementById("chartDataRange")?.value;
const chartTitle = document.getElementById("chartTitle")?.value || "Chart";
if (!dataRange) {
alert("Please specify a data range.");
return;
}
const ws = state.worksheets[state.activeWorksheet];
if (!ws.charts) ws.charts = [];
const chart = {
id: `chart_${Date.now()}`,
type: chartType,
title: chartTitle,
dataRange,
position: {
row: state.activeCell.row,
col: state.activeCell.col,
width: 400,
height: 300,
},
};
ws.charts.push(chart);
hideModal("chartModal");
state.isDirty = true;
scheduleAutoSave();
addChatMessage(
"assistant",
`${chartType.charAt(0).toUpperCase() + chartType.slice(1)} chart created!`,
);
}
function showInsertImageModal() {
showModal("insertImageModal");
}
function switchImgTab(tabName) {
document.querySelectorAll(".img-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === tabName);
});
document.querySelectorAll(".img-tab-content").forEach((content) => {
const contentId = content.id
.replace("img", "")
.replace("Tab", "")
.toLowerCase();
content.classList.toggle("active", contentId === tabName);
});
}
function insertImage() {
const urlTab = document.getElementById("imgUrlTab");
const isUrlTab = urlTab?.classList.contains("active");
let imageUrl;
if (isUrlTab) {
imageUrl = document.getElementById("imgUrl")?.value;
} else {
const fileInput = document.getElementById("imgFile");
if (fileInput?.files?.[0]) {
addChatMessage(
"assistant",
"Image upload coming soon! Please use a URL for now.",
);
return;
}
}
if (!imageUrl) {
alert("Please enter an image URL.");
return;
}
const ws = state.worksheets[state.activeWorksheet];
if (!ws.images) ws.images = [];
const image = {
id: `img_${Date.now()}`,
url: imageUrl,
position: {
row: state.activeCell.row,
col: state.activeCell.col,
width: 200,
height: 150,
},
};
ws.images.push(image);
hideModal("insertImageModal");
state.isDirty = true;
scheduleAutoSave();
addChatMessage("assistant", "Image inserted!");
}
function toggleFilter() {
const ws = state.worksheets[state.activeWorksheet];
ws.filterEnabled = !ws.filterEnabled;
addChatMessage(
"assistant",
ws.filterEnabled
? "Filter enabled. Click column headers to filter."
: "Filter disabled.",
);
}
function selectCustomFormat(formatCode) {
document.querySelectorAll(".cnf-format-item").forEach((item) => {
item.classList.toggle("selected", item.dataset.format === formatCode);
});
const formatInput = document.getElementById("cnfFormatCode");
if (formatInput) {
formatInput.value = formatCode;
}
updateCnfPreview();
}
function updateCnfPreview() {
const formatCode =
document.getElementById("cnfFormatCode")?.value || "#,##0.00";
const previewEl = document.getElementById("cnfPreview");
if (!previewEl) return;
const sampleValue = 1234.5678;
let formatted;
if (formatCode.includes("$")) {
formatted = sampleValue.toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
} else if (formatCode.includes("%")) {
formatted = (sampleValue * 100).toFixed(2) + "%";
} else if (formatCode.includes("E")) {
formatted = sampleValue.toExponential(2);
} else if (formatCode.includes("MM") || formatCode.includes("DD")) {
formatted = new Date().toLocaleDateString();
} else if (formatCode.includes("HH")) {
formatted = new Date().toLocaleTimeString();
} else {
const decimals = (formatCode.match(/0+$/)?.[0] || "").length;
formatted = sampleValue.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
previewEl.textContent = formatted;
}
function applyCustomNumberFormat() {
const formatCode = document.getElementById("cnfFormatCode")?.value;
if (!formatCode) return;
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: "" };
ws.data[key].customFormat = formatCode;
renderCell(r, c);
}
}
hideModal("customNumberFormatModal");
state.isDirty = true;
scheduleAutoSave();
}
function renderCharts() {
const chartsContainer = document.getElementById("chartsContainer");
if (!chartsContainer) return;
const ws = state.worksheets[state.activeWorksheet];
if (!ws.charts || ws.charts.length === 0) {
chartsContainer.innerHTML = "";
return;
}
chartsContainer.innerHTML = ws.charts
.map((chart) => renderChartHTML(chart))
.join("");
chartsContainer.querySelectorAll(".chart-wrapper").forEach((wrapper) => {
const chartId = wrapper.dataset.chartId;
wrapper.addEventListener("click", () => selectChart(chartId));
wrapper.querySelector(".chart-delete")?.addEventListener("click", (e) => {
e.stopPropagation();
deleteChart(chartId);
});
wrapper
.querySelector(".chart-header")
?.addEventListener("mousedown", (e) => {
startDragChart(e, chartId);
});
});
}
function renderChartHTML(chart) {
const { id, type, title, position, dataRange } = chart;
const left = position?.col ? position.col * CONFIG.COL_WIDTH : 100;
const top = position?.row ? position.row * CONFIG.ROW_HEIGHT : 100;
const width = position?.width || 400;
const height = position?.height || 300;
const data = getChartData(dataRange);
let chartContent = "";
switch (type) {
case "bar":
chartContent = renderBarChart(data, height - 80);
break;
case "line":
chartContent = renderLineChart(data, width - 32, height - 80);
break;
case "pie":
chartContent = renderPieChart(data, Math.min(width, height) - 100);
break;
default:
chartContent = renderBarChart(data, height - 80);
}
return `
<div class="chart-wrapper" data-chart-id="${id}" style="left:${left}px;top:${top}px;width:${width}px;height:${height}px;">
<div class="chart-header">
<h4 class="chart-title">${escapeHtml(title || "Chart")}</h4>
<div class="chart-actions">
<button class="chart-delete" title="Delete">×</button>
</div>
</div>
<div class="chart-content">
${chartContent}
</div>
${renderChartLegend(data)}
</div>
`;
}
function getChartData(dataRange) {
if (!dataRange) return { labels: [], values: [] };
const ws = state.worksheets[state.activeWorksheet];
const rangeParts = dataRange.split(":");
if (rangeParts.length !== 2) return { labels: [], values: [] };
const startRef = parseCellRef(rangeParts[0]);
const endRef = parseCellRef(rangeParts[1]);
if (!startRef || !endRef) return { labels: [], values: [] };
const labels = [];
const values = [];
if (startRef.col === endRef.col) {
for (let r = startRef.row; r <= endRef.row; r++) {
const key = `${r},${startRef.col}`;
const cellData = ws.data[key];
const val = parseFloat(cellData?.value) || 0;
values.push(val);
labels.push(`Row ${r + 1}`);
}
} else {
for (let c = startRef.col; c <= endRef.col; c++) {
const key = `${startRef.row},${c}`;
const cellData = ws.data[key];
const val = parseFloat(cellData?.value) || 0;
values.push(val);
labels.push(getColName(c));
}
}
return { labels, values };
}
function renderBarChart(data, maxHeight) {
if (!data.values.length) return '<div class="chart-empty">No data</div>';
const maxVal = Math.max(...data.values, 1);
const bars = data.values
.map((val, i) => {
const height = (val / maxVal) * maxHeight;
return `<div class="chart-bar" style="height:${height}px;" title="${data.labels[i]}: ${val}"></div>`;
})
.join("");
return `<div class="chart-bar-container" style="height:${maxHeight}px;">${bars}</div>`;
}
function renderLineChart(data, width, height) {
if (!data.values.length) return '<div class="chart-empty">No data</div>';
const maxVal = Math.max(...data.values, 1);
const padding = 20;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const points = data.values.map((val, i) => {
const x = padding + (i / (data.values.length - 1 || 1)) * chartWidth;
const y = padding + chartHeight - (val / maxVal) * chartHeight;
return `${x},${y}`;
});
const circles = data.values
.map((val, i) => {
const x = padding + (i / (data.values.length - 1 || 1)) * chartWidth;
const y = padding + chartHeight - (val / maxVal) * chartHeight;
return `<circle class="chart-line-point" cx="${x}" cy="${y}" r="4"/>`;
})
.join("");
return `
<svg class="chart-canvas" viewBox="0 0 ${width} ${height}">
<polyline class="chart-line" points="${points.join(" ")}"/>
${circles}
</svg>
`;
}
function renderPieChart(data, size) {
if (!data.values.length) return '<div class="chart-empty">No data</div>';
const total = data.values.reduce((a, b) => a + b, 0) || 1;
const colors = [
"#4285f4",
"#34a853",
"#fbbc04",
"#ea4335",
"#9c27b0",
"#00bcd4",
"#ff5722",
];
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 10;
let startAngle = 0;
const slices = data.values
.map((val, i) => {
const angle = (val / total) * 360;
const endAngle = startAngle + angle;
const largeArc = angle > 180 ? 1 : 0;
const x1 = cx + r * Math.cos((startAngle * Math.PI) / 180);
const y1 = cy + r * Math.sin((startAngle * Math.PI) / 180);
const x2 = cx + r * Math.cos((endAngle * Math.PI) / 180);
const y2 = cy + r * Math.sin((endAngle * Math.PI) / 180);
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
startAngle = endAngle;
return `<path d="${path}" fill="${colors[i % colors.length]}" stroke="white" stroke-width="2"/>`;
})
.join("");
return `
<div class="chart-pie-container">
<svg class="chart-canvas" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
${slices}
</svg>
</div>
`;
}
function renderChartLegend(data) {
const colors = [
"#4285f4",
"#34a853",
"#fbbc04",
"#ea4335",
"#9c27b0",
"#00bcd4",
"#ff5722",
];
const items = data.labels
.map(
(label, i) =>
`<div class="legend-item"><span class="legend-color" style="background:${colors[i % colors.length]}"></span>${escapeHtml(label)}</div>`,
)
.join("");
return `<div class="chart-legend">${items}</div>`;
}
function selectChart(chartId) {
document.querySelectorAll(".chart-wrapper").forEach((el) => {
el.classList.toggle("selected", el.dataset.chartId === chartId);
});
}
function deleteChart(chartId) {
const ws = state.worksheets[state.activeWorksheet];
if (!ws.charts) return;
ws.charts = ws.charts.filter((c) => c.id !== chartId);
renderCharts();
state.isDirty = true;
scheduleAutoSave();
}
function startDragChart(e, chartId) {
const wrapper = document.querySelector(`[data-chart-id="${chartId}"]`);
if (!wrapper) return;
const startX = e.clientX;
const startY = e.clientY;
const startLeft = parseInt(wrapper.style.left) || 0;
const startTop = parseInt(wrapper.style.top) || 0;
const onMouseMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
wrapper.style.left = `${startLeft + dx}px`;
wrapper.style.top = `${startTop + dy}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const ws = state.worksheets[state.activeWorksheet];
const chart = ws.charts?.find((c) => c.id === chartId);
if (chart) {
chart.position.col = Math.round(
parseInt(wrapper.style.left) / CONFIG.COL_WIDTH,
);
chart.position.row = Math.round(
parseInt(wrapper.style.top) / CONFIG.ROW_HEIGHT,
);
state.isDirty = true;
scheduleAutoSave();
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}
function renderImages() {
const imagesContainer = document.getElementById("imagesContainer");
if (!imagesContainer) return;
const ws = state.worksheets[state.activeWorksheet];
if (!ws.images || ws.images.length === 0) {
imagesContainer.innerHTML = "";
return;
}
imagesContainer.innerHTML = ws.images
.map((img) => {
const left = img.position?.col
? img.position.col * CONFIG.COL_WIDTH
: 100;
const top = img.position?.row
? img.position.row * CONFIG.ROW_HEIGHT
: 100;
const width = img.position?.width || 200;
const height = img.position?.height || 150;
return `
<div class="image-wrapper" data-image-id="${img.id}" style="left:${left}px;top:${top}px;width:${width}px;height:${height}px;">
<img src="${escapeHtml(img.url)}" alt="Embedded image" />
<div class="image-resize-handle"></div>
</div>
`;
})
.join("");
imagesContainer.querySelectorAll(".image-wrapper").forEach((wrapper) => {
const imageId = wrapper.dataset.imageId;
wrapper.addEventListener("click", () => selectImage(imageId));
wrapper.addEventListener("mousedown", (e) => {
if (e.target.classList.contains("image-resize-handle")) {
startResizeImage(e, imageId);
} else {
startDragImage(e, imageId);
}
});
});
}
function selectImage(imageId) {
document.querySelectorAll(".image-wrapper").forEach((el) => {
el.classList.toggle("selected", el.dataset.imageId === imageId);
});
}
function startDragImage(e, imageId) {
const wrapper = document.querySelector(`[data-image-id="${imageId}"]`);
if (!wrapper) return;
const startX = e.clientX;
const startY = e.clientY;
const startLeft = parseInt(wrapper.style.left) || 0;
const startTop = parseInt(wrapper.style.top) || 0;
const onMouseMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
wrapper.style.left = `${startLeft + dx}px`;
wrapper.style.top = `${startTop + dy}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const ws = state.worksheets[state.activeWorksheet];
const img = ws.images?.find((i) => i.id === imageId);
if (img) {
img.position.col = Math.round(
parseInt(wrapper.style.left) / CONFIG.COL_WIDTH,
);
img.position.row = Math.round(
parseInt(wrapper.style.top) / CONFIG.ROW_HEIGHT,
);
state.isDirty = true;
scheduleAutoSave();
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
e.preventDefault();
}
function startResizeImage(e, imageId) {
const wrapper = document.querySelector(`[data-image-id="${imageId}"]`);
if (!wrapper) return;
const startX = e.clientX;
const startY = e.clientY;
const startWidth = parseInt(wrapper.style.width) || 200;
const startHeight = parseInt(wrapper.style.height) || 150;
const aspectRatio = startWidth / startHeight;
const onMouseMove = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const newWidth = Math.max(50, startWidth + dx);
const newHeight = newWidth / aspectRatio;
wrapper.style.width = `${newWidth}px`;
wrapper.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const ws = state.worksheets[state.activeWorksheet];
const img = ws.images?.find((i) => i.id === imageId);
if (img) {
img.position.width = parseInt(wrapper.style.width);
img.position.height = parseInt(wrapper.style.height);
state.isDirty = true;
scheduleAutoSave();
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
e.preventDefault();
e.stopPropagation();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();