diff --git a/tables/build.sh b/tables/build.sh index 6ddec90..3ce5432 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -41,6 +41,7 @@ concat_files \ "js/filters.js" \ "js/sort.js" \ "js/editor.js" \ + "js/undo.js" \ "js/save.js" \ "js/clipboard.js" \ "js/render.js" \ diff --git a/tables/css/table.css b/tables/css/table.css index f02d934..60e414e 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -126,6 +126,13 @@ background: var(--color-bg-selected, rgba(40, 104, 200, 0.08)); } +/* Cells in the multi-cell range get a fainter highlight; the focus + cell (the one with --selected) stays brighter so the anchor / + focus distinction is visible. */ +.zddc-table__cell--in-range:not(.zddc-table__cell--selected) { + background: var(--color-bg-range, rgba(40, 104, 200, 0.05)); +} + /* Inline cell-editor input: occupies the cell verbatim, no border so it visually replaces the cell text. The selected outline on the surrounding td still shows. */ diff --git a/tables/js/app.js b/tables/js/app.js index bbffd7d..bbc828b 100644 --- a/tables/js/app.js +++ b/tables/js/app.js @@ -17,8 +17,11 @@ // drafts: {rowId: {field: value, ...}, ...} — uncommitted // edits, displayed in lieu of row.data while present. // Cleared per-row when that row's PUT succeeds (Phase 3). + // range: {anchor: {row, col}, focus: {row, col}} | null + // — multi-cell range selection (Phase 5). selected: null, editing: false, + range: null, drafts: {} }, modules: {} diff --git a/tables/js/editor.js b/tables/js/editor.js index eaa7860..a079c63 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -164,6 +164,12 @@ } } app.state.selected = { row: r, col: c }; + // Plain selection moves clear the multi-cell range. Range + // operations (Shift+click, Shift+arrow) pass keepRange so the + // anchor stays put while the focus cell moves. + if (!opts.keepRange) { + clearRange(); + } notifySelectionChanged(); } @@ -238,7 +244,24 @@ if (sameValue(oldRaw, newValue)) { clearDraftField(rowKey(row), col.field); } else { + // Capture the prior draft value (or stored value if + // no draft) for undo. Lets Ctrl+Z restore intermediate + // state: e.g. typing A → B → C and undoing returns to + // B, not all the way back to the row's stored value. + const priorDraft = getDraft(rowKey(row), col.field); + const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; setDraft(rowKey(row), col.field, newValue); + const undoMod = app.modules.undo; + if (undoMod) { + undoMod.push({ + cells: [{ + rowId: rowKey(row), + field: col.field, + oldValue: undoOld, + newValue: newValue, + }], + }); + } } tearDown(newValue); } @@ -262,13 +285,16 @@ function onKey(ev) { if (ev.key === 'Enter') { ev.preventDefault(); + ev.stopPropagation(); // don't let the table's onCellKey re-handle it commit(); setSelected(r + 1, c); } else if (ev.key === 'Escape') { ev.preventDefault(); + ev.stopPropagation(); cancel(); } else if (ev.key === 'Tab') { ev.preventDefault(); + ev.stopPropagation(); commit(); if (ev.shiftKey) { moveSelection('left-wrap'); @@ -557,13 +583,25 @@ function onCellKey(ev) { if (app.state.editing) return; // input owns its own keys if (!app.state.selected) return; - const { row: r, col: c } = app.state.selected; + const isRangeKey = ev.shiftKey; switch (ev.key) { - case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return; - case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return; - case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return; - case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return; + case 'ArrowUp': + ev.preventDefault(); + isRangeKey ? extendRange('up') : moveSelection('up'); + return; + case 'ArrowDown': + ev.preventDefault(); + isRangeKey ? extendRange('down') : moveSelection('down'); + return; + case 'ArrowLeft': + ev.preventDefault(); + isRangeKey ? extendRange('left') : moveSelection('left'); + return; + case 'ArrowRight': + ev.preventDefault(); + isRangeKey ? extendRange('right') : moveSelection('right'); + return; case 'Home': ev.preventDefault(); if (ev.ctrlKey || ev.metaKey) moveSelection('home-row'); @@ -586,7 +624,29 @@ case 'Escape': ev.preventDefault(); clearSelection(); + clearRange(); return; + case 'Delete': + case 'Backspace': + ev.preventDefault(); + bulkClearSelection(); + return; + case 'd': + case 'D': + if (ev.ctrlKey || ev.metaKey) { + ev.preventDefault(); + bulkFill('down'); + return; + } + break; + case 'r': + case 'R': + if (ev.ctrlKey || ev.metaKey) { + ev.preventDefault(); + bulkFill('right'); + return; + } + break; } if (isPrintableKey(ev)) { @@ -596,6 +656,162 @@ } } + // --- Range selection (multi-cell ops) ----------------------------- + + function extendRange(dir) { + if (!app.state.selected) return; + const range = ensureRange(); + let { row: r, col: c } = range.focus; + const total = rowCount(); + const cols = colCount(); + switch (dir) { + case 'up': r = Math.max(0, r - 1); break; + case 'down': r = Math.min(total - 1, r + 1); break; + case 'left': c = Math.max(0, c - 1); break; + case 'right': c = Math.min(cols - 1, c + 1); break; + } + range.focus = { row: r, col: c }; + applyRangeSelectionStyles(range); + } + + function ensureRange() { + if (!app.state.range) { + const sel = app.state.selected; + app.state.range = { + anchor: { row: sel.row, col: sel.col }, + focus: { row: sel.row, col: sel.col }, + }; + } + return app.state.range; + } + + function clearRange() { + app.state.range = null; + const t = tableEl(); + if (!t) return; + const all = t.querySelectorAll('[role="gridcell"]'); + for (let i = 0; i < all.length; i++) { + all[i].classList.remove('zddc-table__cell--in-range'); + } + } + + function applyRangeSelectionStyles(range) { + const t = tableEl(); + if (!t) return; + const all = t.querySelectorAll('[role="gridcell"]'); + for (let i = 0; i < all.length; i++) { + all[i].classList.remove('zddc-table__cell--in-range'); + } + const r0 = Math.min(range.anchor.row, range.focus.row); + const r1 = Math.max(range.anchor.row, range.focus.row); + const c0 = Math.min(range.anchor.col, range.focus.col); + const c1 = Math.max(range.anchor.col, range.focus.col); + for (let r = r0; r <= r1; r++) { + for (let c = c0; c <= c1; c++) { + const cell = cellAt(r, c); + if (cell) cell.classList.add('zddc-table__cell--in-range'); + } + } + } + + function rangeCells() { + // Returns an array of {rowIdx, colIdx, row, col} for every + // cell in the current range — or just the selected cell if + // no range is active. Skips cells whose row data can't be + // resolved (defensive). + const out = []; + const range = app.state.range; + if (range) { + const r0 = Math.min(range.anchor.row, range.focus.row); + const r1 = Math.max(range.anchor.row, range.focus.row); + const c0 = Math.min(range.anchor.col, range.focus.col); + const c1 = Math.max(range.anchor.col, range.focus.col); + for (let r = r0; r <= r1; r++) { + const row = rowDataAt(r); + if (!row) continue; + for (let c = c0; c <= c1; c++) { + const col = colAt(c); + if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col }); + } + } + return out; + } + if (!app.state.selected) return out; + const { row: r, col: c } = app.state.selected; + const row = rowDataAt(r); + const col = colAt(c); + if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col }); + return out; + } + + function bulkClearSelection() { + // Delete / Backspace in nav mode: clear every selected cell. + // Pushes one undo Command spanning all affected cells. + const cells = rangeCells(); + if (cells.length === 0) return; + const undoCells = []; + for (let i = 0; i < cells.length; i++) { + const c = cells[i]; + const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field); + const priorDraft = getDraft(rowKey(c.row), c.col.field); + const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; + setDraft(rowKey(c.row), c.col.field, null); + undoCells.push({ + rowId: rowKey(c.row), + field: c.col.field, + oldValue: undoOld, + newValue: null, + }); + } + const undoMod = app.modules.undo; + if (undoMod) undoMod.push({ cells: undoCells }); + if (typeof app.repaint === 'function') app.repaint(); + } + + function bulkFill(dir) { + // Ctrl+D fills the top row's values down through the range. + // Ctrl+R fills the left column's values right through the range. + // No-op when no range is active (Excel does the same). + const range = app.state.range; + if (!range) return; + const r0 = Math.min(range.anchor.row, range.focus.row); + const r1 = Math.max(range.anchor.row, range.focus.row); + const c0 = Math.min(range.anchor.col, range.focus.col); + const c1 = Math.max(range.anchor.col, range.focus.col); + + const undoCells = []; + for (let r = r0; r <= r1; r++) { + const row = rowDataAt(r); + if (!row) continue; + for (let c = c0; c <= c1; c++) { + const col = colAt(c); + if (!col) continue; + const srcR = (dir === 'down') ? r0 : r; + const srcC = (dir === 'right') ? c0 : c; + if (r === srcR && c === srcC) continue; + const srcRow = rowDataAt(srcR); + const srcCol = colAt(srcC); + if (!srcRow || !srcCol) continue; + const value = effectiveCellValue(srcRow, srcCol); + const oldRaw = app.modules.util.resolveField(row.data, col.field); + const priorDraft = getDraft(rowKey(row), col.field); + const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw; + setDraft(rowKey(row), col.field, value); + undoCells.push({ + rowId: rowKey(row), + field: col.field, + oldValue: undoOld, + newValue: value, + }); + } + } + if (undoCells.length > 0) { + const undoMod = app.modules.undo; + if (undoMod) undoMod.push({ cells: undoCells }); + if (typeof app.repaint === 'function') app.repaint(); + } + } + // --- Wiring ------------------------------------------------------- function attachToTable() { @@ -618,10 +834,23 @@ td.addEventListener('click', function (ev) { ev.stopPropagation(); - setSelected(rowIdx, colIdx); + if (ev.shiftKey && app.state.selected) { + // Shift+click extends the range from the existing + // anchor to the clicked cell. + const range = ensureRange(); + range.focus = { row: rowIdx, col: colIdx }; + applyRangeSelectionStyles(range); + // Move tabindex/focus marker to the clicked cell but + // keep the anchor in place. + setSelected(rowIdx, colIdx, { keepRange: true }); + } else { + clearRange(); + setSelected(rowIdx, colIdx); + } }); td.addEventListener('dblclick', function (ev) { ev.stopPropagation(); + clearRange(); setSelected(rowIdx, colIdx, { noFocus: true }); enterEdit(); }); diff --git a/tables/js/undo.js b/tables/js/undo.js new file mode 100644 index 0000000..10e8263 --- /dev/null +++ b/tables/js/undo.js @@ -0,0 +1,115 @@ +// undo.js — Phase 5 of editable-cell mode. +// +// Linear command stack, depth 50, session-local. Every successful +// per-cell edit and every bulk operation (paste, fill, delete) push +// a Command onto the stack. Ctrl/Cmd+Z pops the most recent and +// replays the inverse — sets each affected cell's draft buffer +// back to its `oldValue` (or clears the draft when oldValue was +// the row's stored value), then triggers a re-paint and the +// row-blur save flow picks the change up like any other edit. +// +// Why local-only: shared undo across multiple users is conceptually +// broken under last-writer-wins (undoing my edit might revert +// someone else's intervening edit). Every production grid keeps +// undo per-tab; we follow. +// +// Why no redo: minimum viable. Adding redo is a parallel forward +// stack cleared on any new edit. Cheap to add later if users miss +// it. +// +// Command shape: +// { cells: [ {rowId, field, oldValue, newValue}, ... ] } +// +// One-cell edits push a single-cell Command. Bulk operations push +// one Command with N cells so a single Ctrl+Z reverts the whole +// group. +(function (app) { + 'use strict'; + + const STACK_MAX = 50; + const _stack = []; + + function push(cmd) { + if (!cmd || !cmd.cells || cmd.cells.length === 0) return; + _stack.push(cmd); + if (_stack.length > STACK_MAX) { + _stack.shift(); + } + } + + function depth() { return _stack.length; } + + function clear() { _stack.length = 0; } + + function undo() { + const cmd = _stack.pop(); + if (!cmd || !cmd.cells || cmd.cells.length === 0) return null; + + const editor = app.modules.editor; + if (!editor) return null; + + for (let i = 0; i < cmd.cells.length; i++) { + const c = cmd.cells[i]; + // Compare oldValue to the row's stored data — if they + // match, clear the draft (the user's edit is being + // reversed back to baseline). Otherwise set draft = old. + const row = findRow(c.rowId); + if (!row) continue; + const stored = app.modules.util.resolveField(row.data, c.field); + if (sameValue(stored, c.oldValue)) { + editor.clearDraftField(c.rowId, c.field); + } else { + editor.setDraft(c.rowId, c.field, c.oldValue); + } + } + + if (typeof app.repaint === 'function') app.repaint(); + return cmd; + } + + function findRow(rowId) { + const editor = app.modules.editor; + const all = (app.state && app.state.rows) || []; + for (let i = 0; i < all.length; i++) { + if (editor.rowKey(all[i]) === rowId) return all[i]; + } + return null; + } + + function sameValue(a, b) { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (typeof a === 'object' || typeof b === 'object') { + try { return JSON.stringify(a) === JSON.stringify(b); } + catch (_) { return false; } + } + return String(a) === String(b); + } + + // Hotkey: Ctrl+Z (Cmd+Z on macOS). Bound at the document level + // so the user can undo from anywhere on the page, not just from + // within a focused cell. + function onKey(ev) { + const isMod = ev.ctrlKey || ev.metaKey; + if (!isMod) return; + if (ev.key === 'z' || ev.key === 'Z') { + // Skip when the active element is a text-input-like; we + // don't want to override the browser's intra-input undo. + const ae = document.activeElement; + if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) { + return; + } + ev.preventDefault(); + undo(); + } + } + document.addEventListener('keydown', onKey); + + app.modules.undo = { + push: push, + undo: undo, + depth: depth, + clear: clear, + }; +})(window.tablesApp); diff --git a/tests/tables.spec.js b/tests/tables.spec.js index ca2ece4..5a4ace2 100644 --- a/tests/tables.spec.js +++ b/tests/tables.spec.js @@ -895,6 +895,163 @@ test.describe('tables/ — directory-of-YAML table view', () => { expect(written).toBe('Sample'); }); + // --- Phase 5: undo + multi-cell ops ---------------------------------- + + test('Phase 5: Ctrl+Z reverts a single cell edit to prior value', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const titleCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + const originalText = await titleCell.textContent(); + + await titleCell.dblclick(); + await page.keyboard.press('Control+a'); + await page.keyboard.type('Edited via undo test'); + await page.keyboard.press('Enter'); + await expect(titleCell).toContainText('Edited via undo test'); + + // Ctrl+Z (focus has moved to next cell after Enter — undo's + // hotkey is bound at the document, so it works from any + // active cell). + await page.keyboard.press('Control+z'); + await expect(titleCell).toHaveText(originalText.trim()); + + // Draft cleared because we returned to the stored value. + const draftCount = await page.evaluate(() => + Object.keys(window.tablesApp.state.drafts).length); + expect(draftCount).toBe(0); + }); + + test('Phase 5: Shift+ArrowDown extends range selection', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const r0c1 = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(1); + await r0c1.click(); + await page.keyboard.press('Shift+ArrowDown'); + + // Both cells in the column should be in the range. + const inRange = page.locator('.zddc-table__cell--in-range'); + await expect(inRange).toHaveCount(2); + }); + + test('Phase 5: Shift+click extends range from anchor to clicked cell', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const r0c1 = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(1); + const r1c3 = page.locator('#table-root tbody tr').nth(1) + .locator('[role="gridcell"]').nth(3); + + await r0c1.click(); + await r1c3.click({ modifiers: ['Shift'] }); + + // 2 rows × 3 cols = 6 cells in the range. + await expect(page.locator('.zddc-table__cell--in-range')).toHaveCount(6); + }); + + test('Phase 5: Delete clears every selected cell', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + // Select a 2x2 range starting at the title column. + const titleCell = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + await titleCell.click(); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowDown'); + + await page.keyboard.press('Delete'); + + // 4 drafts created, each set to null. + const drafts = await page.evaluate(() => window.tablesApp.state.drafts); + const totalDraftFields = Object.values(drafts) + .reduce((acc, r) => acc + Object.keys(r).length, 0); + expect(totalDraftFields).toBe(4); + }); + + test('Phase 5: Ctrl+D fills the top row down through the range', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: [ + makeSchemaRow({ id: 'D-001', data: { title: 'Top' } }), + makeSchemaRow({ id: 'D-002', data: { title: 'Bottom' } }), + ], + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + // Select the title column across both rows. + const titleR0 = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + await titleR0.click(); + await page.keyboard.press('Shift+ArrowDown'); + + await page.keyboard.press('Control+d'); + + const titleR1 = page.locator('#table-root tbody tr').nth(1) + .locator('[role="gridcell"]').nth(colIdx('title')); + await expect(titleR1).toContainText('Top'); + }); + + test('Phase 5: Ctrl+Z reverts a bulk fill in one step', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, + rows: [ + makeSchemaRow({ id: 'D-001', data: { title: 'Top' } }), + makeSchemaRow({ id: 'D-002', data: { title: 'Bottom' } }), + ], + rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + const titleR0 = page.locator('#table-root tbody tr').nth(0) + .locator('[role="gridcell"]').nth(colIdx('title')); + const titleR1 = page.locator('#table-root tbody tr').nth(1) + .locator('[role="gridcell"]').nth(colIdx('title')); + + await titleR0.click(); + await page.keyboard.press('Shift+ArrowDown'); + await page.keyboard.press('Control+d'); + await expect(titleR1).toContainText('Top'); + + await page.keyboard.press('Control+z'); + await expect(titleR1).toContainText('Bottom'); + + // Drafts cleared (returning to stored values). + const draftCount = await page.evaluate(() => + Object.keys(window.tablesApp.state.drafts).length); + expect(draftCount).toBe(0); + }); + + test('Phase 5: undo stack depth caps at 50', async ({ page }) => { + await loadTableWithContext(page, { + columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA, + }); + await page.waitForSelector('#table-root tbody tr'); + + // Push 60 commands directly via the module API. + await page.evaluate(() => { + const u = window.tablesApp.modules.undo; + for (let i = 0; i < 60; i++) { + u.push({ cells: [{ rowId: 'fake', field: 'title', oldValue: 'a', newValue: 'b' }] }); + } + }); + const depth = await page.evaluate(() => window.tablesApp.modules.undo.depth()); + expect(depth).toBe(50); + }); + test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => { await setupSaveCapture(page); const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })]; diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index c2d033b..d66c3fb 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -653,6 +653,13 @@ body.help-open .app-header { background: var(--color-bg-selected, rgba(40, 104, 200, 0.08)); } +/* Cells in the multi-cell range get a fainter highlight; the focus + cell (the one with --selected) stays brighter so the anchor / + focus distinction is visible. */ +.zddc-table__cell--in-range:not(.zddc-table__cell--selected) { + background: var(--color-bg-range, rgba(40, 104, 200, 0.05)); +} + /* Inline cell-editor input: occupies the cell verbatim, no border so it visually replaces the cell text. The selected outline on the surrounding td still shows. */ @@ -932,7 +939,7 @@ body.help-open .app-header {