1735 lines
48 KiB
JavaScript
1735 lines
48 KiB
JavaScript
|
|
/* =============================================================================
|
|||
|
|
GB SHEET - Excel-like Spreadsheet JavaScript
|
|||
|
|
General Bots Suite Component
|
|||
|
|
============================================================================= */
|
|||
|
|
|
|||
|
|
(function () {
|
|||
|
|
"use strict";
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CONFIGURATION
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
const CONFIG = {
|
|||
|
|
COLS: 26,
|
|||
|
|
ROWS: 100,
|
|||
|
|
COL_WIDTH: 100,
|
|||
|
|
ROW_HEIGHT: 24,
|
|||
|
|
MAX_HISTORY: 50,
|
|||
|
|
AUTOSAVE_DELAY: 3000,
|
|||
|
|
WS_RECONNECT_DELAY: 5000,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// STATE
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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: 1,
|
|||
|
|
collaborators: [],
|
|||
|
|
ws: null,
|
|||
|
|
isEditing: false,
|
|||
|
|
isSelecting: false,
|
|||
|
|
selectionStart: null,
|
|||
|
|
isDirty: false,
|
|||
|
|
autoSaveTimer: null,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// DOM ELEMENTS
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
const elements = {
|
|||
|
|
container: null,
|
|||
|
|
sidebar: null,
|
|||
|
|
sheetList: null,
|
|||
|
|
sheetName: null,
|
|||
|
|
columnHeaders: null,
|
|||
|
|
rowHeaders: null,
|
|||
|
|
cells: null,
|
|||
|
|
cellsContainer: null,
|
|||
|
|
formulaInput: null,
|
|||
|
|
cellAddress: null,
|
|||
|
|
formulaPreview: null,
|
|||
|
|
worksheetTabs: null,
|
|||
|
|
collaborators: null,
|
|||
|
|
contextMenu: null,
|
|||
|
|
tabContextMenu: null,
|
|||
|
|
shareModal: null,
|
|||
|
|
functionModal: null,
|
|||
|
|
chartModal: null,
|
|||
|
|
cursorIndicators: null,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// INITIALIZATION
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function init() {
|
|||
|
|
cacheElements();
|
|||
|
|
renderGrid();
|
|||
|
|
bindEvents();
|
|||
|
|
loadFromUrlParams();
|
|||
|
|
connectWebSocket();
|
|||
|
|
updateCellAddress();
|
|||
|
|
renderWorksheetTabs();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cacheElements() {
|
|||
|
|
elements.container = document.querySelector(".sheet-container");
|
|||
|
|
elements.sidebar = document.getElementById("sheet-sidebar");
|
|||
|
|
elements.sheetList = document.getElementById("sheet-list");
|
|||
|
|
elements.sheetName = document.getElementById("sheet-name");
|
|||
|
|
elements.columnHeaders = document.getElementById("column-headers");
|
|||
|
|
elements.rowHeaders = document.getElementById("row-headers");
|
|||
|
|
elements.cells = document.getElementById("cells");
|
|||
|
|
elements.cellsContainer = document.getElementById("cells-container");
|
|||
|
|
elements.formulaInput = document.getElementById("formula-input");
|
|||
|
|
elements.cellAddress = document.getElementById("cell-address");
|
|||
|
|
elements.formulaPreview = document.getElementById("formula-preview");
|
|||
|
|
elements.worksheetTabs = document.getElementById("worksheet-tabs");
|
|||
|
|
elements.collaborators = document.getElementById("collaborators");
|
|||
|
|
elements.contextMenu = document.getElementById("context-menu");
|
|||
|
|
elements.tabContextMenu = document.getElementById("tab-context-menu");
|
|||
|
|
elements.shareModal = document.getElementById("share-modal");
|
|||
|
|
elements.functionModal = document.getElementById("function-modal");
|
|||
|
|
elements.chartModal = document.getElementById("chart-modal");
|
|||
|
|
elements.cursorIndicators = document.getElementById("cursor-indicators");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// GRID RENDERING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function renderGrid() {
|
|||
|
|
if (!elements.columnHeaders || !elements.rowHeaders || !elements.cells)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
elements.columnHeaders.innerHTML = "";
|
|||
|
|
for (let c = 0; c < CONFIG.COLS; c++) {
|
|||
|
|
const header = document.createElement("div");
|
|||
|
|
header.className = "column-header";
|
|||
|
|
header.textContent = getColName(c);
|
|||
|
|
header.dataset.col = c;
|
|||
|
|
header.innerHTML += '<div class="column-resize"></div>';
|
|||
|
|
elements.columnHeaders.appendChild(header);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
elements.rowHeaders.innerHTML = "";
|
|||
|
|
for (let r = 0; r < CONFIG.ROWS; r++) {
|
|||
|
|
const header = document.createElement("div");
|
|||
|
|
header.className = "row-header";
|
|||
|
|
header.textContent = r + 1;
|
|||
|
|
header.dataset.row = r;
|
|||
|
|
elements.rowHeaders.appendChild(header);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
elements.cells.innerHTML = "";
|
|||
|
|
elements.cells.style.gridTemplateColumns = `repeat(${CONFIG.COLS}, ${CONFIG.COL_WIDTH}px)`;
|
|||
|
|
|
|||
|
|
for (let r = 0; r < CONFIG.ROWS; r++) {
|
|||
|
|
for (let c = 0; c < CONFIG.COLS; c++) {
|
|||
|
|
const cell = document.createElement("div");
|
|||
|
|
cell.className = "cell";
|
|||
|
|
cell.dataset.row = r;
|
|||
|
|
cell.dataset.col = c;
|
|||
|
|
cell.id = `cell-${r}-${c}`;
|
|||
|
|
elements.cells.appendChild(cell);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
selectCell(0, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderAllCells() {
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
if (!ws) return;
|
|||
|
|
|
|||
|
|
for (let r = 0; r < CONFIG.ROWS; r++) {
|
|||
|
|
for (let c = 0; c < CONFIG.COLS; c++) {
|
|||
|
|
renderCell(r, c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCell(row, col) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (!cell) return;
|
|||
|
|
|
|||
|
|
const data = getCellData(row, col);
|
|||
|
|
if (data) {
|
|||
|
|
let displayValue = data.value || "";
|
|||
|
|
if (displayValue.startsWith("=")) {
|
|||
|
|
displayValue = evaluateFormula(displayValue, row, col);
|
|||
|
|
}
|
|||
|
|
cell.textContent = displayValue;
|
|||
|
|
|
|||
|
|
if (data.format) {
|
|||
|
|
applyFormatToCell(cell, data.format);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
cell.textContent = "";
|
|||
|
|
cell.style.cssText = "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyFormatToCell(cell, format) {
|
|||
|
|
if (format.bold) cell.style.fontWeight = "bold";
|
|||
|
|
if (format.italic) cell.style.fontStyle = "italic";
|
|||
|
|
if (format.underline) cell.style.textDecoration = "underline";
|
|||
|
|
if (format.strikethrough) {
|
|||
|
|
cell.style.textDecoration = cell.style.textDecoration
|
|||
|
|
? cell.style.textDecoration + " line-through"
|
|||
|
|
: "line-through";
|
|||
|
|
}
|
|||
|
|
if (format.fontFamily) cell.style.fontFamily = format.fontFamily;
|
|||
|
|
if (format.fontSize) cell.style.fontSize = format.fontSize + "px";
|
|||
|
|
if (format.color) cell.style.color = format.color;
|
|||
|
|
if (format.backgroundColor)
|
|||
|
|
cell.style.backgroundColor = format.backgroundColor;
|
|||
|
|
if (format.textAlign) cell.style.textAlign = format.textAlign;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// COLUMN/ROW UTILITIES
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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()),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// EVENT BINDING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function bindEvents() {
|
|||
|
|
if (elements.cells) {
|
|||
|
|
elements.cells.addEventListener("mousedown", handleCellMouseDown);
|
|||
|
|
elements.cells.addEventListener("dblclick", handleCellDoubleClick);
|
|||
|
|
elements.cells.addEventListener("contextmenu", handleContextMenu);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|||
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|||
|
|
document.addEventListener("keydown", handleKeyDown);
|
|||
|
|
document.addEventListener("click", hideContextMenus);
|
|||
|
|
|
|||
|
|
if (elements.formulaInput) {
|
|||
|
|
elements.formulaInput.addEventListener("keydown", handleFormulaKey);
|
|||
|
|
elements.formulaInput.addEventListener("input", updateFormulaPreview);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (elements.columnHeaders) {
|
|||
|
|
elements.columnHeaders.addEventListener("click", handleColumnHeaderClick);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (elements.rowHeaders) {
|
|||
|
|
elements.rowHeaders.addEventListener("click", handleRowHeaderClick);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CELL SELECTION
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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 (e.shiftKey) {
|
|||
|
|
extendSelection(row, col);
|
|||
|
|
} else {
|
|||
|
|
state.isSelecting = true;
|
|||
|
|
state.selectionStart = { row, col };
|
|||
|
|
selectCell(row, col);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleMouseMove(e) {
|
|||
|
|
if (!state.isSelecting || !state.selectionStart) return;
|
|||
|
|
|
|||
|
|
const cell = document.elementFromPoint(e.clientX, e.clientY);
|
|||
|
|
if (!cell || !cell.classList.contains("cell")) return;
|
|||
|
|
|
|||
|
|
const row = parseInt(cell.dataset.row);
|
|||
|
|
const col = parseInt(cell.dataset.col);
|
|||
|
|
extendSelection(row, col);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleMouseUp() {
|
|||
|
|
state.isSelecting = false;
|
|||
|
|
state.selectionStart = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (cell) {
|
|||
|
|
cell.classList.add("selected");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateCellAddress();
|
|||
|
|
updateFormulaBar();
|
|||
|
|
updateSelectionInfo();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extendSelection(row, col) {
|
|||
|
|
if (!state.selectionStart) {
|
|||
|
|
state.selectionStart = { ...state.activeCell };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const start = state.selectionStart;
|
|||
|
|
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),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
clearSelection();
|
|||
|
|
|
|||
|
|
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 = document.getElementById(`cell-${r}-${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() {
|
|||
|
|
document
|
|||
|
|
.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);
|
|||
|
|
state.selection = {
|
|||
|
|
start: { row: 0, col },
|
|||
|
|
end: { row: CONFIG.ROWS - 1, col },
|
|||
|
|
};
|
|||
|
|
state.activeCell = { row: 0, col };
|
|||
|
|
|
|||
|
|
clearSelection();
|
|||
|
|
for (let r = 0; r < CONFIG.ROWS; r++) {
|
|||
|
|
const cell = document.getElementById(`cell-${r}-${col}`);
|
|||
|
|
if (cell) {
|
|||
|
|
cell.classList.add(r === 0 ? "selected" : "in-range");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
header.classList.add("selected");
|
|||
|
|
updateCellAddress();
|
|||
|
|
updateCalculationResult();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleRowHeaderClick(e) {
|
|||
|
|
const header = e.target.closest(".row-header");
|
|||
|
|
if (!header) return;
|
|||
|
|
|
|||
|
|
const row = parseInt(header.dataset.row);
|
|||
|
|
state.selection = {
|
|||
|
|
start: { row, col: 0 },
|
|||
|
|
end: { row, col: CONFIG.COLS - 1 },
|
|||
|
|
};
|
|||
|
|
state.activeCell = { row, col: 0 };
|
|||
|
|
|
|||
|
|
clearSelection();
|
|||
|
|
for (let c = 0; c < CONFIG.COLS; c++) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${c}`);
|
|||
|
|
if (cell) {
|
|||
|
|
cell.classList.add(c === 0 ? "selected" : "in-range");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
header.classList.add("selected");
|
|||
|
|
updateCellAddress();
|
|||
|
|
updateCalculationResult();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CELL EDITING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function startEditing(row, col) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (!cell) return;
|
|||
|
|
|
|||
|
|
state.isEditing = true;
|
|||
|
|
const data = getCellData(row, col);
|
|||
|
|
|
|||
|
|
cell.classList.add("editing");
|
|||
|
|
const input = document.createElement("input");
|
|||
|
|
input.type = "text";
|
|||
|
|
input.className = "cell-input";
|
|||
|
|
input.value = data ? data.value || "" : "";
|
|||
|
|
|
|||
|
|
input.addEventListener("blur", () => finishEditing(row, col));
|
|||
|
|
input.addEventListener("keydown", (e) => {
|
|||
|
|
if (e.key === "Enter") {
|
|||
|
|
finishEditing(row, col);
|
|||
|
|
navigateCell(1, 0);
|
|||
|
|
} else if (e.key === "Tab") {
|
|||
|
|
e.preventDefault();
|
|||
|
|
finishEditing(row, col);
|
|||
|
|
navigateCell(0, e.shiftKey ? -1 : 1);
|
|||
|
|
} else if (e.key === "Escape") {
|
|||
|
|
cancelEditing(row, col);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
cell.innerHTML = "";
|
|||
|
|
cell.appendChild(input);
|
|||
|
|
input.focus();
|
|||
|
|
input.select();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function finishEditing(row, col) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (!cell) return;
|
|||
|
|
|
|||
|
|
const input = cell.querySelector(".cell-input");
|
|||
|
|
if (input) {
|
|||
|
|
const value = input.value;
|
|||
|
|
setCellValue(row, col, value);
|
|||
|
|
broadcastChange(row, col, value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cell.classList.remove("editing");
|
|||
|
|
state.isEditing = false;
|
|||
|
|
renderCell(row, col);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cancelEditing(row, col) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (!cell) return;
|
|||
|
|
|
|||
|
|
cell.classList.remove("editing");
|
|||
|
|
state.isEditing = false;
|
|||
|
|
renderCell(row, col);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CELL DATA
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function setCellValue(row, col, value) {
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const key = `${row},${col}`;
|
|||
|
|
|
|||
|
|
if (!value && ws.data[key]) {
|
|||
|
|
delete ws.data[key];
|
|||
|
|
} else if (value) {
|
|||
|
|
if (!ws.data[key]) {
|
|||
|
|
ws.data[key] = {};
|
|||
|
|
}
|
|||
|
|
ws.data[key].value = value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
state.isDirty = true;
|
|||
|
|
scheduleAutoSave();
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCellData(row, col) {
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
return ws ? ws.data[`${row},${col}`] : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCellValue(row, col) {
|
|||
|
|
const data = getCellData(row, col);
|
|||
|
|
return data ? data.value || "" : "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// FORMULA EVALUATION
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function evaluateFormula(formula, sourceRow, sourceCol) {
|
|||
|
|
if (!formula.startsWith("=")) return formula;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
let expr = formula.substring(1);
|
|||
|
|
|
|||
|
|
expr = expr.replace(/([A-Z]+\d+):([A-Z]+\d+)/gi, (match, start, end) => {
|
|||
|
|
return JSON.stringify(parseRange(start, end));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/([A-Z]+)(\d+)/gi, (match, col, row) => {
|
|||
|
|
const r = parseInt(row) - 1;
|
|||
|
|
const c = parseColName(col.toUpperCase());
|
|||
|
|
const val = getCellValue(r, c);
|
|||
|
|
const num = parseFloat(val);
|
|||
|
|
return isNaN(num) ? `"${val}"` : num;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/SUM\s*\(\s*(\[.*?\])\s*\)/gi, (match, arr) => {
|
|||
|
|
return `sumRange(${arr})`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/AVERAGE\s*\(\s*(\[.*?\])\s*\)/gi, (match, arr) => {
|
|||
|
|
return `averageRange(${arr})`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/COUNT\s*\(\s*(\[.*?\])\s*\)/gi, (match, arr) => {
|
|||
|
|
return `countRange(${arr})`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/MAX\s*\(\s*(\[.*?\])\s*\)/gi, (match, arr) => {
|
|||
|
|
return `maxRange(${arr})`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(/MIN\s*\(\s*(\[.*?\])\s*\)/gi, (match, arr) => {
|
|||
|
|
return `minRange(${arr})`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expr = expr.replace(
|
|||
|
|
/IF\s*\(\s*(.*?)\s*,\s*(.*?)\s*,\s*(.*?)\s*\)/gi,
|
|||
|
|
(match, cond, t, f) => {
|
|||
|
|
return `(${cond} ? ${t} : ${f})`;
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const result = new Function(
|
|||
|
|
"sumRange",
|
|||
|
|
"averageRange",
|
|||
|
|
"countRange",
|
|||
|
|
"maxRange",
|
|||
|
|
"minRange",
|
|||
|
|
`return ${expr}`,
|
|||
|
|
)(sumRange, averageRange, countRange, maxRange, minRange);
|
|||
|
|
|
|||
|
|
return isNaN(result) ? result : Math.round(result * 1000000) / 1000000;
|
|||
|
|
} catch (e) {
|
|||
|
|
return "#ERROR!";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseRange(startRef, endRef) {
|
|||
|
|
const start = parseCellRef(startRef);
|
|||
|
|
const end = parseCellRef(endRef);
|
|||
|
|
if (!start || !end) return [];
|
|||
|
|
|
|||
|
|
const values = [];
|
|||
|
|
for (let r = start.row; r <= end.row; r++) {
|
|||
|
|
for (let c = start.col; c <= end.col; c++) {
|
|||
|
|
const val = getCellValue(r, c);
|
|||
|
|
const num = parseFloat(val);
|
|||
|
|
if (!isNaN(num)) values.push(num);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return values;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sumRange(values) {
|
|||
|
|
return values.reduce((a, b) => a + b, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function averageRange(values) {
|
|||
|
|
if (values.length === 0) return 0;
|
|||
|
|
return sumRange(values) / values.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function countRange(values) {
|
|||
|
|
return values.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function maxRange(values) {
|
|||
|
|
return values.length > 0 ? Math.max(...values) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function minRange(values) {
|
|||
|
|
return values.length > 0 ? Math.min(...values) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// KEYBOARD HANDLING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function handleKeyDown(e) {
|
|||
|
|
if (state.isEditing) return;
|
|||
|
|
|
|||
|
|
const { row, col } = state.activeCell;
|
|||
|
|
|
|||
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
|||
|
|
switch (e.key.toLowerCase()) {
|
|||
|
|
case "c":
|
|||
|
|
e.preventDefault();
|
|||
|
|
copySelection();
|
|||
|
|
break;
|
|||
|
|
case "x":
|
|||
|
|
e.preventDefault();
|
|||
|
|
cutSelection();
|
|||
|
|
break;
|
|||
|
|
case "v":
|
|||
|
|
e.preventDefault();
|
|||
|
|
pasteSelection();
|
|||
|
|
break;
|
|||
|
|
case "z":
|
|||
|
|
e.preventDefault();
|
|||
|
|
undo();
|
|||
|
|
break;
|
|||
|
|
case "y":
|
|||
|
|
e.preventDefault();
|
|||
|
|
redo();
|
|||
|
|
break;
|
|||
|
|
case "b":
|
|||
|
|
e.preventDefault();
|
|||
|
|
formatCells("bold");
|
|||
|
|
break;
|
|||
|
|
case "i":
|
|||
|
|
e.preventDefault();
|
|||
|
|
formatCells("italic");
|
|||
|
|
break;
|
|||
|
|
case "u":
|
|||
|
|
e.preventDefault();
|
|||
|
|
formatCells("underline");
|
|||
|
|
break;
|
|||
|
|
case "s":
|
|||
|
|
e.preventDefault();
|
|||
|
|
saveSheet();
|
|||
|
|
break;
|
|||
|
|
case "a":
|
|||
|
|
e.preventDefault();
|
|||
|
|
selectAll();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch (e.key) {
|
|||
|
|
case "ArrowUp":
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (e.shiftKey) {
|
|||
|
|
extendSelection(
|
|||
|
|
Math.max(0, state.selection.end.row - 1),
|
|||
|
|
state.selection.end.col,
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
navigateCell(-1, 0);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case "ArrowDown":
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (e.shiftKey) {
|
|||
|
|
extendSelection(
|
|||
|
|
Math.min(CONFIG.ROWS - 1, state.selection.end.row + 1),
|
|||
|
|
state.selection.end.col,
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
navigateCell(1, 0);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case "ArrowLeft":
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (e.shiftKey) {
|
|||
|
|
extendSelection(
|
|||
|
|
state.selection.end.row,
|
|||
|
|
Math.max(0, state.selection.end.col - 1),
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
navigateCell(0, -1);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case "ArrowRight":
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (e.shiftKey) {
|
|||
|
|
extendSelection(
|
|||
|
|
state.selection.end.row,
|
|||
|
|
Math.min(CONFIG.COLS - 1, state.selection.end.col + 1),
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
navigateCell(0, 1);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case "Enter":
|
|||
|
|
e.preventDefault();
|
|||
|
|
startEditing(row, col);
|
|||
|
|
break;
|
|||
|
|
case "Tab":
|
|||
|
|
e.preventDefault();
|
|||
|
|
navigateCell(0, e.shiftKey ? -1 : 1);
|
|||
|
|
break;
|
|||
|
|
case "Delete":
|
|||
|
|
case "Backspace":
|
|||
|
|
e.preventDefault();
|
|||
|
|
clearCells();
|
|||
|
|
break;
|
|||
|
|
case "F2":
|
|||
|
|
e.preventDefault();
|
|||
|
|
startEditing(row, col);
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|||
|
|
startEditing(row, col);
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
const input = cell ? cell.querySelector(".cell-input") : null;
|
|||
|
|
if (input) {
|
|||
|
|
input.value = e.key;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function navigateCell(deltaRow, deltaCol) {
|
|||
|
|
const newRow = Math.max(
|
|||
|
|
0,
|
|||
|
|
Math.min(CONFIG.ROWS - 1, state.activeCell.row + deltaRow),
|
|||
|
|
);
|
|||
|
|
const newCol = Math.max(
|
|||
|
|
0,
|
|||
|
|
Math.min(CONFIG.COLS - 1, state.activeCell.col + deltaCol),
|
|||
|
|
);
|
|||
|
|
selectCell(newRow, newCol);
|
|||
|
|
scrollCellIntoView(newRow, newCol);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scrollCellIntoView(row, col) {
|
|||
|
|
const cell = document.getElementById(`cell-${row}-${col}`);
|
|||
|
|
if (cell) {
|
|||
|
|
cell.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectAll() {
|
|||
|
|
state.selection = {
|
|||
|
|
start: { row: 0, col: 0 },
|
|||
|
|
end: { row: CONFIG.ROWS - 1, col: CONFIG.COLS - 1 },
|
|||
|
|
};
|
|||
|
|
clearSelection();
|
|||
|
|
for (let r = 0; r < CONFIG.ROWS; r++) {
|
|||
|
|
for (let c = 0; c < CONFIG.COLS; c++) {
|
|||
|
|
const cell = document.getElementById(`cell-${r}-${c}`);
|
|||
|
|
if (cell) {
|
|||
|
|
cell.classList.add(r === 0 && c === 0 ? "selected" : "in-range");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// FORMULA BAR
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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() {
|
|||
|
|
if (!elements.formulaInput || !elements.formulaPreview) return;
|
|||
|
|
|
|||
|
|
const value = elements.formulaInput.value;
|
|||
|
|
if (value.startsWith("=")) {
|
|||
|
|
const result = evaluateFormula(
|
|||
|
|
value,
|
|||
|
|
state.activeCell.row,
|
|||
|
|
state.activeCell.col,
|
|||
|
|
);
|
|||
|
|
elements.formulaPreview.textContent = `= ${result}`;
|
|||
|
|
} else {
|
|||
|
|
elements.formulaPreview.textContent = "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateCellAddress() {
|
|||
|
|
if (!elements.cellAddress) return;
|
|||
|
|
const ref = getCellRef(state.activeCell.row, state.activeCell.col);
|
|||
|
|
elements.cellAddress.textContent = ref;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateFormulaBar() {
|
|||
|
|
if (!elements.formulaInput) return;
|
|||
|
|
const data = getCellData(state.activeCell.row, state.activeCell.col);
|
|||
|
|
elements.formulaInput.value = data ? data.value || "" : "";
|
|||
|
|
updateFormulaPreview();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateSelectionInfo() {
|
|||
|
|
const info = document.getElementById("selection-info");
|
|||
|
|
if (!info) return;
|
|||
|
|
|
|||
|
|
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) {
|
|||
|
|
info.textContent = `${rows}R × ${cols}C`;
|
|||
|
|
} else {
|
|||
|
|
info.textContent = "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateCalculationResult() {
|
|||
|
|
const result = document.getElementById("calculation-result");
|
|||
|
|
if (!result) return;
|
|||
|
|
|
|||
|
|
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 = getCellValue(r, c);
|
|||
|
|
const num = parseFloat(val);
|
|||
|
|
if (!isNaN(num)) values.push(num);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (values.length > 1) {
|
|||
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|||
|
|
const avg = sum / values.length;
|
|||
|
|
result.textContent = `Sum: ${sum.toFixed(2)} | Avg: ${avg.toFixed(2)} | Count: ${values.length}`;
|
|||
|
|
} else {
|
|||
|
|
result.textContent = "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CLIPBOARD OPERATIONS
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function copySelection() {
|
|||
|
|
state.clipboard = getSelectionData();
|
|||
|
|
state.clipboardMode = "copy";
|
|||
|
|
showCopyBox();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cutSelection() {
|
|||
|
|
state.clipboard = getSelectionData();
|
|||
|
|
state.clipboardMode = "cut";
|
|||
|
|
showCopyBox();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pasteSelection() {
|
|||
|
|
if (!state.clipboard) return;
|
|||
|
|
|
|||
|
|
const { row, col } = state.activeCell;
|
|||
|
|
const data = state.clipboard;
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
for (let r = 0; r < data.length; r++) {
|
|||
|
|
for (let c = 0; c < data[r].length; c++) {
|
|||
|
|
const targetRow = row + r;
|
|||
|
|
const targetCol = col + c;
|
|||
|
|
if (targetRow < CONFIG.ROWS && targetCol < CONFIG.COLS) {
|
|||
|
|
const cellData = data[r][c];
|
|||
|
|
if (cellData) {
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
ws.data[`${targetRow},${targetCol}`] = { ...cellData };
|
|||
|
|
}
|
|||
|
|
renderCell(targetRow, targetCol);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (state.clipboardMode === "cut") {
|
|||
|
|
clearSourceCells();
|
|||
|
|
state.clipboard = null;
|
|||
|
|
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) ? { ...getCellData(r, c) } : null);
|
|||
|
|
}
|
|||
|
|
data.push(rowData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearSourceCells() {
|
|||
|
|
const { start, end } = state.selection;
|
|||
|
|
for (let r = start.row; r <= end.row; r++) {
|
|||
|
|
for (let c = start.col; c <= end.col; c++) {
|
|||
|
|
setCellValue(r, c, "");
|
|||
|
|
renderCell(r, c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearCells() {
|
|||
|
|
const { start, end } = state.selection;
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
for (let r = start.row; r <= end.row; r++) {
|
|||
|
|
for (let c = start.col; c <= end.col; c++) {
|
|||
|
|
setCellValue(r, c, "");
|
|||
|
|
renderCell(r, c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showCopyBox() {
|
|||
|
|
const box = document.getElementById("copy-box");
|
|||
|
|
if (!box) return;
|
|||
|
|
|
|||
|
|
const { start, end } = state.selection;
|
|||
|
|
const startCell = document.getElementById(`cell-${start.row}-${start.col}`);
|
|||
|
|
const endCell = document.getElementById(`cell-${end.row}-${end.col}`);
|
|||
|
|
|
|||
|
|
if (!startCell || !endCell || !elements.cellsContainer) return;
|
|||
|
|
|
|||
|
|
const containerRect = elements.cellsContainer.getBoundingClientRect();
|
|||
|
|
const startRect = startCell.getBoundingClientRect();
|
|||
|
|
const endRect = endCell.getBoundingClientRect();
|
|||
|
|
|
|||
|
|
box.style.left = startRect.left - containerRect.left + "px";
|
|||
|
|
box.style.top = startRect.top - containerRect.top + "px";
|
|||
|
|
box.style.width = endRect.right - startRect.left + "px";
|
|||
|
|
box.style.height = endRect.bottom - startRect.top + "px";
|
|||
|
|
box.classList.remove("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideCopyBox() {
|
|||
|
|
const box = document.getElementById("copy-box");
|
|||
|
|
if (box) box.classList.add("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// FORMATTING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function formatCells(format, value) {
|
|||
|
|
const { start, end } = state.selection;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
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: "", format: {} };
|
|||
|
|
}
|
|||
|
|
if (!ws.data[key].format) {
|
|||
|
|
ws.data[key].format = {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch (format) {
|
|||
|
|
case "bold":
|
|||
|
|
ws.data[key].format.bold = !ws.data[key].format.bold;
|
|||
|
|
break;
|
|||
|
|
case "italic":
|
|||
|
|
ws.data[key].format.italic = !ws.data[key].format.italic;
|
|||
|
|
break;
|
|||
|
|
case "underline":
|
|||
|
|
ws.data[key].format.underline = !ws.data[key].format.underline;
|
|||
|
|
break;
|
|||
|
|
case "strikethrough":
|
|||
|
|
ws.data[key].format.strikethrough =
|
|||
|
|
!ws.data[key].format.strikethrough;
|
|||
|
|
break;
|
|||
|
|
case "fontFamily":
|
|||
|
|
ws.data[key].format.fontFamily = value;
|
|||
|
|
break;
|
|||
|
|
case "fontSize":
|
|||
|
|
ws.data[key].format.fontSize = value;
|
|||
|
|
break;
|
|||
|
|
case "color":
|
|||
|
|
ws.data[key].format.color = value;
|
|||
|
|
break;
|
|||
|
|
case "backgroundColor":
|
|||
|
|
ws.data[key].format.backgroundColor = value;
|
|||
|
|
break;
|
|||
|
|
case "alignLeft":
|
|||
|
|
ws.data[key].format.textAlign = "left";
|
|||
|
|
break;
|
|||
|
|
case "alignCenter":
|
|||
|
|
ws.data[key].format.textAlign = "center";
|
|||
|
|
break;
|
|||
|
|
case "alignRight":
|
|||
|
|
ws.data[key].format.textAlign = "right";
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderCell(r, c);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
state.isDirty = true;
|
|||
|
|
scheduleAutoSave();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// HISTORY (UNDO/REDO)
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// CONTEXT MENU
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function handleContextMenu(e) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const menu = elements.contextMenu;
|
|||
|
|
if (!menu) return;
|
|||
|
|
|
|||
|
|
menu.style.left = e.clientX + "px";
|
|||
|
|
menu.style.top = e.clientY + "px";
|
|||
|
|
menu.classList.remove("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideContextMenus() {
|
|||
|
|
if (elements.contextMenu) elements.contextMenu.classList.add("hidden");
|
|||
|
|
if (elements.tabContextMenu)
|
|||
|
|
elements.tabContextMenu.classList.add("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// ROW/COLUMN OPERATIONS
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function insertRow() {
|
|||
|
|
const row = state.activeCell.row;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const newData = {};
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
for (const key in ws.data) {
|
|||
|
|
const [r, c] = key.split(",").map(Number);
|
|||
|
|
if (r >= row) {
|
|||
|
|
newData[`${r + 1},${c}`] = ws.data[key];
|
|||
|
|
} else {
|
|||
|
|
newData[key] = ws.data[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ws.data = newData;
|
|||
|
|
renderAllCells();
|
|||
|
|
state.isDirty = true;
|
|||
|
|
scheduleAutoSave();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function insertColumn() {
|
|||
|
|
const col = state.activeCell.col;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const newData = {};
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
for (const key in ws.data) {
|
|||
|
|
const [r, c] = key.split(",").map(Number);
|
|||
|
|
if (c >= col) {
|
|||
|
|
newData[`${r},${c + 1}`] = ws.data[key];
|
|||
|
|
} else {
|
|||
|
|
newData[key] = ws.data[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ws.data = newData;
|
|||
|
|
renderAllCells();
|
|||
|
|
state.isDirty = true;
|
|||
|
|
scheduleAutoSave();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteRow() {
|
|||
|
|
const row = state.activeCell.row;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const newData = {};
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
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() {
|
|||
|
|
const col = state.activeCell.col;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const newData = {};
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// SORTING
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function sortAscending() {
|
|||
|
|
sortSelection(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortDescending() {
|
|||
|
|
sortSelection(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortSelection(ascending) {
|
|||
|
|
const { start, end } = state.selection;
|
|||
|
|
const ws = state.worksheets[state.activeWorksheet];
|
|||
|
|
const rows = [];
|
|||
|
|
|
|||
|
|
saveToHistory();
|
|||
|
|
|
|||
|
|
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 || "";
|
|||
|
|
const valB = b.data[0]?.value || "";
|
|||
|
|
const numA = parseFloat(valA);
|
|||
|
|
const numB = parseFloat(valB);
|
|||
|
|
|
|||
|
|
if (!isNaN(numA) && !isNaN(numB)) {
|
|||
|
|
return ascending ? numA - numB : numB - numA;
|
|||
|
|
}
|
|||
|
|
return ascending ? valA.localeCompare(valB) : valB.localeCompare(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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// WORKSHEETS
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function switchWorksheet(index) {
|
|||
|
|
if (index < 0 || index >= state.worksheets.length) return;
|
|||
|
|
state.activeWorksheet = index;
|
|||
|
|
renderWorksheetTabs();
|
|||
|
|
renderAllCells();
|
|||
|
|
selectCell(0, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderWorksheetTabs() {
|
|||
|
|
if (!elements.worksheetTabs) return;
|
|||
|
|
|
|||
|
|
elements.worksheetTabs.innerHTML = state.worksheets
|
|||
|
|
.map(
|
|||
|
|
(ws, i) => `
|
|||
|
|
<div class="sheet-tab ${i === state.activeWorksheet ? "active" : ""}"
|
|||
|
|
onclick="window.gbSheet.switchWorksheet(${i})">
|
|||
|
|
${escapeHtml(ws.name)}
|
|||
|
|
<button class="tab-menu-btn" onclick="event.stopPropagation(); window.gbSheet.showTabMenu(event, ${i})">▼</button>
|
|||
|
|
</div>
|
|||
|
|
`,
|
|||
|
|
)
|
|||
|
|
.join("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showTabMenu(e, index) {
|
|||
|
|
if (!elements.tabContextMenu) return;
|
|||
|
|
elements.tabContextMenu.style.left = e.clientX + "px";
|
|||
|
|
elements.tabContextMenu.style.top = e.clientY + "px";
|
|||
|
|
elements.tabContextMenu.dataset.index = index;
|
|||
|
|
elements.tabContextMenu.classList.remove("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renameWorksheet(index, name) {
|
|||
|
|
if (index >= 0 && index < state.worksheets.length) {
|
|||
|
|
state.worksheets[index].name = name;
|
|||
|
|
renderWorksheetTabs();
|
|||
|
|
state.isDirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteWorksheet(index) {
|
|||
|
|
if (state.worksheets.length <= 1) return;
|
|||
|
|
state.worksheets.splice(index, 1);
|
|||
|
|
if (state.activeWorksheet >= state.worksheets.length) {
|
|||
|
|
state.activeWorksheet = state.worksheets.length - 1;
|
|||
|
|
}
|
|||
|
|
renderWorksheetTabs();
|
|||
|
|
renderAllCells();
|
|||
|
|
state.isDirty = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// ZOOM
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function zoomIn() {
|
|||
|
|
state.zoom = Math.min(2, state.zoom + 0.1);
|
|||
|
|
applyZoom();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoomOut() {
|
|||
|
|
state.zoom = Math.max(0.5, state.zoom - 0.1);
|
|||
|
|
applyZoom();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyZoom() {
|
|||
|
|
if (!elements.cells) return;
|
|||
|
|
elements.cells.style.transform = `scale(${state.zoom})`;
|
|||
|
|
elements.cells.style.transformOrigin = "top left";
|
|||
|
|
|
|||
|
|
const zoomDisplay = document.getElementById("zoom-level");
|
|||
|
|
if (zoomDisplay) {
|
|||
|
|
zoomDisplay.textContent = Math.round(state.zoom * 100) + "%";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// SIDEBAR
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function toggleSidebar() {
|
|||
|
|
if (elements.sidebar) {
|
|||
|
|
elements.sidebar.classList.toggle("collapsed");
|
|||
|
|
elements.sidebar.classList.toggle("open");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// MODALS
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function showModal(id) {
|
|||
|
|
const modal = document.getElementById(id);
|
|||
|
|
if (modal) modal.classList.remove("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideModal(id) {
|
|||
|
|
const modal = document.getElementById(id);
|
|||
|
|
if (modal) modal.classList.add("hidden");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showShareModal() {
|
|||
|
|
const link = document.getElementById("share-link");
|
|||
|
|
if (link) {
|
|||
|
|
link.value = window.location.href;
|
|||
|
|
}
|
|||
|
|
showModal("share-modal");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function copyShareLink() {
|
|||
|
|
const input = document.getElementById("share-link");
|
|||
|
|
if (input) {
|
|||
|
|
input.select();
|
|||
|
|
navigator.clipboard.writeText(input.value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// SAVE/LOAD
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function scheduleAutoSave() {
|
|||
|
|
if (state.autoSaveTimer) {
|
|||
|
|
clearTimeout(state.autoSaveTimer);
|
|||
|
|
}
|
|||
|
|
state.autoSaveTimer = setTimeout(() => {
|
|||
|
|
if (state.isDirty) {
|
|||
|
|
saveSheet();
|
|||
|
|
}
|
|||
|
|
}, CONFIG.AUTOSAVE_DELAY);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveSheet() {
|
|||
|
|
const btn = document.getElementById("save-btn");
|
|||
|
|
if (btn) btn.disabled = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const data = {
|
|||
|
|
name: state.sheetName,
|
|||
|
|
worksheets: state.worksheets,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const response = await fetch("/api/sheet/save", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(data),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
const result = await response.json();
|
|||
|
|
if (result.id) {
|
|||
|
|
state.sheetId = result.id;
|
|||
|
|
window.history.replaceState({}, "", `#id=${state.sheetId}`);
|
|||
|
|
}
|
|||
|
|
state.isDirty = false;
|
|||
|
|
showSaveStatus("saved");
|
|||
|
|
} else {
|
|||
|
|
showSaveStatus("error");
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Save failed:", e);
|
|||
|
|
showSaveStatus("error");
|
|||
|
|
} finally {
|
|||
|
|
if (btn) btn.disabled = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showSaveStatus(status) {
|
|||
|
|
const statusEl = document.getElementById("save-status");
|
|||
|
|
if (!statusEl) return;
|
|||
|
|
|
|||
|
|
statusEl.className = "save-status " + status;
|
|||
|
|
statusEl.textContent =
|
|||
|
|
status === "saved"
|
|||
|
|
? "Saved"
|
|||
|
|
: status === "error"
|
|||
|
|
? "Save failed"
|
|||
|
|
: "Saving...";
|
|||
|
|
|
|||
|
|
if (status === "saved") {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
statusEl.textContent = "";
|
|||
|
|
statusEl.className = "save-status";
|
|||
|
|
}, 2000);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadFromUrlParams() {
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|||
|
|
const hash = window.location.hash;
|
|||
|
|
let sheetId = urlParams.get("id");
|
|||
|
|
|
|||
|
|
if (!sheetId && hash) {
|
|||
|
|
const hashParams = new URLSearchParams(hash.substring(1));
|
|||
|
|
sheetId = hashParams.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();
|
|||
|
|
selectCell(0, 0);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Load failed:", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleBeforeUnload(e) {
|
|||
|
|
if (state.isDirty) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.returnValue = "";
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// WEBSOCKET (COLLABORATION)
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
state.ws.onerror = (error) => {
|
|||
|
|
console.error("WebSocket error:", error);
|
|||
|
|
};
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("WebSocket connection failed:", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleWebSocketMessage(msg) {
|
|||
|
|
switch (msg.type) {
|
|||
|
|
case "cellChange":
|
|||
|
|
if (msg.userId !== getUserId()) {
|
|||
|
|
setCellValue(msg.row, msg.col, msg.value);
|
|||
|
|
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(row, col, value) {
|
|||
|
|
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|||
|
|
state.ws.send(
|
|||
|
|
JSON.stringify({
|
|||
|
|
type: "cellChange",
|
|||
|
|
sheetId: state.sheetId,
|
|||
|
|
row,
|
|||
|
|
col,
|
|||
|
|
value,
|
|||
|
|
userId: getUserId(),
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateRemoteCursor(msg) {
|
|||
|
|
if (!elements.cursorIndicators) return;
|
|||
|
|
|
|||
|
|
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 || "#10b981";
|
|||
|
|
cursor.innerHTML = `<div class="cursor-label" style="background:${msg.color || "#10b981"}">${escapeHtml(msg.userName)}</div>`;
|
|||
|
|
elements.cursorIndicators.appendChild(cursor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cell = document.getElementById(`cell-${msg.row}-${msg.col}`);
|
|||
|
|
if (cell && elements.cellsContainer) {
|
|||
|
|
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);
|
|||
|
|
const cursor = document.getElementById(`cursor-${userId}`);
|
|||
|
|
if (cursor) cursor.remove();
|
|||
|
|
renderCollaborators();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderCollaborators() {
|
|||
|
|
if (!elements.collaborators) return;
|
|||
|
|
|
|||
|
|
elements.collaborators.innerHTML = state.collaborators
|
|||
|
|
.slice(0, 5)
|
|||
|
|
.map(
|
|||
|
|
(u) => `
|
|||
|
|
<div class="collaborator-avatar" style="background:${u.color || "#3b82f6"}" title="${escapeHtml(u.name)}">
|
|||
|
|
${u.name.charAt(0).toUpperCase()}
|
|||
|
|
</div>
|
|||
|
|
`,
|
|||
|
|
)
|
|||
|
|
.join("");
|
|||
|
|
|
|||
|
|
if (state.collaborators.length > 5) {
|
|||
|
|
elements.collaborators.innerHTML += `
|
|||
|
|
<div class="collaborator-avatar" style="background:#64748b">
|
|||
|
|
+${state.collaborators.length - 5}
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// UTILITIES
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
function getUserId() {
|
|||
|
|
let id = localStorage.getItem("sheet-user-id");
|
|||
|
|
if (!id) {
|
|||
|
|
id = "user-" + Math.random().toString(36).substr(2, 9);
|
|||
|
|
localStorage.setItem("sheet-user-id", id);
|
|||
|
|
}
|
|||
|
|
return id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getUserName() {
|
|||
|
|
return localStorage.getItem("sheet-user-name") || "Anonymous";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(str) {
|
|||
|
|
if (!str) return "";
|
|||
|
|
return String(str)
|
|||
|
|
.replace(/&/g, "&")
|
|||
|
|
.replace(/</g, "<")
|
|||
|
|
.replace(/>/g, ">")
|
|||
|
|
.replace(/"/g, """);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renameSheet(name) {
|
|||
|
|
state.sheetName = name;
|
|||
|
|
state.isDirty = true;
|
|||
|
|
scheduleAutoSave();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createNewSheet() {
|
|||
|
|
state.sheetId = null;
|
|||
|
|
state.sheetName = "Untitled Spreadsheet";
|
|||
|
|
state.worksheets = [{ name: "Sheet1", data: {} }];
|
|||
|
|
state.activeWorksheet = 0;
|
|||
|
|
state.history = [];
|
|||
|
|
state.historyIndex = -1;
|
|||
|
|
state.isDirty = false;
|
|||
|
|
|
|||
|
|
if (elements.sheetName) {
|
|||
|
|
elements.sheetName.value = state.sheetName;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.history.replaceState({}, "", window.location.pathname);
|
|||
|
|
renderWorksheetTabs();
|
|||
|
|
renderAllCells();
|
|||
|
|
selectCell(0, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// PUBLIC API
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
window.gbSheet = {
|
|||
|
|
init,
|
|||
|
|
toggleSidebar,
|
|||
|
|
createNewSheet,
|
|||
|
|
saveSheet,
|
|||
|
|
undo,
|
|||
|
|
redo,
|
|||
|
|
formatCells,
|
|||
|
|
insertRow,
|
|||
|
|
insertColumn,
|
|||
|
|
deleteRow,
|
|||
|
|
deleteColumn,
|
|||
|
|
sortAscending,
|
|||
|
|
sortDescending,
|
|||
|
|
addWorksheet,
|
|||
|
|
switchWorksheet,
|
|||
|
|
showTabMenu,
|
|||
|
|
renameWorksheet,
|
|||
|
|
deleteWorksheet,
|
|||
|
|
zoomIn,
|
|||
|
|
zoomOut,
|
|||
|
|
showModal,
|
|||
|
|
hideModal,
|
|||
|
|
showShareModal,
|
|||
|
|
copyShareLink,
|
|||
|
|
renameSheet,
|
|||
|
|
copySelection,
|
|||
|
|
cutSelection,
|
|||
|
|
pasteSelection,
|
|||
|
|
clearCells,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// INITIALIZE ON DOM READY
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
if (document.readyState === "loading") {
|
|||
|
|
document.addEventListener("DOMContentLoaded", init);
|
|||
|
|
} else {
|
|||
|
|
init();
|
|||
|
|
}
|
|||
|
|
})();
|