2026-03-03 21:34:09 +00:00
|
|
|
import './style.css';
|
|
|
|
|
|
|
|
|
|
// ===== State =====
|
|
|
|
|
const state = {
|
|
|
|
|
headers: [],
|
|
|
|
|
rows: [],
|
|
|
|
|
filePath: '',
|
|
|
|
|
cursor: { row: 0, col: 0 },
|
|
|
|
|
selection: null, // { startRow, startCol, endRow, endCol }
|
2026-03-05 03:06:03 +00:00
|
|
|
selectedCells: null, // Set of "r,c" strings for non-contiguous selection (Match Cell)
|
2026-03-03 21:34:09 +00:00
|
|
|
colWidths: [], // pixel widths per column
|
|
|
|
|
isSelecting: false,
|
|
|
|
|
editingHeader: -1,
|
|
|
|
|
formulaBarFocused: false,
|
|
|
|
|
formulaBarFromTable: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== DOM refs =====
|
|
|
|
|
const $ = (sel) => document.querySelector(sel);
|
|
|
|
|
const tableHead = $('#table-head');
|
|
|
|
|
const tableBody = $('#table-body');
|
|
|
|
|
const formulaBar = $('#formula-bar');
|
|
|
|
|
const cellRef = $('#cell-ref');
|
|
|
|
|
const statusText = $('#status-text');
|
|
|
|
|
const commandPalette = $('#command-palette');
|
|
|
|
|
const commandInput = $('#command-input');
|
|
|
|
|
const commandList = $('#command-list');
|
|
|
|
|
const overlay = $('#overlay');
|
|
|
|
|
const tableContainer = $('#table-container');
|
2026-03-05 02:41:41 +00:00
|
|
|
const sortModal = $('#sort-modal');
|
|
|
|
|
const sortInput = $('#sort-columns-input');
|
|
|
|
|
const sortAutocomplete = $('#sort-autocomplete');
|
|
|
|
|
const sortConfirmBtn = $('#sort-confirm-btn');
|
|
|
|
|
const sortCancelBtn = $('#sort-cancel-btn');
|
2026-03-03 21:34:09 +00:00
|
|
|
|
|
|
|
|
// Default column width
|
|
|
|
|
const DEFAULT_COL_WIDTH = 120;
|
|
|
|
|
const MIN_COL_WIDTH = 40;
|
2026-03-04 00:32:41 +00:00
|
|
|
const MAX_BEST_FIT = 150;
|
2026-03-03 21:34:09 +00:00
|
|
|
|
|
|
|
|
// ===== Helpers =====
|
|
|
|
|
function colLabel(i) {
|
|
|
|
|
// A, B, C ... Z, AA, AB ...
|
|
|
|
|
let s = '';
|
|
|
|
|
let n = i;
|
|
|
|
|
do {
|
|
|
|
|
s = String.fromCharCode(65 + (n % 26)) + s;
|
|
|
|
|
n = Math.floor(n / 26) - 1;
|
|
|
|
|
} while (n >= 0);
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cellRefStr(r, c) {
|
|
|
|
|
return colLabel(c) + (r + 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 03:06:03 +00:00
|
|
|
function clearMatchSelection() {
|
|
|
|
|
state.selectedCells = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 21:34:09 +00:00
|
|
|
function normalizeSelection() {
|
|
|
|
|
if (!state.selection) return null;
|
|
|
|
|
const { startRow, startCol, endRow, endCol } = state.selection;
|
|
|
|
|
return {
|
|
|
|
|
r1: Math.min(startRow, endRow),
|
|
|
|
|
c1: Math.min(startCol, endCol),
|
|
|
|
|
r2: Math.max(startRow, endRow),
|
|
|
|
|
c2: Math.max(startCol, endCol),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCellSelected(r, c) {
|
2026-03-05 03:06:03 +00:00
|
|
|
if (state.selectedCells) return state.selectedCells.has(`${r},${c}`);
|
2026-03-03 21:34:09 +00:00
|
|
|
const sel = normalizeSelection();
|
|
|
|
|
if (!sel) return r === state.cursor.row && c === state.cursor.col;
|
|
|
|
|
return r >= sel.r1 && r <= sel.r2 && c >= sel.c1 && c <= sel.c2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCellValue(r, c) {
|
|
|
|
|
if (r < 0 || r >= state.rows.length) return '';
|
|
|
|
|
if (c < 0 || !state.rows[r] || c >= state.rows[r].length) return '';
|
|
|
|
|
return state.rows[r][c] || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setCellValue(r, c, val) {
|
|
|
|
|
// Ensure row exists
|
|
|
|
|
while (state.rows.length <= r) state.rows.push([]);
|
|
|
|
|
// Ensure columns exist
|
|
|
|
|
while (state.rows[r].length <= c) state.rows[r].push('');
|
|
|
|
|
state.rows[r][c] = val;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureGridSize() {
|
|
|
|
|
const numCols = state.headers.length;
|
|
|
|
|
for (let i = 0; i < state.rows.length; i++) {
|
|
|
|
|
while (state.rows[i].length < numCols) state.rows[i].push('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setStatus(msg) {
|
|
|
|
|
statusText.textContent = msg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Rendering =====
|
|
|
|
|
function render() {
|
|
|
|
|
renderHead();
|
|
|
|
|
renderBody();
|
|
|
|
|
updateFormulaBar();
|
|
|
|
|
updateCellRef();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderHead() {
|
|
|
|
|
tableHead.innerHTML = '';
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
|
|
|
|
// Row number header
|
|
|
|
|
const th0 = document.createElement('th');
|
|
|
|
|
th0.className = 'row-number-header';
|
|
|
|
|
th0.textContent = '';
|
|
|
|
|
tr.appendChild(th0);
|
|
|
|
|
|
|
|
|
|
state.headers.forEach((h, i) => {
|
|
|
|
|
const th = document.createElement('th');
|
|
|
|
|
th.style.width = (state.colWidths[i] || DEFAULT_COL_WIDTH) + 'px';
|
|
|
|
|
th.style.position = 'relative';
|
|
|
|
|
th.dataset.col = i;
|
|
|
|
|
|
|
|
|
|
const content = document.createElement('div');
|
|
|
|
|
content.className = 'header-content';
|
|
|
|
|
|
|
|
|
|
const textSpan = document.createElement('span');
|
|
|
|
|
textSpan.className = 'header-text';
|
|
|
|
|
textSpan.textContent = h;
|
|
|
|
|
content.appendChild(textSpan);
|
|
|
|
|
|
|
|
|
|
th.appendChild(content);
|
|
|
|
|
|
|
|
|
|
// Resize handle
|
|
|
|
|
const handle = document.createElement('div');
|
|
|
|
|
handle.className = 'col-resize-handle';
|
|
|
|
|
handle.addEventListener('mousedown', (e) => startColResize(e, i));
|
|
|
|
|
handle.addEventListener('dblclick', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
bestFitColumn(i);
|
|
|
|
|
});
|
|
|
|
|
th.appendChild(handle);
|
|
|
|
|
|
|
|
|
|
// Double click to edit header
|
|
|
|
|
th.addEventListener('dblclick', (e) => {
|
|
|
|
|
if (e.target.classList.contains('col-resize-handle')) return;
|
|
|
|
|
startHeaderEdit(i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tr.appendChild(th);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tableHead.appendChild(tr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderBody() {
|
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
|
const numCols = state.headers.length;
|
|
|
|
|
|
|
|
|
|
state.rows.forEach((row, r) => {
|
|
|
|
|
const tr = document.createElement('tr');
|
|
|
|
|
|
|
|
|
|
// Row number
|
|
|
|
|
const tdNum = document.createElement('td');
|
|
|
|
|
tdNum.className = 'row-number';
|
|
|
|
|
tdNum.textContent = r + 1;
|
|
|
|
|
tr.appendChild(tdNum);
|
|
|
|
|
|
|
|
|
|
for (let c = 0; c < numCols; c++) {
|
|
|
|
|
const td = document.createElement('td');
|
|
|
|
|
td.style.width = (state.colWidths[c] || DEFAULT_COL_WIDTH) + 'px';
|
|
|
|
|
td.textContent = row[c] || '';
|
|
|
|
|
td.dataset.row = r;
|
|
|
|
|
td.dataset.col = c;
|
|
|
|
|
|
|
|
|
|
if (isCellSelected(r, c)) {
|
|
|
|
|
td.classList.add('selected');
|
|
|
|
|
}
|
|
|
|
|
if (r === state.cursor.row && c === state.cursor.col) {
|
|
|
|
|
td.classList.add('cursor-cell');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tr.appendChild(td);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tableBody.appendChild(tr);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateSelectionClasses() {
|
|
|
|
|
const tds = tableBody.querySelectorAll('td[data-row]');
|
|
|
|
|
tds.forEach(td => {
|
|
|
|
|
const r = parseInt(td.dataset.row);
|
|
|
|
|
const c = parseInt(td.dataset.col);
|
|
|
|
|
td.classList.toggle('selected', isCellSelected(r, c));
|
|
|
|
|
td.classList.toggle('cursor-cell', r === state.cursor.row && c === state.cursor.col);
|
|
|
|
|
});
|
|
|
|
|
updateFormulaBar();
|
|
|
|
|
updateCellRef();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateFormulaBar() {
|
|
|
|
|
if (!state.formulaBarFocused) {
|
|
|
|
|
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateCellRef() {
|
|
|
|
|
if (state.headers.length > 0 && state.rows.length > 0) {
|
|
|
|
|
cellRef.textContent = cellRefStr(state.cursor.row, state.cursor.col);
|
|
|
|
|
} else {
|
|
|
|
|
cellRef.textContent = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scrollCursorIntoView() {
|
|
|
|
|
const td = tableBody.querySelector(
|
|
|
|
|
`td[data-row="${state.cursor.row}"][data-col="${state.cursor.col}"]`
|
|
|
|
|
);
|
|
|
|
|
if (td) {
|
|
|
|
|
td.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Header editing =====
|
|
|
|
|
function startHeaderEdit(colIndex) {
|
|
|
|
|
state.editingHeader = colIndex;
|
|
|
|
|
const ths = tableHead.querySelectorAll('th');
|
|
|
|
|
const th = ths[colIndex + 1]; // +1 for row number header
|
|
|
|
|
if (!th) return;
|
|
|
|
|
|
|
|
|
|
const content = th.querySelector('.header-content');
|
|
|
|
|
content.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const input = document.createElement('input');
|
|
|
|
|
input.type = 'text';
|
|
|
|
|
input.className = 'header-edit-input';
|
|
|
|
|
input.value = state.headers[colIndex];
|
|
|
|
|
content.appendChild(input);
|
|
|
|
|
input.focus();
|
|
|
|
|
input.select();
|
|
|
|
|
|
|
|
|
|
const finish = () => {
|
|
|
|
|
state.headers[colIndex] = input.value;
|
|
|
|
|
state.editingHeader = -1;
|
|
|
|
|
renderHead();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
input.addEventListener('blur', finish);
|
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter') { input.blur(); }
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
input.value = state.headers[colIndex]; // revert
|
|
|
|
|
input.blur();
|
|
|
|
|
}
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Column resizing =====
|
|
|
|
|
let resizeState = null;
|
|
|
|
|
|
|
|
|
|
function startColResize(e, colIndex) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const startX = e.clientX;
|
|
|
|
|
const startWidth = state.colWidths[colIndex] || DEFAULT_COL_WIDTH;
|
|
|
|
|
|
|
|
|
|
resizeState = { colIndex, startX, startWidth };
|
|
|
|
|
|
|
|
|
|
const handle = e.target;
|
|
|
|
|
handle.classList.add('active');
|
|
|
|
|
|
|
|
|
|
const onMove = (ev) => {
|
|
|
|
|
const diff = ev.clientX - startX;
|
|
|
|
|
const newWidth = Math.max(MIN_COL_WIDTH, startWidth + diff);
|
|
|
|
|
state.colWidths[colIndex] = newWidth;
|
|
|
|
|
|
|
|
|
|
// Update widths live
|
|
|
|
|
const ths = tableHead.querySelectorAll('th');
|
|
|
|
|
if (ths[colIndex + 1]) ths[colIndex + 1].style.width = newWidth + 'px';
|
|
|
|
|
|
|
|
|
|
const allTds = tableBody.querySelectorAll(`td[data-col="${colIndex}"]`);
|
|
|
|
|
allTds.forEach(td => td.style.width = newWidth + 'px');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onUp = () => {
|
|
|
|
|
handle.classList.remove('active');
|
|
|
|
|
document.removeEventListener('mousemove', onMove);
|
|
|
|
|
document.removeEventListener('mouseup', onUp);
|
|
|
|
|
resizeState = null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', onMove);
|
|
|
|
|
document.addEventListener('mouseup', onUp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bestFitColumn(colIndex) {
|
|
|
|
|
// Measure the widest content in this column
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
|
|
|
|
|
|
|
|
let maxW = ctx.measureText(state.headers[colIndex] || '').width;
|
|
|
|
|
|
|
|
|
|
for (const row of state.rows) {
|
|
|
|
|
const val = row[colIndex] || '';
|
|
|
|
|
const w = ctx.measureText(val).width;
|
|
|
|
|
if (w > maxW) maxW = w;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add padding
|
|
|
|
|
const fitted = Math.ceil(maxW) + 24; // 12px padding each side
|
|
|
|
|
const width = Math.max(fitted, MAX_BEST_FIT);
|
|
|
|
|
state.colWidths[colIndex] = width;
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bestFitAllColumns() {
|
|
|
|
|
for (let i = 0; i < state.headers.length; i++) {
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
|
|
|
|
|
|
|
|
let maxW = ctx.measureText(state.headers[i] || '').width;
|
|
|
|
|
for (const row of state.rows) {
|
|
|
|
|
const w = ctx.measureText(row[i] || '').width;
|
|
|
|
|
if (w > maxW) maxW = w;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fitted = Math.ceil(maxW) + 24;
|
|
|
|
|
state.colWidths[i] = Math.max(fitted, MAX_BEST_FIT);
|
|
|
|
|
}
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Cell click & drag selection =====
|
|
|
|
|
tableBody.addEventListener('mousedown', (e) => {
|
|
|
|
|
const td = e.target.closest('td[data-row]');
|
|
|
|
|
if (!td) return;
|
|
|
|
|
|
|
|
|
|
const r = parseInt(td.dataset.row);
|
|
|
|
|
const c = parseInt(td.dataset.col);
|
|
|
|
|
|
|
|
|
|
// Commit formula bar if it was focused
|
|
|
|
|
if (state.formulaBarFocused) {
|
|
|
|
|
commitFormulaBar();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 03:06:03 +00:00
|
|
|
clearMatchSelection();
|
|
|
|
|
|
2026-03-03 21:34:09 +00:00
|
|
|
if (e.shiftKey) {
|
|
|
|
|
// Extend selection
|
|
|
|
|
state.selection = {
|
|
|
|
|
startRow: state.cursor.row,
|
|
|
|
|
startCol: state.cursor.col,
|
|
|
|
|
endRow: r,
|
|
|
|
|
endCol: c
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
state.cursor = { row: r, col: c };
|
|
|
|
|
state.selection = { startRow: r, startCol: c, endRow: r, endCol: c };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.isSelecting = true;
|
|
|
|
|
updateSelectionClasses();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
|
|
|
if (!state.isSelecting) return;
|
|
|
|
|
const td = e.target.closest('td[data-row]');
|
|
|
|
|
if (!td) return;
|
|
|
|
|
|
|
|
|
|
const r = parseInt(td.dataset.row);
|
|
|
|
|
const c = parseInt(td.dataset.col);
|
|
|
|
|
|
|
|
|
|
if (state.selection) {
|
|
|
|
|
state.selection.endRow = r;
|
|
|
|
|
state.selection.endCol = c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSelectionClasses();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
|
|
|
state.isSelecting = false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== Keyboard navigation =====
|
|
|
|
|
function moveCursor(dr, dc, shift) {
|
2026-03-05 03:06:03 +00:00
|
|
|
clearMatchSelection();
|
2026-03-03 21:34:09 +00:00
|
|
|
if (shift) {
|
|
|
|
|
if (!state.selection) {
|
|
|
|
|
state.selection = {
|
|
|
|
|
startRow: state.cursor.row,
|
|
|
|
|
startCol: state.cursor.col,
|
|
|
|
|
endRow: state.cursor.row,
|
|
|
|
|
endCol: state.cursor.col
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const nr = Math.max(0, Math.min(state.rows.length - 1, state.selection.endRow + dr));
|
|
|
|
|
const nc = Math.max(0, Math.min(state.headers.length - 1, state.selection.endCol + dc));
|
|
|
|
|
state.selection.endRow = nr;
|
|
|
|
|
state.selection.endCol = nc;
|
|
|
|
|
} else {
|
|
|
|
|
const nr = Math.max(0, Math.min(state.rows.length - 1, state.cursor.row + dr));
|
|
|
|
|
const nc = Math.max(0, Math.min(state.headers.length - 1, state.cursor.col + dc));
|
|
|
|
|
state.cursor = { row: nr, col: nc };
|
|
|
|
|
state.selection = null;
|
|
|
|
|
}
|
|
|
|
|
updateSelectionClasses();
|
|
|
|
|
scrollCursorIntoView();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
// Command palette
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
openCommandPalette();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If command palette is open, don't handle other keys
|
|
|
|
|
if (!commandPalette.classList.contains('hidden')) return;
|
|
|
|
|
|
|
|
|
|
// If editing a header, don't interfere
|
|
|
|
|
if (state.editingHeader >= 0) return;
|
|
|
|
|
|
|
|
|
|
// If formula bar is focused, handle Enter/Escape
|
|
|
|
|
if (state.formulaBarFocused) {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
commitFormulaBar();
|
|
|
|
|
if (state.formulaBarFromTable) {
|
|
|
|
|
formulaBar.blur();
|
|
|
|
|
tableContainer.focus();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cancelFormulaBar();
|
|
|
|
|
formulaBar.blur();
|
|
|
|
|
tableContainer.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return; // Let normal typing happen in formula bar
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shortcuts
|
|
|
|
|
const meta = e.metaKey || e.ctrlKey;
|
|
|
|
|
|
|
|
|
|
if (meta && e.key === 's') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('save');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (meta && e.key === 'c') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('copy');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (meta && e.key === 'x') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('cut');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (meta && e.key === 'v') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('paste');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (meta && e.key === 'o') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('open');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-05 03:06:03 +00:00
|
|
|
if (meta && e.key === 'm') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('match-cell');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-03 21:34:09 +00:00
|
|
|
|
|
|
|
|
// Arrow keys
|
|
|
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); moveCursor(-1, 0, e.shiftKey); return; }
|
|
|
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); moveCursor(1, 0, e.shiftKey); return; }
|
|
|
|
|
if (e.key === 'ArrowLeft') { e.preventDefault(); moveCursor(0, -1, e.shiftKey); return; }
|
|
|
|
|
if (e.key === 'ArrowRight') { e.preventDefault(); moveCursor(0, 1, e.shiftKey); return; }
|
|
|
|
|
|
|
|
|
|
// Tab
|
|
|
|
|
if (e.key === 'Tab') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
moveCursor(0, e.shiftKey ? -1 : 1, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enter moves down
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
moveCursor(1, 0, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 02:54:32 +00:00
|
|
|
// Cmd+Backspace deletes rows
|
|
|
|
|
if (meta && e.key === 'Backspace') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
executeCommand('delete-row');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 21:34:09 +00:00
|
|
|
// Delete/Backspace clears selected cells
|
|
|
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
clearSelectedCells();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Escape clears selection
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
state.selection = null;
|
2026-03-05 03:06:03 +00:00
|
|
|
clearMatchSelection();
|
2026-03-03 21:34:09 +00:00
|
|
|
updateSelectionClasses();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Typing a printable character: focus formula bar and start editing
|
|
|
|
|
if (e.key.length === 1 && !meta && !e.altKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
formulaBar.value = '';
|
|
|
|
|
formulaBar.focus();
|
|
|
|
|
state.formulaBarFocused = true;
|
|
|
|
|
state.formulaBarFromTable = true;
|
|
|
|
|
// Insert the typed character
|
|
|
|
|
formulaBar.value = e.key;
|
|
|
|
|
// Move cursor to end
|
|
|
|
|
formulaBar.setSelectionRange(formulaBar.value.length, formulaBar.value.length);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== Formula bar =====
|
|
|
|
|
formulaBar.addEventListener('focus', () => {
|
|
|
|
|
state.formulaBarFocused = true;
|
|
|
|
|
// If not triggered by keyboard, this was a direct click
|
|
|
|
|
if (!state.formulaBarFromTable) {
|
|
|
|
|
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
formulaBar.addEventListener('blur', () => {
|
|
|
|
|
state.formulaBarFocused = false;
|
|
|
|
|
state.formulaBarFromTable = false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function commitFormulaBar() {
|
|
|
|
|
setCellValue(state.cursor.row, state.cursor.col, formulaBar.value);
|
|
|
|
|
state.formulaBarFocused = false;
|
|
|
|
|
state.formulaBarFromTable = false;
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelFormulaBar() {
|
|
|
|
|
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
|
|
|
|
|
state.formulaBarFocused = false;
|
|
|
|
|
state.formulaBarFromTable = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Selection data extraction =====
|
|
|
|
|
function getSelectedData() {
|
2026-03-05 03:06:03 +00:00
|
|
|
// Non-contiguous match selection: collect bounding box
|
|
|
|
|
if (state.selectedCells && state.selectedCells.size > 0) {
|
|
|
|
|
let minR = Infinity, maxR = -1, minC = Infinity, maxC = -1;
|
|
|
|
|
for (const key of state.selectedCells) {
|
|
|
|
|
const [r, c] = key.split(',').map(Number);
|
|
|
|
|
if (r < minR) minR = r;
|
|
|
|
|
if (r > maxR) maxR = r;
|
|
|
|
|
if (c < minC) minC = c;
|
|
|
|
|
if (c > maxC) maxC = c;
|
|
|
|
|
}
|
|
|
|
|
const headers = [];
|
|
|
|
|
for (let c = minC; c <= maxC; c++) headers.push(state.headers[c] || '');
|
|
|
|
|
const rows = [];
|
|
|
|
|
for (let r = minR; r <= maxR; r++) {
|
|
|
|
|
const row = [];
|
|
|
|
|
for (let c = minC; c <= maxC; c++) {
|
|
|
|
|
row.push(state.selectedCells.has(`${r},${c}`) ? getCellValue(r, c) : '');
|
|
|
|
|
}
|
|
|
|
|
rows.push(row);
|
|
|
|
|
}
|
|
|
|
|
return { headers, rows, isSingleCol: minC === maxC };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 21:34:09 +00:00
|
|
|
const sel = normalizeSelection();
|
|
|
|
|
if (!sel) {
|
|
|
|
|
return {
|
|
|
|
|
headers: [state.headers[state.cursor.col]],
|
|
|
|
|
rows: [[getCellValue(state.cursor.row, state.cursor.col)]],
|
|
|
|
|
isSingleCol: true
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const headers = [];
|
|
|
|
|
for (let c = sel.c1; c <= sel.c2; c++) {
|
|
|
|
|
headers.push(state.headers[c] || '');
|
|
|
|
|
}
|
|
|
|
|
const rows = [];
|
|
|
|
|
for (let r = sel.r1; r <= sel.r2; r++) {
|
|
|
|
|
const row = [];
|
|
|
|
|
for (let c = sel.c1; c <= sel.c2; c++) {
|
|
|
|
|
row.push(getCellValue(r, c));
|
|
|
|
|
}
|
|
|
|
|
rows.push(row);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
headers,
|
|
|
|
|
rows,
|
|
|
|
|
isSingleCol: sel.c1 === sel.c2
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearSelectedCells() {
|
2026-03-05 03:06:03 +00:00
|
|
|
if (state.selectedCells && state.selectedCells.size > 0) {
|
|
|
|
|
for (const key of state.selectedCells) {
|
|
|
|
|
const [r, c] = key.split(',').map(Number);
|
|
|
|
|
setCellValue(r, c, '');
|
|
|
|
|
}
|
|
|
|
|
render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-03 21:34:09 +00:00
|
|
|
const sel = normalizeSelection();
|
|
|
|
|
if (!sel) {
|
|
|
|
|
setCellValue(state.cursor.row, state.cursor.col, '');
|
|
|
|
|
} else {
|
|
|
|
|
for (let r = sel.r1; r <= sel.r2; r++) {
|
|
|
|
|
for (let c = sel.c1; c <= sel.c2; c++) {
|
|
|
|
|
setCellValue(r, c, '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Commands =====
|
|
|
|
|
let commands = [];
|
|
|
|
|
|
|
|
|
|
async function loadCommands() {
|
|
|
|
|
try {
|
|
|
|
|
commands = await window.go.main.CommandRegistry.GetCommands();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to load commands:', e);
|
|
|
|
|
// Fallback commands
|
|
|
|
|
commands = [
|
|
|
|
|
{ ID: 'copy', Name: 'Copy', Shortcut: 'Cmd+C' },
|
|
|
|
|
{ ID: 'cut', Name: 'Cut', Shortcut: 'Cmd+X' },
|
|
|
|
|
{ ID: 'copy-markdown', Name: 'Copy as Markdown', Shortcut: '' },
|
|
|
|
|
{ ID: 'copy-jira', Name: 'Copy as Jira', Shortcut: '' },
|
|
|
|
|
{ ID: 'paste', Name: 'Paste', Shortcut: 'Cmd+V' },
|
|
|
|
|
{ ID: 'resize-all', Name: 'Resize All Columns', Shortcut: '' },
|
|
|
|
|
{ ID: 'open', Name: 'Open File', Shortcut: 'Cmd+O' },
|
|
|
|
|
{ ID: 'save', Name: 'Save File', Shortcut: 'Cmd+S' },
|
|
|
|
|
{ ID: 'open-up', Name: 'Insert Row Above', Shortcut: '' },
|
|
|
|
|
{ ID: 'open-down', Name: 'Insert Row Below', Shortcut: '' },
|
|
|
|
|
{ ID: 'open-left', Name: 'Insert Column Left', Shortcut: '' },
|
|
|
|
|
{ ID: 'open-right', Name: 'Insert Column Right', Shortcut: '' },
|
2026-03-04 22:21:49 +00:00
|
|
|
{ ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' },
|
|
|
|
|
{ ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' },
|
2026-03-05 02:41:41 +00:00
|
|
|
{ ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' },
|
2026-03-05 03:06:03 +00:00
|
|
|
{ ID: 'match-cell', Name: 'Match Cell', Shortcut: 'Cmd+M' },
|
2026-03-05 02:54:32 +00:00
|
|
|
{ ID: 'delete-row', Name: 'Delete Row', Shortcut: 'Cmd+Backspace' },
|
2026-03-03 21:34:09 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCommandPalette() {
|
|
|
|
|
commandPalette.classList.remove('hidden');
|
|
|
|
|
overlay.classList.remove('hidden');
|
|
|
|
|
commandInput.value = '';
|
|
|
|
|
renderCommandList('');
|
|
|
|
|
commandInput.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeCommandPalette() {
|
|
|
|
|
commandPalette.classList.add('hidden');
|
|
|
|
|
overlay.classList.add('hidden');
|
|
|
|
|
commandInput.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let activeCommandIndex = 0;
|
|
|
|
|
|
|
|
|
|
function renderCommandList(filter) {
|
|
|
|
|
commandList.innerHTML = '';
|
|
|
|
|
const lower = filter.toLowerCase();
|
2026-03-04 00:32:41 +00:00
|
|
|
const filtered = commands
|
|
|
|
|
.filter(c => c.Name.toLowerCase().includes(lower))
|
|
|
|
|
.sort((a, b) => a.Name.toLowerCase().localeCompare(b.Name.toLowerCase()));
|
2026-03-03 21:34:09 +00:00
|
|
|
|
|
|
|
|
activeCommandIndex = 0;
|
|
|
|
|
|
|
|
|
|
filtered.forEach((cmd, i) => {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = 'command-item' + (i === 0 ? ' active' : '');
|
|
|
|
|
div.innerHTML = `<span class="cmd-name">${cmd.Name}</span>` +
|
|
|
|
|
(cmd.Shortcut ? `<span class="cmd-shortcut">${cmd.Shortcut}</span>` : '');
|
|
|
|
|
div.addEventListener('click', () => {
|
|
|
|
|
closeCommandPalette();
|
|
|
|
|
executeCommand(cmd.ID);
|
|
|
|
|
});
|
|
|
|
|
commandList.appendChild(div);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
commandInput.addEventListener('input', () => {
|
|
|
|
|
renderCommandList(commandInput.value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
commandInput.addEventListener('keydown', (e) => {
|
|
|
|
|
const items = commandList.querySelectorAll('.command-item');
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
closeCommandPalette();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (items.length === 0) return;
|
|
|
|
|
items[activeCommandIndex]?.classList.remove('active');
|
|
|
|
|
activeCommandIndex = (activeCommandIndex + 1) % items.length;
|
|
|
|
|
items[activeCommandIndex]?.classList.add('active');
|
|
|
|
|
items[activeCommandIndex]?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (items.length === 0) return;
|
|
|
|
|
items[activeCommandIndex]?.classList.remove('active');
|
|
|
|
|
activeCommandIndex = (activeCommandIndex - 1 + items.length) % items.length;
|
|
|
|
|
items[activeCommandIndex]?.classList.add('active');
|
|
|
|
|
items[activeCommandIndex]?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const items = commandList.querySelectorAll('.command-item');
|
|
|
|
|
if (items[activeCommandIndex]) {
|
|
|
|
|
items[activeCommandIndex].click();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-05 02:41:41 +00:00
|
|
|
overlay.addEventListener('click', () => {
|
|
|
|
|
if (!commandPalette.classList.contains('hidden')) closeCommandPalette();
|
|
|
|
|
if (!sortModal.classList.contains('hidden')) closeSortAdvanced();
|
|
|
|
|
});
|
2026-03-03 21:34:09 +00:00
|
|
|
|
|
|
|
|
// ===== Command execution =====
|
|
|
|
|
async function executeCommand(id) {
|
|
|
|
|
switch (id) {
|
|
|
|
|
case 'copy': await doCopy(); break;
|
|
|
|
|
case 'cut': await doCut(); break;
|
|
|
|
|
case 'copy-markdown': await doCopyMarkdown(); break;
|
|
|
|
|
case 'copy-jira': await doCopyJira(); break;
|
|
|
|
|
case 'paste': await doPaste(); break;
|
|
|
|
|
case 'resize-all': bestFitAllColumns(); setStatus('All columns resized'); break;
|
|
|
|
|
case 'open': await doOpen(); break;
|
|
|
|
|
case 'save': await doSave(); break;
|
|
|
|
|
case 'open-up': doInsertRowAbove(); break;
|
|
|
|
|
case 'open-down': doInsertRowBelow(); break;
|
|
|
|
|
case 'open-left': doInsertColLeft(); break;
|
|
|
|
|
case 'open-right': doInsertColRight(); break;
|
2026-03-04 22:21:49 +00:00
|
|
|
case 'sort-asc': doSort(true); break;
|
|
|
|
|
case 'sort-desc': doSort(false); break;
|
2026-03-05 02:41:41 +00:00
|
|
|
case 'sort-advanced': openSortAdvanced(); break;
|
2026-03-05 03:06:03 +00:00
|
|
|
case 'match-cell': doMatchCell(); break;
|
2026-03-05 02:54:32 +00:00
|
|
|
case 'delete-row': doDeleteRow(); break;
|
2026-03-03 21:34:09 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doCopy() {
|
|
|
|
|
const data = getSelectedData();
|
|
|
|
|
let text;
|
|
|
|
|
if (data.isSingleCol) {
|
|
|
|
|
try {
|
|
|
|
|
text = await window.go.main.App.FormatAsSingleColumn(data.rows);
|
|
|
|
|
} catch {
|
|
|
|
|
text = data.rows.map(r => r[0]).join('\n');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
text = await window.go.main.App.FormatRowsAsCSV(data.rows);
|
|
|
|
|
} catch {
|
|
|
|
|
text = data.rows.map(r => r.join(',')).join('\n');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
setStatus('Copied to clipboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doCut() {
|
|
|
|
|
await doCopy();
|
|
|
|
|
clearSelectedCells();
|
|
|
|
|
setStatus('Cut to clipboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doCopyMarkdown() {
|
|
|
|
|
const data = getSelectedData();
|
|
|
|
|
let text;
|
|
|
|
|
try {
|
|
|
|
|
text = await window.go.main.App.FormatAsMarkdown(data.headers, data.rows);
|
|
|
|
|
} catch {
|
|
|
|
|
text = '(Markdown format unavailable)';
|
|
|
|
|
}
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
setStatus('Copied as Markdown');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doCopyJira() {
|
|
|
|
|
const data = getSelectedData();
|
|
|
|
|
let text;
|
|
|
|
|
try {
|
|
|
|
|
text = await window.go.main.App.FormatAsJira(data.headers, data.rows);
|
|
|
|
|
} catch {
|
|
|
|
|
text = '(Jira format unavailable)';
|
|
|
|
|
}
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
setStatus('Copied as Jira');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doPaste() {
|
|
|
|
|
let text;
|
|
|
|
|
try {
|
|
|
|
|
text = await navigator.clipboard.readText();
|
|
|
|
|
} catch {
|
|
|
|
|
setStatus('Failed to read clipboard');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!text || !text.trim()) {
|
|
|
|
|
setStatus('Clipboard is empty');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lines = text.trim().split('\n');
|
|
|
|
|
|
|
|
|
|
// Check if it looks like CSV: multi-line, consistent comma-separated columns
|
|
|
|
|
let isCSV = false;
|
|
|
|
|
if (lines.length > 1) {
|
|
|
|
|
// Count commas outside quotes for each line
|
|
|
|
|
const colCounts = lines.map(line => {
|
|
|
|
|
let count = 1;
|
|
|
|
|
let inQuote = false;
|
|
|
|
|
for (const ch of line) {
|
|
|
|
|
if (ch === '"') inQuote = !inQuote;
|
|
|
|
|
else if (ch === ',' && !inQuote) count++;
|
|
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
});
|
|
|
|
|
// Check if all lines have the same column count and > 1
|
|
|
|
|
const first = colCounts[0];
|
|
|
|
|
if (first > 1 && colCounts.every(c => c === first)) {
|
|
|
|
|
// Also check no spaces before commas (heuristic for CSV-like)
|
|
|
|
|
isCSV = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let pasteRows;
|
|
|
|
|
if (isCSV) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = await window.go.main.App.ParseCSVString(text);
|
|
|
|
|
// Combine headers and rows since we're pasting into cells
|
|
|
|
|
pasteRows = [parsed.Headers, ...parsed.Rows];
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback: split by comma
|
|
|
|
|
pasteRows = lines.map(l => l.split(','));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Each line is a single-column row
|
|
|
|
|
pasteRows = lines.map(l => [l]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Place at cursor position
|
|
|
|
|
const startRow = state.cursor.row;
|
|
|
|
|
const startCol = state.cursor.col;
|
|
|
|
|
|
|
|
|
|
for (let r = 0; r < pasteRows.length; r++) {
|
|
|
|
|
for (let c = 0; c < pasteRows[r].length; c++) {
|
|
|
|
|
const targetRow = startRow + r;
|
|
|
|
|
const targetCol = startCol + c;
|
|
|
|
|
// Extend grid if needed
|
|
|
|
|
while (state.rows.length <= targetRow) state.rows.push(new Array(state.headers.length).fill(''));
|
|
|
|
|
while (state.headers.length <= targetCol) {
|
|
|
|
|
state.headers.push(colLabel(state.headers.length));
|
|
|
|
|
state.colWidths.push(DEFAULT_COL_WIDTH);
|
|
|
|
|
for (const row of state.rows) row.push('');
|
|
|
|
|
}
|
|
|
|
|
setCellValue(targetRow, targetCol, pasteRows[r][c]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render();
|
|
|
|
|
setStatus(`Pasted ${pasteRows.length} rows`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doOpen() {
|
|
|
|
|
let filePath;
|
|
|
|
|
try {
|
|
|
|
|
filePath = await window.go.main.App.OpenFileDialog();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus('Open cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
setStatus('Open cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await loadFile(filePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doSave() {
|
|
|
|
|
if (state.filePath) {
|
|
|
|
|
try {
|
|
|
|
|
await window.go.main.App.SaveCurrentFile(state.headers, state.rows);
|
|
|
|
|
setStatus('Saved: ' + state.filePath);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus('Error saving: ' + e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let filePath;
|
|
|
|
|
try {
|
|
|
|
|
filePath = await window.go.main.App.SaveFileDialog();
|
|
|
|
|
} catch {
|
|
|
|
|
setStatus('Save cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
setStatus('Save cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await window.go.main.App.SaveCSV(filePath, state.headers, state.rows);
|
|
|
|
|
state.filePath = filePath;
|
|
|
|
|
setStatus('Saved: ' + filePath);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus('Error saving: ' + e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doInsertRowAbove() {
|
|
|
|
|
const r = state.cursor.row;
|
|
|
|
|
const newRow = new Array(state.headers.length).fill('');
|
|
|
|
|
state.rows.splice(r, 0, newRow);
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Inserted row above');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doInsertRowBelow() {
|
|
|
|
|
const r = state.cursor.row;
|
|
|
|
|
const newRow = new Array(state.headers.length).fill('');
|
|
|
|
|
state.rows.splice(r + 1, 0, newRow);
|
|
|
|
|
state.cursor.row = r + 1;
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Inserted row below');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doInsertColLeft() {
|
|
|
|
|
const c = state.cursor.col;
|
|
|
|
|
state.headers.splice(c, 0, colLabel(state.headers.length));
|
|
|
|
|
state.colWidths.splice(c, 0, DEFAULT_COL_WIDTH);
|
|
|
|
|
for (const row of state.rows) {
|
|
|
|
|
row.splice(c, 0, '');
|
|
|
|
|
}
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Inserted column left');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doInsertColRight() {
|
|
|
|
|
const c = state.cursor.col + 1;
|
|
|
|
|
state.headers.splice(c, 0, colLabel(state.headers.length));
|
|
|
|
|
state.colWidths.splice(c, 0, DEFAULT_COL_WIDTH);
|
|
|
|
|
for (const row of state.rows) {
|
|
|
|
|
row.splice(c, 0, '');
|
|
|
|
|
}
|
|
|
|
|
state.cursor.col = c;
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Inserted column right');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 02:54:32 +00:00
|
|
|
// ===== Match & Delete Row =====
|
2026-03-05 03:06:03 +00:00
|
|
|
function doMatchCell() {
|
|
|
|
|
const val = getCellValue(state.cursor.row, state.cursor.col);
|
|
|
|
|
const matched = new Set();
|
2026-03-05 02:54:32 +00:00
|
|
|
for (let r = 0; r < state.rows.length; r++) {
|
2026-03-05 03:06:03 +00:00
|
|
|
for (let c = 0; c < state.headers.length; c++) {
|
|
|
|
|
if ((state.rows[r][c] || '') === val) {
|
|
|
|
|
matched.add(`${r},${c}`);
|
|
|
|
|
}
|
2026-03-05 02:54:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 03:06:03 +00:00
|
|
|
if (matched.size === 0) {
|
|
|
|
|
setStatus('No matching cells');
|
2026-03-05 02:54:32 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-05 03:06:03 +00:00
|
|
|
state.selection = null;
|
|
|
|
|
state.selectedCells = matched;
|
2026-03-05 02:54:32 +00:00
|
|
|
updateSelectionClasses();
|
2026-03-05 03:06:03 +00:00
|
|
|
setStatus(`${matched.size} cell(s) matching "${val}"`);
|
2026-03-05 02:54:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function doDeleteRow() {
|
|
|
|
|
const sel = normalizeSelection();
|
|
|
|
|
let startRow, endRow;
|
|
|
|
|
if (sel) {
|
|
|
|
|
startRow = sel.r1;
|
|
|
|
|
endRow = sel.r2;
|
|
|
|
|
} else {
|
|
|
|
|
startRow = state.cursor.row;
|
|
|
|
|
endRow = state.cursor.row;
|
|
|
|
|
}
|
|
|
|
|
const count = endRow - startRow + 1;
|
|
|
|
|
state.rows.splice(startRow, count);
|
|
|
|
|
// Ensure at least one row remains
|
|
|
|
|
if (state.rows.length === 0) {
|
|
|
|
|
state.rows.push(new Array(state.headers.length).fill(''));
|
|
|
|
|
}
|
|
|
|
|
// Adjust cursor
|
|
|
|
|
state.cursor.row = Math.min(startRow, state.rows.length - 1);
|
|
|
|
|
state.selection = null;
|
|
|
|
|
render();
|
|
|
|
|
setStatus(`Deleted ${count} row(s)`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 22:21:49 +00:00
|
|
|
// ===== Sorting =====
|
|
|
|
|
function doSort(ascending) {
|
|
|
|
|
const col = state.cursor.col;
|
|
|
|
|
state.rows.sort((a, b) => {
|
|
|
|
|
const va = (a[col] || '').toLowerCase();
|
|
|
|
|
const vb = (b[col] || '').toLowerCase();
|
|
|
|
|
return ascending ? va.localeCompare(vb, undefined, { numeric: true })
|
|
|
|
|
: vb.localeCompare(va, undefined, { numeric: true });
|
|
|
|
|
});
|
|
|
|
|
render();
|
|
|
|
|
setStatus(`Sorted by ${state.headers[col] || colLabel(col)} ${ascending ? 'A-Z' : 'Z-A'}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 02:41:41 +00:00
|
|
|
// ===== Sort Advanced Modal =====
|
|
|
|
|
let acIndex = -1;
|
|
|
|
|
|
|
|
|
|
function openSortAdvanced() {
|
|
|
|
|
sortModal.classList.remove('hidden');
|
|
|
|
|
overlay.classList.remove('hidden');
|
|
|
|
|
sortInput.value = '';
|
|
|
|
|
hideSortAutocomplete();
|
|
|
|
|
sortInput.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeSortAdvanced() {
|
|
|
|
|
sortModal.classList.add('hidden');
|
|
|
|
|
overlay.classList.add('hidden');
|
|
|
|
|
sortInput.value = '';
|
|
|
|
|
hideSortAutocomplete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideSortAutocomplete() {
|
|
|
|
|
sortAutocomplete.classList.add('hidden');
|
|
|
|
|
sortAutocomplete.innerHTML = '';
|
|
|
|
|
acIndex = -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCurrentToken() {
|
|
|
|
|
const val = sortInput.value;
|
|
|
|
|
const cursor = sortInput.selectionStart;
|
|
|
|
|
const before = val.substring(0, cursor);
|
|
|
|
|
const lastComma = before.lastIndexOf(',');
|
|
|
|
|
return {
|
|
|
|
|
text: before.substring(lastComma + 1).trim(),
|
|
|
|
|
start: lastComma + 1,
|
|
|
|
|
cursorPos: cursor,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getAlreadyChosen() {
|
|
|
|
|
const parts = sortInput.value.split(',');
|
|
|
|
|
const cursor = sortInput.selectionStart;
|
|
|
|
|
const before = sortInput.value.substring(0, cursor);
|
|
|
|
|
const tokenIdx = before.split(',').length - 1;
|
|
|
|
|
return parts
|
|
|
|
|
.map(p => p.trim().toLowerCase())
|
|
|
|
|
.filter((_, i) => i !== tokenIdx)
|
|
|
|
|
.filter(p => p.length > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateSortAutocomplete() {
|
|
|
|
|
const { text } = getCurrentToken();
|
|
|
|
|
const lower = text.toLowerCase();
|
|
|
|
|
const chosen = getAlreadyChosen();
|
|
|
|
|
|
|
|
|
|
const matches = state.headers
|
|
|
|
|
.filter(h => h.toLowerCase().includes(lower))
|
|
|
|
|
.filter(h => !chosen.includes(h.toLowerCase()))
|
|
|
|
|
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
|
|
|
|
|
|
|
|
if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === lower)) {
|
|
|
|
|
hideSortAutocomplete();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sortAutocomplete.innerHTML = '';
|
|
|
|
|
sortAutocomplete.classList.remove('hidden');
|
|
|
|
|
acIndex = -1;
|
|
|
|
|
|
|
|
|
|
matches.forEach((name, i) => {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = 'ac-item';
|
|
|
|
|
div.textContent = name;
|
|
|
|
|
div.addEventListener('mousedown', (e) => {
|
|
|
|
|
e.preventDefault(); // keep focus on input
|
|
|
|
|
applySortCompletion(name);
|
|
|
|
|
});
|
|
|
|
|
sortAutocomplete.appendChild(div);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySortCompletion(name) {
|
|
|
|
|
const val = sortInput.value;
|
|
|
|
|
const cursor = sortInput.selectionStart;
|
|
|
|
|
const before = val.substring(0, cursor);
|
|
|
|
|
const after = val.substring(cursor);
|
|
|
|
|
const lastComma = before.lastIndexOf(',');
|
|
|
|
|
const prefix = before.substring(0, lastComma + 1);
|
|
|
|
|
const spacing = lastComma >= 0 ? ' ' : '';
|
|
|
|
|
const newVal = prefix + spacing + name + after;
|
|
|
|
|
sortInput.value = newVal;
|
|
|
|
|
const newCursor = (prefix + spacing + name).length;
|
|
|
|
|
sortInput.setSelectionRange(newCursor, newCursor);
|
|
|
|
|
hideSortAutocomplete();
|
|
|
|
|
sortInput.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function confirmSortAdvanced() {
|
|
|
|
|
const names = sortInput.value
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(s => s.trim())
|
|
|
|
|
.filter(s => s.length > 0);
|
|
|
|
|
|
|
|
|
|
const colIndices = [];
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
const idx = state.headers.findIndex(
|
|
|
|
|
h => h.toLowerCase() === name.toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
if (idx === -1) {
|
|
|
|
|
setStatus(`Unknown column: "${name}"`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
colIndices.push(idx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (colIndices.length === 0) {
|
|
|
|
|
setStatus('No columns specified');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.rows.sort((a, b) => {
|
|
|
|
|
for (const col of colIndices) {
|
|
|
|
|
const va = (a[col] || '').toLowerCase();
|
|
|
|
|
const vb = (b[col] || '').toLowerCase();
|
|
|
|
|
const cmp = va.localeCompare(vb, undefined, { numeric: true });
|
|
|
|
|
if (cmp !== 0) return cmp;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
closeSortAdvanced();
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Sorted by ' + names.join(', '));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sortInput.addEventListener('input', updateSortAutocomplete);
|
|
|
|
|
sortInput.addEventListener('focus', updateSortAutocomplete);
|
|
|
|
|
sortInput.addEventListener('blur', () => {
|
|
|
|
|
// Small delay to allow mousedown on ac-item to fire
|
|
|
|
|
setTimeout(hideSortAutocomplete, 150);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sortInput.addEventListener('keydown', (e) => {
|
|
|
|
|
const items = sortAutocomplete.querySelectorAll('.ac-item');
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (!sortAutocomplete.classList.contains('hidden')) {
|
|
|
|
|
hideSortAutocomplete();
|
|
|
|
|
} else {
|
|
|
|
|
closeSortAdvanced();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (acIndex >= 0 && items[acIndex]) {
|
|
|
|
|
applySortCompletion(items[acIndex].textContent);
|
|
|
|
|
} else {
|
|
|
|
|
confirmSortAdvanced();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowDown' && items.length > 0) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (acIndex >= 0) items[acIndex]?.classList.remove('active');
|
|
|
|
|
acIndex = (acIndex + 1) % items.length;
|
|
|
|
|
items[acIndex]?.classList.add('active');
|
|
|
|
|
items[acIndex]?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowUp' && items.length > 0) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (acIndex >= 0) items[acIndex]?.classList.remove('active');
|
|
|
|
|
acIndex = (acIndex - 1 + items.length) % items.length;
|
|
|
|
|
items[acIndex]?.classList.add('active');
|
|
|
|
|
items[acIndex]?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
sortConfirmBtn.addEventListener('click', confirmSortAdvanced);
|
|
|
|
|
sortCancelBtn.addEventListener('click', closeSortAdvanced);
|
|
|
|
|
|
2026-03-03 21:34:09 +00:00
|
|
|
// ===== File loading =====
|
|
|
|
|
async function loadFile(filePath) {
|
|
|
|
|
try {
|
|
|
|
|
const data = await window.go.main.App.LoadCSV(filePath);
|
|
|
|
|
state.headers = data.Headers || [];
|
|
|
|
|
state.rows = data.Rows || [];
|
|
|
|
|
state.filePath = data.FilePath || filePath;
|
|
|
|
|
state.cursor = { row: 0, col: 0 };
|
|
|
|
|
state.selection = null;
|
|
|
|
|
state.colWidths = state.headers.map(() => DEFAULT_COL_WIDTH);
|
|
|
|
|
ensureGridSize();
|
|
|
|
|
render();
|
|
|
|
|
setStatus('Loaded: ' + filePath);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus('Error loading: ' + e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadEmptySheet() {
|
|
|
|
|
const cols = 10;
|
|
|
|
|
const rows = 30;
|
|
|
|
|
state.headers = Array.from({ length: cols }, (_, i) => colLabel(i));
|
|
|
|
|
state.rows = Array.from({ length: rows }, () => new Array(cols).fill(''));
|
|
|
|
|
state.colWidths = new Array(cols).fill(DEFAULT_COL_WIDTH);
|
|
|
|
|
state.cursor = { row: 0, col: 0 };
|
|
|
|
|
state.selection = null;
|
|
|
|
|
state.filePath = '';
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== File drop =====
|
|
|
|
|
function setupFileDrop() {
|
|
|
|
|
try {
|
|
|
|
|
window.runtime.EventsOn('file-dropped', (filePath) => {
|
|
|
|
|
if (filePath && filePath.toLowerCase().endsWith('.csv')) {
|
|
|
|
|
loadFile(filePath);
|
|
|
|
|
} else if (filePath) {
|
|
|
|
|
// Try to load anyway
|
|
|
|
|
loadFile(filePath);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('File drop events not available:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Init =====
|
|
|
|
|
async function init() {
|
|
|
|
|
await loadCommands();
|
|
|
|
|
setupFileDrop();
|
|
|
|
|
loadEmptySheet();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init();
|