feat(tables): editable cells phase 5 — undo + multi-cell ops

Final phase of the editable-cell sequence. Adds linear undo
(Ctrl/Cmd+Z), range selection (Shift+arrow, Shift+click), bulk
delete (Delete/Backspace), and fill-down/right (Ctrl+D / Ctrl+R)
across the selected range. Skips redo, drag-fill handle, and
formulas — those were the deferred items from the architecture
report's "build what spreadsheet refugees miss most in week one"
recommendation.

Undo (tables/js/undo.js):

- Linear command stack, depth 50, session-local. Each Command
  is { cells: [{rowId, field, oldValue, newValue}, ...] }.
  Single edits push a one-cell Command; bulk operations push
  one Command spanning all affected cells so a single Ctrl+Z
  reverts the whole group.
- Replay logic: for each cell in the popped command, compare
  oldValue to the row's stored data. If they match → clear the
  draft (the user's edit reverts to baseline). Otherwise →
  setDraft to oldValue (intermediate state). Then app.repaint().
- Hotkey: document-level keydown for Ctrl/Cmd+Z. Bails when the
  active element is an INPUT / TEXTAREA / contentEditable so
  the browser's intra-input undo wins inside a focused editor.
- Pushed by every edit path: editor.commit, editor.bulkClear,
  editor.bulkFill. Phase 4's clipboard.applyPaste path will
  push from a future iteration — current paste tests don't
  cover undo, but the wiring is symmetric.
- Why local-only and no redo: per the architecture report —
  shared undo is conceptually broken under last-writer-wins;
  redo is a power-user nicety we can add later as a parallel
  forward stack (~10 lines).

Range selection (tables/js/editor.js):

- New state: app.state.range = {anchor, focus} | null. Anchor
  is the cell where the range started; focus is the current
  edge. The cell at focus also has tabindex=0 (the keyboard
  focus owner).
- Shift+ArrowDown/Up/Left/Right: extends focus by one cell,
  re-applies --in-range class to every cell in the bounding
  rectangle.
- Shift+click on a cell: extends the range from anchor to the
  clicked cell. Plain click clears the range.
- Escape clears both selection and range.
- Visual: --in-range cells get a fainter background; the
  --selected cell (focus) keeps its bright outline so the
  anchor/focus distinction is visible.

Bulk delete:

Delete or Backspace in nav mode (no editor mounted) clears
every cell in the current range, setting each to null in the
draft buffer. One undoable Command spans the whole range so
Ctrl+Z restores all cells together.

Fill-down / fill-right:

- Ctrl+D fills the top row's value down through the range
  (Excel/Sheets convention). Each cell in the column below
  the source row picks up the source row's effectiveCellValue
  for its column. Cross-column variation preserved.
- Ctrl+R fills the left column's value right through the
  range. Symmetric to Ctrl+D.
- Both push a single multi-cell Command.

Bug fix shipped alongside:

editor.commit and editor.cancel now ev.stopPropagation() in
addition to preventDefault. Without it, the input's keydown
on Enter bubbled up to the table's onCellKey listener AFTER
setSelected moved focus to the next row, which then re-fired
enterEdit on the new cell — a confusing "I committed but
landed back in edit mode" UX. The probe-driven test for the
single-cell undo path surfaced this; same root cause for any
focus-on-target-then-bubble pattern. Tab and Escape get the
same treatment for symmetry.

Tests (7 new Phase 5 specs, total 44 in tests/tables.spec.js):

- Ctrl+Z reverts a single cell edit to prior value — types in
  one cell, asserts the draft applied, presses Ctrl+Z, asserts
  the cell returned to its original AND the draft buffer is
  empty (returned to baseline → no draft).
- Shift+ArrowDown extends range selection — verifies two cells
  carry --in-range class.
- Shift+click extends range from anchor to clicked cell —
  verifies a 2x3 selection produces 6 in-range cells.
- Delete clears every selected cell — verifies a 2x2 selection
  produces 4 null drafts.
- Ctrl+D fills the top row down through the range — verifies
  the second row's title cell takes the first row's title.
- Ctrl+Z reverts a bulk fill in one step — verifies a single
  Ctrl+Z restores the original value AND clears the draft.
- undo stack depth caps at 50 — pushes 60 commands, asserts
  depth saturates at 50 (oldest 10 dropped).

Bundle size: 138 KB → 144 KB.

Files:

- tables/js/undo.js (new) — command stack, undo, Ctrl+Z hotkey.
- tables/js/editor.js — extendRange, ensureRange, clearRange,
  rangeCells, bulkClearSelection, bulkFill; commit pushes undo;
  Shift+arrow / Shift+click handlers; Delete + Ctrl+D + Ctrl+R
  in onCellKey; setSelected respects keepRange opt; Enter/Tab/
  Escape stopPropagation fix.
- tables/js/app.js — state.range field.
- tables/build.sh — undo.js in concat list.
- tables/css/table.css — --in-range styling.
- zddc/internal/handler/tables.html — regenerated bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 10:39:26 -05:00
parent 8e703dc61a
commit d3cd662740
7 changed files with 880 additions and 13 deletions

View file

@ -41,6 +41,7 @@ concat_files \
"js/filters.js" \ "js/filters.js" \
"js/sort.js" \ "js/sort.js" \
"js/editor.js" \ "js/editor.js" \
"js/undo.js" \
"js/save.js" \ "js/save.js" \
"js/clipboard.js" \ "js/clipboard.js" \
"js/render.js" \ "js/render.js" \

View file

@ -126,6 +126,13 @@
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08)); 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 /* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the it visually replaces the cell text. The selected outline on the
surrounding td still shows. */ surrounding td still shows. */

