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:
parent
8e703dc61a
commit
d3cd662740
7 changed files with 880 additions and 13 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
115
tables/js/undo.js
Normal file
115
tables/js/undo.js
Normal 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);
|
||||
|
|
@ -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' })];
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -2015,8 +2022,11 @@ body.help-open .app-header {
|
|||
// 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: {}
|
||||
|
|
@ -2730,6 +2740,12 @@ body.help-open .app-header {
|
|||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -2804,7 +2820,24 @@ body.help-open .app-header {
|
|||
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);
|
||||
}
|
||||
|
|
@ -2828,13 +2861,16 @@ body.help-open .app-header {
|
|||
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');
|
||||
|
|
@ -3123,13 +3159,25 @@ body.help-open .app-header {
|
|||
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');
|
||||
|
|
@ -3152,7 +3200,29 @@ body.help-open .app-header {
|
|||
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)) {
|
||||
|
|
@ -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 -------------------------------------------------------
|
||||
|
||||
function attachToTable() {
|
||||
|
|
@ -3184,10 +3410,23 @@ body.help-open .app-header {
|
|||
|
||||
td.addEventListener('click', function (ev) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
td.addEventListener('dblclick', function (ev) {
|
||||
ev.stopPropagation();
|
||||
clearRange();
|
||||
setSelected(rowIdx, colIdx, { noFocus: true });
|
||||
enterEdit();
|
||||
});
|
||||
|
|
@ -3209,6 +3448,122 @@ body.help-open .app-header {
|
|||
};
|
||||
})(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.
|
||||
//
|
||||
// Row-level batch save on row-blur. While the user is editing cells
|
||||
|
|
|
|||
Loading…
Reference in a new issue