Compare commits

..

2 commits

Author SHA1 Message Date
exe.dev user 15bd72e02b Add Set Where command
Some checks failed
Build / build-linux (push) Failing after 16s
Build / build-linux-webkit2_41 (push) Failing after 16s
Build / build-macos (push) Successful in 3m30s
New modal-based command that bulk-sets cell values conditionally:
- Match Column: autocomplete column name picker
- Match Values: textarea with one value per line
- Set Value: the value to write

Scans all rows; where the Match Column cell equals any Match Value,
sets the cell at the cursor's current column to Set Value.

Modal supports full keyboard navigation: Tab between fields,
Enter in Set Value confirms, Escape closes, arrow keys for
autocomplete selection.

Co-authored-by: Shelley <shelley@exe.dev>
2026-03-05 03:22:35 +00:00
exe.dev user df8ade2c4d Replace Match Row with Match Cell (Cmd+M)
- New 'Match Cell' command selects all cells in the sheet whose value
  equals the current cell's value (non-contiguous selection)
- Added state.selectedCells (Set of 'r,c' keys) for scatter selection
- isCellSelected checks selectedCells first, then falls back to
  rectangular selection
- getSelectedData/clearSelectedCells handle non-contiguous selections
- Cmd+M keyboard shortcut mapped
- Match selection cleared on click, arrow movement, or Escape

Co-authored-by: Shelley <shelley@exe.dev>
2026-03-05 03:06:03 +00:00
5 changed files with 386 additions and 26 deletions

View file