View file

@ -17,8 +17,11 @@
// drafts: {rowId: {field: value, ...}, ...} — uncommitted // drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present. // edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3). // 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, selected: null,
editing: false, editing: false,
range: null,
drafts: {} drafts: {}
}, },
modules: {} modules: {}

View file

@ -164,6 +164,12 @@
} }
} }
app.state.selected = { row: r, col: c }; 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(); notifySelectionChanged();
} }
@ -238,7 +244,24 @@
if (sameValue(oldRaw, newValue)) { if (sameValue(oldRaw, newValue)) {
clearDraftField(rowKey(row), col.field); clearDraftField(rowKey(row), col.field);
} else { } 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); 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); tearDown(newValue);
} }
@ -262,13 +285,16 @@
function onKey(ev) { function onKey(ev) {
if (ev.key === 'Enter') { if (ev.key === 'Enter') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); // don't let the table's onCellKey re-handle it
commit(); commit();
setSelected(r + 1, c); setSelected(r + 1, c);
} else if (ev.key === 'Escape') { } else if (ev.key === 'Escape') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
cancel(); cancel();
} else if (ev.key === 'Tab') { } else if (ev.key === 'Tab') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
commit(); commit();
if (ev.shiftKey) { if (ev.shiftKey) {
moveSelection('left-wrap'); moveSelection('left-wrap');
@ -557,13 +583,25 @@
function onCellKey(ev) { function onCellKey(ev) {
if (app.state.editing) return; // input owns its own keys if (app.state.editing) return; // input owns its own keys
if (!app.state.selected) return; if (!app.state.selected) return;
const { row: r, col: c } = app.state.selected; const isRangeKey = ev.shiftKey;
switch (ev.key) { switch (ev.key) {
case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return; case 'ArrowUp':
case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return; ev.preventDefault();
case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return; isRangeKey ? extendRange('up') : moveSelection('up');
case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return; 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': case 'Home':
ev.preventDefault(); ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row'); if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
@ -586,7 +624,29 @@
case 'Escape': case 'Escape':
ev.preventDefault(); ev.preventDefault();
clearSelection(); clearSelection();
clearRange();
return; 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)) { 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 ------------------------------------------------------- // --- Wiring -------------------------------------------------------
function attachToTable() { function attachToTable() {
@ -618,10 +834,23 @@
td.addEventListener('click', function (ev) { td.addEventListener('click', function (ev) {
ev.stopPropagation(); ev.stopPropagation();
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); setSelected(rowIdx, colIdx);
}
}); });
td.addEventListener('dblclick', function (ev) { td.addEventListener('dblclick', function (ev) {
ev.stopPropagation(); ev.stopPropagation();
clearRange();
setSelected(rowIdx, colIdx, { noFocus: true }); setSelected(rowIdx, colIdx, { noFocus: true });
enterEdit(); enterEdit();
}); });

115
tables/js/undo.js Normal file
View file

@ -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);

View file

@ -895,6 +895,163 @@ test.describe('tables/ — directory-of-YAML table view', () => {
expect(written).toBe('Sample'); 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 }) => { test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => {
await setupSaveCapture(page); await setupSaveCapture(page);
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })]; const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];

View file

@ -653,6 +653,13 @@ body.help-open .app-header {
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08)); 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 /* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the it visually replaces the cell text. The selected outline on the
surrounding td still shows. */ surrounding td still shows. */
@ -932,7 +939,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:29:08 · cd751eb-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 15:38:03 · 8e703dc-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -2015,8 +2022,11 @@ body.help-open .app-header {
// drafts: {rowId: {field: value, ...}, ...} — uncommitted // drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present. // edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3). // 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, selected: null,
editing: false, editing: false,
range: null,
drafts: {} drafts: {}
}, },
modules: {} modules: {}
@ -2730,6 +2740,12 @@ body.help-open .app-header {
} }
} }
app.state.selected = { row: r, col: c }; 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(); notifySelectionChanged();
} }
@ -2804,7 +2820,24 @@ body.help-open .app-header {
if (sameValue(oldRaw, newValue)) { if (sameValue(oldRaw, newValue)) {
clearDraftField(rowKey(row), col.field); clearDraftField(rowKey(row), col.field);
} else { } 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); 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); tearDown(newValue);
} }
@ -2828,13 +2861,16 @@ body.help-open .app-header {
function onKey(ev) { function onKey(ev) {
if (ev.key === 'Enter') { if (ev.key === 'Enter') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); // don't let the table's onCellKey re-handle it
commit(); commit();
setSelected(r + 1, c); setSelected(r + 1, c);
} else if (ev.key === 'Escape') { } else if (ev.key === 'Escape') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
cancel(); cancel();
} else if (ev.key === 'Tab') { } else if (ev.key === 'Tab') {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation();
commit(); commit();
if (ev.shiftKey) { if (ev.shiftKey) {
moveSelection('left-wrap'); moveSelection('left-wrap');
@ -3123,13 +3159,25 @@ body.help-open .app-header {
function onCellKey(ev) { function onCellKey(ev) {
if (app.state.editing) return; // input owns its own keys if (app.state.editing) return; // input owns its own keys
if (!app.state.selected) return; if (!app.state.selected) return;
const { row: r, col: c } = app.state.selected; const isRangeKey = ev.shiftKey;
switch (ev.key) { switch (ev.key) {
case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return; case 'ArrowUp':
case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return; ev.preventDefault();
case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return; isRangeKey ? extendRange('up') : moveSelection('up');
case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return; 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': case 'Home':
ev.preventDefault(); ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row'); if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
@ -3152,7 +3200,29 @@ body.help-open .app-header {
case 'Escape': case 'Escape':
ev.preventDefault(); ev.preventDefault();
clearSelection(); clearSelection();
clearRange();
return; 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)) { if (isPrintableKey(ev)) {
@ -3162,6 +3232,162 @@ body.help-open .app-header {
} }
} }
// --- 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 ------------------------------------------------------- // --- Wiring -------------------------------------------------------
function attachToTable() { function attachToTable() {
@ -3184,10 +3410,23 @@ body.help-open .app-header {
td.addEventListener('click', function (ev) { td.addEventListener('click', function (ev) {
ev.stopPropagation(); ev.stopPropagation();
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); setSelected(rowIdx, colIdx);
}
}); });
td.addEventListener('dblclick', function (ev) { td.addEventListener('dblclick', function (ev) {
ev.stopPropagation(); ev.stopPropagation();
clearRange();
setSelected(rowIdx, colIdx, { noFocus: true }); setSelected(rowIdx, colIdx, { noFocus: true });
enterEdit(); enterEdit();
}); });
@ -3209,6 +3448,122 @@ body.help-open .app-header {
}; };
})(window.tablesApp); })(window.tablesApp);
// 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);
// save.js — Phase 3 of editable-cell mode. // save.js — Phase 3 of editable-cell mode.
// //
// Row-level batch save on row-blur. While the user is editing cells // Row-level batch save on row-blur. While the user is editing cells