| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>SpreadMaster - Excel Clone</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/feather-icons"></script> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| colors: { |
| primary: { DEFAULT: '#4F46E5', 50: '#F5F5FF', 500: '#4F46E5' }, |
| secondary: { DEFAULT: '#10B981', 50: '#ECFDF5', 500: '#10B981' } |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| .cell { position: relative; outline: none; } |
| .cell:focus, .cell.selected { @apply ring-2 ring-primary-500 z-10; } |
| .grid-container { display: grid; position: relative; scrollbar-width: thin; } |
| .cell[data-formula="true"] { @apply font-mono bg-primary-50 text-primary-700; } |
| .header-row { position: sticky; top: 0; z-index: 20; background: white; } |
| .header-col { position: sticky; left: 0; z-index: 15; background: white; } |
| .corner-header { position: sticky; top: 0; left: 0; z-index: 30; background: #f3f4f6; } |
| .cell-resize-handle { |
| position: absolute; bottom: 0; right: 0; width: 6px; height: 6px; |
| background: #4F46E5; border: 1px solid white; cursor: se-resize; z-index: 5; |
| display: none; |
| } |
| .cell.selected .cell-resize-handle { display: block; } |
| .cell-content { |
| min-height: 100%; width: 100%; padding: 4px; outline: none; |
| background: transparent; resize: none; overflow: hidden; |
| } |
| .cell-content:focus { @apply bg-white; } |
| .header-row, .header-col, .corner-header { |
| @apply flex items-center justify-center font-medium text-gray-600 select-none border border-gray-300; |
| } |
| .formula-bar { background: white; border: 1px solid #e5e7eb; } |
| .function-list { |
| @apply absolute top-full left-0 bg-white border border-gray-300 rounded-lg shadow-lg mt-1 z-50 w-48 max-h-64 overflow-auto; |
| } |
| .function-item { @apply px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm; } |
| .function-item:hover { @apply bg-primary-50 text-primary-700; } |
| </style> |
| </head> |
| <body class="bg-gray-50 min-h-screen"> |
| <div class="container mx-auto px-4 py-8 max-w-7xl"> |
| |
| <div class="flex items-center justify-between mb-6"> |
| <div class="flex space-x-2"> |
| <button onclick="saveData()" class="flex items-center space-x-2 px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"> |
| <i data-feather="save"></i><span>Save</span> |
| </button> |
| <button onclick="addColumn()" class="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg"> |
| <i data-feather="plus"></i><span>Add Column(Cột)</span> |
| </button> |
| <button onclick="addRow()" class="flex items-center space-x-2 px-4 py-2 bg-secondary-500 hover:bg-secondary-600 text-white rounded-lg"> |
| <i data-feather="plus"></i><span>Add Row(Hàng)</span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="mb-4 bg-white rounded-lg shadow-sm border border-gray-200"> |
| <div class="flex items-center p-2 border-b border-gray-200"> |
| <div class="flex items-center space-x-2 flex-1"> |
| <span class="text-xs font-medium text-gray-500">fx</span> |
| <input type="text" id="cell-name" class="w-20 px-2 py-1 bg-gray-100 text-xs font-mono text-right border rounded" readonly> |
| <button id="function-btn" class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded" onclick="toggleFunctionList()"> |
| <i data-feather="chevron-down" class="w-3 h-3"></i> |
| </button> |
| </div> |
| <input type="text" id="formula-bar" class="formula-bar flex-1 px-4 py-2 outline-none font-mono mx-2" |
| placeholder="Enter formula or value" onkeydown="handleFormulaInput(event)" |
| onfocus="syncFormulaBarWithCell()"> |
| </div> |
| <div id="function-list" class="function-list hidden"> |
| <div class="px-3 py-2 border-b border-gray-200 font-medium text-sm">Common Functions</div> |
| <div class="function-item" onclick="insertFunction('SUM')">SUM(range)</div> |
| <div class="function-item" onclick="insertFunction('AVERAGE')">AVERAGE(range)</div> |
| <div class="function-item" onclick="insertFunction('COUNT')">COUNT(range)</div> |
| <div class="function-item" onclick="insertFunction('MAX')">MAX(range)</div> |
| <div class="function-item" onclick="insertFunction('MIN')">MIN(range)</div> |
| <div class="function-item" onclick="insertFunction('IF')">IF(condition, true, false)</div> |
| </div> |
| </div> |
|
|
| |
| <div class="relative"> |
| <div id="grid" class="grid-container bg-gray-200 border border-gray-200 rounded-lg overflow-auto shadow-sm max-h-[600px]"></div> |
| </div> |
|
|
| |
| <div class="mt-4 px-4 py-2 bg-white rounded-lg shadow-sm text-sm text-gray-500 flex justify-between"> |
| <div id="status-message">Ready</div> |
| </div> |
| </div> |
|
|
| <script> |
| let numRows = 20, numCols = 10; |
| const colLetters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; |
| let selectedCell = null, data = {}, isDragging = false, startCell = null, dragEndCell = null; |
| |
| const functions = { |
| SUM: (range) => getRangeValues(range).reduce((a, b) => a + (parseFloat(b) || 0), 0), |
| AVERAGE: (range) => { |
| const values = getRangeValues(range).filter(v => v !== '' && !isNaN(parseFloat(v))); |
| return values.length ? functions.SUM(range) / values.length : 0; |
| }, |
| COUNT: (range) => getRangeValues(range).filter(v => v !== '' && !isNaN(parseFloat(v))).length, |
| MAX: (range) => Math.max(...getRangeValues(range).map(v => parseFloat(v) || -Infinity)), |
| MIN: (range) => Math.min(...getRangeValues(range).map(v => parseFloat(v) || Infinity)), |
| IF: (condition, trueVal, falseVal) => evalCondition(condition) ? trueVal : falseVal |
| }; |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| loadData(); |
| initGrid(); |
| feather.replace(); |
| }); |
| |
| function initGrid() { |
| const grid = document.getElementById('grid'); |
| grid.innerHTML = ''; |
| grid.style.gridTemplateColumns = `50px repeat(${numCols}, minmax(120px, 1fr))`; |
| grid.style.gridTemplateRows = `40px repeat(${numRows}, 40px)`; |
| |
| |
| const corner = document.createElement('div'); |
| corner.className = 'corner-header bg-gray-100'; |
| corner.style.gridArea = '1 / 1'; |
| grid.appendChild(corner); |
| |
| |
| for (let col = 0; col < numCols; col++) { |
| const header = document.createElement('div'); |
| header.className = 'header-row bg-gray-100'; |
| header.textContent = colLetters[col]; |
| header.style.gridArea = '1 / ' + (col + 2); |
| header.onclick = () => selectColumn(col); |
| grid.appendChild(header); |
| } |
| |
| |
| for (let row = 1; row <= numRows; row++) { |
| |
| const rowHeader = document.createElement('div'); |
| rowHeader.className = 'header-col bg-gray-100'; |
| rowHeader.textContent = row; |
| rowHeader.style.gridArea = (row + 1) + ' / 1'; |
| rowHeader.onclick = () => selectRow(row); |
| grid.appendChild(rowHeader); |
| |
| |
| for (let col = 0; col < numCols; col++) { |
| const cellId = getCellId(col, row); |
| const cell = createCell(col, row, cellId); |
| grid.appendChild(cell); |
| } |
| } |
| } |
| |
| function createCell(col, row, cellId) { |
| const cell = document.createElement('div'); |
| cell.className = 'cell bg-white border border-gray-200 p-0 hover:bg-gray-50 cursor-cell'; |
| cell.dataset.col = col; cell.dataset.row = row; cell.tabIndex = 0; cell.id = cellId; |
| |
| const handle = document.createElement('div'); |
| handle.className = 'cell-resize-handle'; |
| handle.onmousedown = (e) => startAutoFill(e, col, row); |
| cell.appendChild(handle); |
| |
| const content = document.createElement('div'); |
| content.className = 'cell-content'; content.contentEditable = true; |
| content.oninput = () => updateCellValue(cell, col, row); |
| content.onfocus = () => selectCell(cell, col, row); |
| content.onkeydown = handleCellKeydown; |
| |
| |
| if (data[cellId]) { |
| content.textContent = data[cellId].displayValue || data[cellId].value; |
| if (data[cellId].formula) cell.dataset.formula = 'true'; |
| } |
| |
| cell.appendChild(content); |
| cell.style.gridArea = (row + 1) + ' / ' + (col + 2); |
| cell.onclick = (e) => { e.stopPropagation(); selectCell(cell, col, row); content.focus(); }; |
| return cell; |
| } |
| |
| function getCellId(col, row) { return colLetters[col] + row; } |
| function getCellValue(cellId) { return data[cellId]?.value || '0'; } |
| |
| function selectCell(cell, col, row) { |
| if (selectedCell) selectedCell.classList.remove('selected'); |
| selectedCell = cell; cell.classList.add('selected'); |
| document.getElementById('cell-name').value = getCellId(col, row); |
| syncFormulaBarWithCell(); |
| document.getElementById('status-message').textContent = `Editing ${getCellId(col, row)}`; |
| } |
| |
| function syncFormulaBarWithCell() { |
| if (!selectedCell) return; |
| const col = parseInt(selectedCell.dataset.col), row = parseInt(selectedCell.dataset.row); |
| const cellId = getCellId(col, row), formulaBar = document.getElementById('formula-bar'); |
| formulaBar.value = data[cellId]?.formula ? '=' + data[cellId].formula : (data[cellId]?.value || ''); |
| } |
| |
| function handleCellKeydown(e) { |
| const cell = e.target.parentElement, col = parseInt(cell.dataset.col), row = parseInt(cell.dataset.row); |
| if (e.key === 'F2') { e.preventDefault(); document.getElementById('formula-bar').focus(); return; } |
| if (['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(e.key)) { |
| e.preventDefault(); |
| let nextCol = col, nextRow = row; |
| if (e.key === 'ArrowRight' && col < numCols - 1) nextCol++; |
| else if (e.key === 'ArrowLeft' && col > 0) nextCol--; |
| else if (e.key === 'ArrowDown' && row < numRows) nextRow++; |
| else if (e.key === 'ArrowUp' && row > 1) nextRow--; |
| const nextCell = document.getElementById(getCellId(nextCol, nextRow)); |
| if (nextCell) nextCell.querySelector('.cell-content').focus(); |
| } |
| } |
| |
| function updateCellValue(cell, col, row) { |
| const content = cell.querySelector('.cell-content'), cellId = getCellId(col, row); |
| const value = content.textContent; |
| if (!data[cellId]) data[cellId] = { value: '', formula: '', displayValue: '' }; |
| data[cellId].value = value; data[cellId].displayValue = value; |
| } |
| |
| function handleFormulaInput(e) { |
| if (e.key !== 'Enter' || !selectedCell) return; |
| const formulaBar = e.target, input = formulaBar.value.trim(); |
| const content = selectedCell.querySelector('.cell-content'); |
| const col = parseInt(selectedCell.dataset.col), row = parseInt(selectedCell.dataset.row); |
| const cellId = getCellId(col, row); |
| |
| if (input.toLowerCase().startsWith('del')) { |
| handleDelete(input); formulaBar.value = ''; return; |
| } |
| |
| let formula = '', result = ''; |
| if (input.startsWith('=')) { |
| formula = input.slice(1); |
| result = evaluateFormula(formula, cellId); |
| content.textContent = result; |
| selectedCell.dataset.formula = 'true'; |
| data[cellId] = { value: result, formula, displayValue: result }; |
| recalculateDependents(cellId); |
| } else { |
| content.textContent = input; |
| if (selectedCell.dataset.formula) delete selectedCell.dataset.formula; |
| data[cellId] = { value: input, formula: '', displayValue: input }; |
| } |
| formulaBar.value = ''; content.focus(); |
| } |
| |
| function evaluateFormula(formula, currentCellId) { |
| try { |
| |
| formula = formula.replace(/[A-Z]\d+/g, (match) => { |
| const col = colLetters.indexOf(match[0]), row = parseInt(match.slice(1)); |
| const value = getCellValue(getCellId(col, row)); |
| return isNaN(value) ? `"${value}"` : value; |
| }); |
| |
| |
| const funcMatch = formula.match(/^([A-Z]+)\((.+)\)$/i); |
| if (funcMatch) { |
| const funcName = funcMatch[1].toUpperCase(), argsStr = funcMatch[2]; |
| const args = argsStr.split(',').map(arg => arg.trim().replace(/"/g, '')); |
| if (functions[funcName]) { |
| const firstArg = args[0]; |
| if (firstArg.includes(':')) { |
| return functions[funcName](firstArg); |
| } else { |
| return functions[funcName](...args.map(arg => isNaN(arg) ? arg : parseFloat(arg))); |
| } |
| } |
| } |
| |
| |
| return eval(formula); |
| } catch { |
| return '#ERROR!'; |
| } |
| } |
| |
| function getRangeValues(range) { |
| const [start, end] = range.split(':'); |
| const startCol = colLetters.indexOf(start[0]), startRow = parseInt(start.slice(1)); |
| const endCol = colLetters.indexOf(end[0]), endRow = parseInt(end.slice(1)); |
| const values = []; |
| for (let c = Math.min(startCol, endCol); c <= Math.max(startCol, endCol); c++) { |
| for (let r = Math.min(startRow, endRow); r <= Math.max(startRow, endRow); r++) { |
| values.push(getCellValue(getCellId(c, r))); |
| } |
| } |
| return values; |
| } |
| |
| function evalCondition(condition) { |
| try { return eval(condition); } catch { return false; } |
| } |
| |
| function recalculateDependents(cellId) { |
| Object.keys(data).forEach(id => { |
| if (data[id].formula && data[id].formula.includes(cellId)) { |
| const result = evaluateFormula(data[id].formula, id); |
| const cell = document.getElementById(id); |
| if (cell) { |
| cell.querySelector('.cell-content').textContent = result; |
| data[id].value = result; data[id].displayValue = result; |
| } |
| } |
| }); |
| } |
| |
| |
| function startAutoFill(e, col, row) { |
| e.preventDefault(); e.stopPropagation(); |
| isDragging = true; startCell = { col, row }; |
| document.body.style.cursor = 'se-resize'; |
| document.addEventListener('mousemove', handleAutoFillDrag); |
| document.addEventListener('mouseup', endAutoFill); |
| } |
| |
| function handleAutoFillDrag(e) { |
| if (!isDragging) return; |
| const grid = document.getElementById('grid'), rect = grid.getBoundingClientRect(); |
| const x = e.clientX - rect.left, y = e.clientY - rect.top; |
| let endCol = Math.floor((x - 50) / 120), endRow = Math.floor((y - 40) / 40) + 1; |
| endCol = Math.max(0, Math.min(endCol, numCols - 1)); |
| endRow = Math.max(1, Math.min(endRow, numRows)); |
| dragEndCell = { col: endCol, row: endRow }; |
| highlightRange(startCell.col, startCell.row, endCol, endRow); |
| } |
| |
| function endAutoFill() { |
| if (!isDragging || !startCell || !dragEndCell) return; |
| isDragging = false; document.body.style.cursor = 'default'; |
| performAutoFill(startCell.col, startCell.row, dragEndCell.col, dragEndCell.row); |
| clearHighlights(); |
| document.removeEventListener('mousemove', handleAutoFillDrag); |
| document.removeEventListener('mouseup', endAutoFill); |
| startCell = dragEndCell = null; |
| } |
| |
| function highlightRange(sCol, sRow, eCol, eRow) { |
| clearHighlights(); |
| const minC = Math.min(sCol, eCol), maxC = Math.max(sCol, eCol); |
| const minR = Math.min(sRow, eRow), maxR = Math.max(sRow, eRow); |
| for (let c = minC; c <= maxC; c++) for (let r = minR; r <= maxR; r++) { |
| const cell = document.getElementById(getCellId(c, r)); |
| if (cell) cell.classList.add('bg-blue-100'); |
| } |
| } |
| |
| function clearHighlights() { |
| document.querySelectorAll('.cell').forEach(cell => cell.classList.remove('bg-blue-100')); |
| } |
| |
| function performAutoFill(sCol, sRow, eCol, eRow) { |
| const startId = getCellId(sCol, sRow); |
| const startData = data[startId] || { value: '', formula: '' }; |
| const isFormula = !!startData.formula; |
| const deltaCol = eCol - sCol, deltaRow = eRow - sRow; |
| const dirCol = deltaCol !== 0 ? (deltaCol > 0 ? 1 : -1) : 0; |
| const dirRow = deltaRow !== 0 ? (deltaRow > 0 ? 1 : -1) : 0; |
| |
| let step = 1; |
| let currentCol = sCol + dirCol, currentRow = sRow + dirRow; |
| while (dirCol ? (dirCol > 0 ? currentCol <= eCol : currentCol >= eCol) : true && |
| dirRow ? (dirRow > 0 ? currentRow <= eRow : currentRow >= eRow) : true) { |
| const cellId = getCellId(currentCol, currentRow); |
| const cell = document.getElementById(cellId); |
| if (cell) { |
| let newValue, newFormula = ''; |
| if (isFormula) { |
| newFormula = adjustFormulaReferences(startData.formula, step * dirRow, step * dirCol); |
| newValue = evaluateFormula(newFormula, cellId); |
| } else { |
| const startValue = parseFloat(startData.value); |
| if (!isNaN(startValue)) { |
| newValue = startValue + step * (dirRow || dirCol); |
| } else { |
| newValue = startData.value; |
| } |
| } |
| cell.querySelector('.cell-content').textContent = newValue; |
| data[cellId] = { value: newValue, formula: newFormula, displayValue: newValue }; |
| if (newFormula) cell.dataset.formula = 'true'; |
| } |
| currentCol += dirCol; currentRow += dirRow; step++; |
| } |
| } |
| |
| function adjustFormulaReferences(formula, deltaRow, deltaCol) { |
| return formula.replace(/([A-Z])(\d+)/g, (match, colLetter, rowStr) => { |
| const col = colLetters.indexOf(colLetter); |
| const row = parseInt(rowStr); |
| const newCol = col + deltaCol; |
| const newRow = row + deltaRow; |
| return (newCol >= 0 ? colLetters[newCol] : colLetter) + newRow; |
| }); |
| } |
| |
| |
| function toggleFunctionList() { document.getElementById('function-list').classList.toggle('hidden'); } |
| function insertFunction(name) { |
| document.getElementById('formula-bar').value += `=${name}(`; |
| document.getElementById('formula-bar').focus(); |
| toggleFunctionList(); |
| } |
| |
| function handleDelete(input) { |
| const parts = input.toLowerCase().split(/\s+/); |
| if (parts.length === 1) { |
| selectedCell.querySelector('.cell-content').textContent = ''; |
| delete data[selectedCell.id]; delete selectedCell.dataset.formula; |
| } else if (parts.length === 2) { |
| const target = parts[1].toUpperCase(); |
| if (colLetters.includes(target[0])) deleteColumn(colLetters.indexOf(target[0])); |
| else if (!isNaN(target)) deleteRow(parseInt(target)); |
| } |
| } |
| |
| function deleteColumn(col) { |
| for (let r = 1; r <= numRows; r++) delete data[getCellId(col, r)]; |
| for (let c = col; c < numCols - 1; c++) for (let r = 1; r <= numRows; r++) { |
| const oldId = getCellId(c + 1, r), newId = getCellId(c, r); |
| if (data[oldId]) { data[newId] = data[oldId]; delete data[oldId]; } |
| } |
| numCols--; initGrid(); |
| } |
| |
| function deleteRow(row) { |
| for (let c = 0; c < numCols; c++) delete data[getCellId(c, row)]; |
| for (let r = row; r < numRows; r++) for (let c = 0; c < numCols; c++) { |
| const oldId = getCellId(c, r + 1), newId = getCellId(c, r); |
| if (data[oldId]) { data[newId] = data[oldId]; delete data[oldId]; } |
| } |
| numRows--; initGrid(); |
| } |
| |
| function addColumn() { numCols++; initGrid(); } |
| function addRow() { numRows++; initGrid(); } |
| function saveData() { localStorage.setItem('spreadmaster', JSON.stringify({data, numRows, numCols})); } |
| function loadData() { |
| const saved = localStorage.getItem('spreadmaster'); |
| if (saved) { |
| const {data: d, numRows: r, numCols: c} = JSON.parse(saved); |
| data = d || {}; numRows = r || 20; numCols = c || 10; |
| } |
| } |
| |
| |
| function selectColumn(col) { document.getElementById('status-message').textContent = `Column ${colLetters[col]}`; } |
| function selectRow(row) { document.getElementById('status-message').textContent = `Row ${row}`; } |
| </script> |
| <script>feather.replace();</script> |
| </body> |
| </html> |