@ -98,8 +98,8 @@ func TestParseCSVString(t *testing.T) {
func TestGetCommands(t *testing.T) { func TestGetCommands(t *testing.T) {
reg := NewCommandRegistry() reg := NewCommandRegistry()
cmds := reg.GetCommands() cmds := reg.GetCommands()
if len(cmds) != 17 { if len(cmds) != 18 {
t.Errorf("expected 12 commands, got %d", len(cmds)) t.Errorf("expected 18 commands, got %d", len(cmds))
} }
// Check that all have IDs // Check that all have IDs
for _, cmd := range cmds { for _, cmd := range cmds {

View file

@ -33,7 +33,8 @@ func (c *CommandRegistry) GetCommands() []Command {
{ID: "sort-asc", Name: "Sort A-Z", Shortcut: ""}, {ID: "sort-asc", Name: "Sort A-Z", Shortcut: ""},
{ID: "sort-desc", Name: "Sort Z-A", Shortcut: ""}, {ID: "sort-desc", Name: "Sort Z-A", Shortcut: ""},
{ID: "sort-advanced", Name: "Sort Advanced", Shortcut: ""}, {ID: "sort-advanced", Name: "Sort Advanced", Shortcut: ""},
{ID: "match-row", Name: "Match Row", Shortcut: ""}, {ID: "match-cell", Name: "Match Cell", Shortcut: "Cmd+M"},
{ID: "set-where", Name: "Set Where", Shortcut: ""},
{ID: "delete-row", Name: "Delete Row", Shortcut: "Cmd+Backspace"}, {ID: "delete-row", Name: "Delete Row", Shortcut: "Cmd+Backspace"},
} }
} }

View file

@ -39,6 +39,24 @@
<button id="sort-confirm-btn" class="modal-btn modal-btn-primary">Sort</button> <button id="sort-confirm-btn" class="modal-btn modal-btn-primary">Sort</button>
</div> </div>
</div> </div>
<div id="set-where-modal" class="hidden">
<div class="modal-title">Set Where</div>
<div class="modal-body">
<label for="sw-match-col">Match Column:</label>
<div class="sw-input-wrap">
<input type="text" id="sw-match-col" placeholder="Column name" autocomplete="off" />
<div id="sw-col-autocomplete" class="hidden"></div>
</div>
<label for="sw-match-values" style="margin-top:10px">Match Values (one per line):</label>
<textarea id="sw-match-values" rows="6" placeholder="value1&#10;value2&#10;value3"></textarea>
<label for="sw-set-value" style="margin-top:10px">Set Value:</label>
<input type="text" id="sw-set-value" placeholder="Value to set" />
</div>
<div class="modal-actions">
<button id="sw-cancel-btn" class="modal-btn">Cancel</button>
<button id="sw-confirm-btn" class="modal-btn modal-btn-primary">Apply</button>
</div>
</div>
<div id="overlay" class="hidden"></div> <div id="overlay" class="hidden"></div>
</div> </div>
<script src="./src/main.js" type="module"></script> <script src="./src/main.js" type="module"></script>

View file

@ -7,6 +7,7 @@ const state = {
filePath: '', filePath: '',
cursor: { row: 0, col: 0 }, cursor: { row: 0, col: 0 },
selection: null, // { startRow, startCol, endRow, endCol } selection: null, // { startRow, startCol, endRow, endCol }
selectedCells: null, // Set of "r,c" strings for non-contiguous selection (Match Cell)
colWidths: [], // pixel widths per column colWidths: [], // pixel widths per column
isSelecting: false, isSelecting: false,
editingHeader: -1, editingHeader: -1,
@ -31,6 +32,13 @@ const sortInput = $('#sort-columns-input');
const sortAutocomplete = $('#sort-autocomplete'); const sortAutocomplete = $('#sort-autocomplete');
const sortConfirmBtn = $('#sort-confirm-btn'); const sortConfirmBtn = $('#sort-confirm-btn');
const sortCancelBtn = $('#sort-cancel-btn'); const sortCancelBtn = $('#sort-cancel-btn');
const swModal = $('#set-where-modal');
const swMatchCol = $('#sw-match-col');
const swColAutocomplete = $('#sw-col-autocomplete');
const swMatchValues = $('#sw-match-values');
const swSetValue = $('#sw-set-value');
const swConfirmBtn = $('#sw-confirm-btn');
const swCancelBtn = $('#sw-cancel-btn');
// Default column width // Default column width
const DEFAULT_COL_WIDTH = 120; const DEFAULT_COL_WIDTH = 120;
@ -53,6 +61,10 @@ function cellRefStr(r, c) {
return colLabel(c) + (r + 1); return colLabel(c) + (r + 1);
} }
function clearMatchSelection() {
state.selectedCells = null;
}
function normalizeSelection() { function normalizeSelection() {
if (!state.selection) return null; if (!state.selection) return null;
const { startRow, startCol, endRow, endCol } = state.selection; const { startRow, startCol, endRow, endCol } = state.selection;
@ -65,6 +77,7 @@ function normalizeSelection() {
} }
function isCellSelected(r, c) { function isCellSelected(r, c) {
if (state.selectedCells) return state.selectedCells.has(`${r},${c}`);
const sel = normalizeSelection(); const sel = normalizeSelection();
if (!sel) return r === state.cursor.row && c === state.cursor.col; if (!sel) return r === state.cursor.row && c === state.cursor.col;
return r >= sel.r1 && r <= sel.r2 && c >= sel.c1 && c <= sel.c2; return r >= sel.r1 && r <= sel.r2 && c >= sel.c1 && c <= sel.c2;
@ -345,6 +358,8 @@ tableBody.addEventListener('mousedown', (e) => {
commitFormulaBar(); commitFormulaBar();
} }
clearMatchSelection();
if (e.shiftKey) { if (e.shiftKey) {
// Extend selection // Extend selection
state.selection = { state.selection = {
@ -384,6 +399,7 @@ document.addEventListener('mouseup', () => {
// ===== Keyboard navigation ===== // ===== Keyboard navigation =====
function moveCursor(dr, dc, shift) { function moveCursor(dr, dc, shift) {
clearMatchSelection();
if (shift) { if (shift) {
if (!state.selection) { if (!state.selection) {
state.selection = { state.selection = {
@ -470,6 +486,11 @@ document.addEventListener('keydown', (e) => {
executeCommand('open'); executeCommand('open');
return; return;
} }
if (meta && e.key === 'm') {
e.preventDefault();
executeCommand('match-cell');
return;
}
// Arrow keys // Arrow keys
if (e.key === 'ArrowUp') { e.preventDefault(); moveCursor(-1, 0, e.shiftKey); return; } if (e.key === 'ArrowUp') { e.preventDefault(); moveCursor(-1, 0, e.shiftKey); return; }
@ -508,6 +529,7 @@ document.addEventListener('keydown', (e) => {
// Escape clears selection // Escape clears selection
if (e.key === 'Escape') { if (e.key === 'Escape') {
state.selection = null; state.selection = null;
clearMatchSelection();
updateSelectionClasses(); updateSelectionClasses();
return; return;
} }
@ -556,6 +578,29 @@ function cancelFormulaBar() {
// ===== Selection data extraction ===== // ===== Selection data extraction =====
function getSelectedData() { function getSelectedData() {
// 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 };
}
const sel = normalizeSelection(); const sel = normalizeSelection();
if (!sel) { if (!sel) {
return { return {
@ -584,6 +629,14 @@ function getSelectedData() {
} }
function clearSelectedCells() { function clearSelectedCells() {
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;
}
const sel = normalizeSelection(); const sel = normalizeSelection();
if (!sel) { if (!sel) {
setCellValue(state.cursor.row, state.cursor.col, ''); setCellValue(state.cursor.row, state.cursor.col, '');
@ -622,7 +675,8 @@ async function loadCommands() {
{ ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' }, { ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' },
{ ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' }, { ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' },
{ ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' }, { ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' },
{ ID: 'match-row', Name: 'Match Row', Shortcut: '' }, { ID: 'match-cell', Name: 'Match Cell', Shortcut: 'Cmd+M' },
{ ID: 'set-where', Name: 'Set Where', Shortcut: '' },
{ ID: 'delete-row', Name: 'Delete Row', Shortcut: 'Cmd+Backspace' }, { ID: 'delete-row', Name: 'Delete Row', Shortcut: 'Cmd+Backspace' },
]; ];
} }
@ -709,6 +763,7 @@ commandInput.addEventListener('keydown', (e) => {
overlay.addEventListener('click', () => { overlay.addEventListener('click', () => {
if (!commandPalette.classList.contains('hidden')) closeCommandPalette(); if (!commandPalette.classList.contains('hidden')) closeCommandPalette();
if (!sortModal.classList.contains('hidden')) closeSortAdvanced(); if (!sortModal.classList.contains('hidden')) closeSortAdvanced();
if (!swModal.classList.contains('hidden')) closeSetWhere();
}); });
// ===== Command execution ===== // ===== Command execution =====
@ -729,7 +784,8 @@ async function executeCommand(id) {
case 'sort-asc': doSort(true); break; case 'sort-asc': doSort(true); break;
case 'sort-desc': doSort(false); break; case 'sort-desc': doSort(false); break;
case 'sort-advanced': openSortAdvanced(); break; case 'sort-advanced': openSortAdvanced(); break;
case 'match-row': doMatchRow(); break; case 'match-cell': doMatchCell(); break;
case 'set-where': openSetWhere(); break;
case 'delete-row': doDeleteRow(); break; case 'delete-row': doDeleteRow(); break;
} }
} }
@ -945,33 +1001,24 @@ function doInsertColRight() {
} }
// ===== Match & Delete Row ===== // ===== Match & Delete Row =====
function doMatchRow() { function doMatchCell() {
const col = state.cursor.col; const val = getCellValue(state.cursor.row, state.cursor.col);
const val = getCellValue(state.cursor.row, col); const matched = new Set();
const matchingRows = [];
for (let r = 0; r < state.rows.length; r++) { for (let r = 0; r < state.rows.length; r++) {
if ((state.rows[r][col] || '') === val) { for (let c = 0; c < state.headers.length; c++) {
matchingRows.push(r); if ((state.rows[r][c] || '') === val) {
matched.add(`${r},${c}`);
}
} }
} }
if (matchingRows.length === 0) { if (matched.size === 0) {
setStatus('No matching rows'); setStatus('No matching cells');
return; return;
} }
// Select from first to last matching row, spanning all columns state.selection = null;
const first = matchingRows[0]; state.selectedCells = matched;
const last = matchingRows[matchingRows.length - 1];
state.cursor = { row: first, col: 0 };
state.selection = {
startRow: first,
startCol: 0,
endRow: last,
endCol: state.headers.length - 1,
};
updateSelectionClasses(); updateSelectionClasses();
scrollCursorIntoView(); setStatus(`${matched.size} cell(s) matching "${val}"`);
const colName = state.headers[col] || colLabel(col);
setStatus(`${matchingRows.length} row(s) matching ${colName} = "${val}"`);
} }
function doDeleteRow() { function doDeleteRow() {
@ -1198,6 +1245,209 @@ sortInput.addEventListener('keydown', (e) => {
sortConfirmBtn.addEventListener('click', confirmSortAdvanced); sortConfirmBtn.addEventListener('click', confirmSortAdvanced);
sortCancelBtn.addEventListener('click', closeSortAdvanced); sortCancelBtn.addEventListener('click', closeSortAdvanced);
// ===== Set Where Modal =====
let swAcIndex = -1;
function openSetWhere() {
swModal.classList.remove('hidden');
overlay.classList.remove('hidden');
swMatchCol.value = '';
swMatchValues.value = '';
swSetValue.value = '';
hideSwAutocomplete();
swMatchCol.focus();
}
function closeSetWhere() {
swModal.classList.add('hidden');
overlay.classList.add('hidden');
swMatchCol.value = '';
swMatchValues.value = '';
swSetValue.value = '';
hideSwAutocomplete();
}
function hideSwAutocomplete() {
swColAutocomplete.classList.add('hidden');
swColAutocomplete.innerHTML = '';
swAcIndex = -1;
}
function updateSwAutocomplete() {
const text = swMatchCol.value.trim().toLowerCase();
const matches = state.headers
.filter(h => h.toLowerCase().includes(text))
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === text)) {
hideSwAutocomplete();
return;
}
swColAutocomplete.innerHTML = '';
swColAutocomplete.classList.remove('hidden');
swAcIndex = -1;
matches.forEach((name) => {
const div = document.createElement('div');
div.className = 'ac-item';
div.textContent = name;
div.addEventListener('mousedown', (e) => {
e.preventDefault();
swMatchCol.value = name;
hideSwAutocomplete();
swMatchValues.focus();
});
swColAutocomplete.appendChild(div);
});
}
function confirmSetWhere() {
const colName = swMatchCol.value.trim();
const matchColIdx = state.headers.findIndex(
h => h.toLowerCase() === colName.toLowerCase()
);
if (matchColIdx === -1) {
setStatus(`Unknown column: "${colName}"`);
return;
}
const matchVals = new Set(
swMatchValues.value.split('\n').map(s => s.trim()).filter(s => s.length > 0)
);
if (matchVals.size === 0) {
setStatus('No match values specified');
return;
}
const setValue = swSetValue.value;
const targetCol = state.cursor.col;
let count = 0;
for (let r = 0; r < state.rows.length; r++) {
const cellVal = state.rows[r][matchColIdx] || '';
if (matchVals.has(cellVal)) {
setCellValue(r, targetCol, setValue);
count++;
}
}
closeSetWhere();
render();
const targetColName = state.headers[targetCol] || colLabel(targetCol);
setStatus(`Set ${count} cell(s) in ${targetColName}`);
}
swMatchCol.addEventListener('input', updateSwAutocomplete);
swMatchCol.addEventListener('focus', updateSwAutocomplete);
swMatchCol.addEventListener('blur', () => {
setTimeout(hideSwAutocomplete, 150);
});
swMatchCol.addEventListener('keydown', (e) => {
const items = swColAutocomplete.querySelectorAll('.ac-item');
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (!swColAutocomplete.classList.contains('hidden')) {
hideSwAutocomplete();
} else {
closeSetWhere();
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (swAcIndex >= 0 && items[swAcIndex]) {
swMatchCol.value = items[swAcIndex].textContent;
hideSwAutocomplete();
swMatchValues.focus();
} else {
// Move focus to match values
swMatchValues.focus();
}
return;
}
if (e.key === 'ArrowDown' && items.length > 0) {
e.preventDefault();
if (swAcIndex >= 0) items[swAcIndex]?.classList.remove('active');
swAcIndex = (swAcIndex + 1) % items.length;
items[swAcIndex]?.classList.add('active');
items[swAcIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'ArrowUp' && items.length > 0) {
e.preventDefault();
if (swAcIndex >= 0) items[swAcIndex]?.classList.remove('active');
swAcIndex = (swAcIndex - 1 + items.length) % items.length;
items[swAcIndex]?.classList.add('active');
items[swAcIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'Tab') {
e.preventDefault();
if (swAcIndex >= 0 && items[swAcIndex]) {
swMatchCol.value = items[swAcIndex].textContent;
hideSwAutocomplete();
}
swMatchValues.focus();
return;
}
e.stopPropagation();
});
swMatchValues.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSetWhere();
return;
}
// Tab to next field
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
swSetValue.focus();
return;
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
swMatchCol.focus();
return;
}
e.stopPropagation();
});
swSetValue.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSetWhere();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
confirmSetWhere();
return;
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
swMatchValues.focus();
return;
}
e.stopPropagation();
});
swConfirmBtn.addEventListener('click', confirmSetWhere);
swCancelBtn.addEventListener('click', closeSetWhere);
// ===== File loading ===== // ===== File loading =====
async function loadFile(filePath) { async function loadFile(filePath) {
try { try {

View file

@ -391,6 +391,97 @@ td.cursor-cell {
background: #1a54a9; background: #1a54a9;
} }
/* Set Where modal */
#set-where-modal {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
width: 460px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
z-index: 100;
overflow: visible;
}
#set-where-modal.hidden {
display: none;
}
.sw-input-wrap {
position: relative;
}
#sw-match-col {
width: 100%;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: 4px;
outline: none;
box-sizing: border-box;
}
#sw-match-col:focus {
border-color: var(--selected-border);
box-shadow: 0 0 0 2px rgba(34,102,204,0.15);
}
#sw-col-autocomplete {
position: absolute;
left: 0;
right: 0;
top: 100%;
margin-top: 2px;
background: #fff;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
max-height: 180px;
overflow-y: auto;
z-index: 110;
}
#sw-col-autocomplete.hidden {
display: none;
}
#sw-match-values {
width: 100%;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: 4px;
outline: none;
resize: vertical;
box-sizing: border-box;
}
#sw-match-values:focus {
border-color: var(--selected-border);
box-shadow: 0 0 0 2px rgba(34,102,204,0.15);
}
#sw-set-value {
width: 100%;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: 4px;
outline: none;
box-sizing: border-box;
}
#sw-set-value:focus {
border-color: var(--selected-border);
box-shadow: 0 0 0 2px rgba(34,102,204,0.15);
}
/* Drag over style */ /* Drag over style */
#table-container.drag-over { #table-container.drag-over {
background: var(--selected-bg); background: var(--selected-bg);