|
|
<!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> |