Add cell selection copy/cut/paste with formula reference adjustment

- store: copySelection/cutSelection/pasteClipboard/cancelClipboard hold an
  in-memory (unpersisted) clipboard buffer of raw cell inputs + formatting.
- shiftFormula rewrites every relative cell reference in a copied/cut formula
  by the move delta (the engine has no $-absolute refs, so all refs qualify).
  Copy shifts every reference uniformly; cut only follows references that
  point inside the moved block itself, leaving refs to untouched cells alone
  and never touching a qualified Table!A1 reference.
- paste anchors on a smaller/single-cell selection (and overflows past it),
  or tiles across a selection that's an exact larger multiple of the buffer.
- TableGrid: Strg+C/X/V shortcuts, context-menu entries, a dashed "clip-src"
  source marker, and Escape drops a pending buffer.
- document.spec.ts: coverage for reference shifting, tiled/fill paste, cut's
  inside/outside reference split, format copy, and Escape cancelling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 09:55:09 +02:00
co-authored by Claude Sonnet 5
parent 31108977b9
commit 44f3314137
3 changed files with 364 additions and 9 deletions
+103
View File
@@ -461,6 +461,109 @@ describe('multi-cell (discontiguous) selection', () => {
});
});
describe('clipboard: copy / cut / paste', () => {
it('copy adjusts every relative reference by the paste delta', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5');
setCell(s, name, 'A2', '=A1*2');
setCell(s, name, 'C4', '9'); // the shifted formula's new precedent
s.setSelection({ tableId: id, anchor: coords('A2'), focus: coords('A2') });
s.copySelection();
s.setSelection({ tableId: id, anchor: coords('C5'), focus: coords('C5') });
s.pasteClipboard();
expect(s.cellInput(id, coords('C5').col, coords('C5').row)).toBe('=C4*2');
expect(val(s, name, 'C5')).toBe(18);
});
it('copy fills a larger, exact-multiple selection (tiled paste)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
setCell(s, s.tableById(id)!.name, 'A1', '1');
setCell(s, s.tableById(id)!.name, 'A2', '2');
const name = s.tableById(id)!.name;
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A2') }); // 1x2 block
s.copySelection();
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B4') }); // 1x4 -> tiles twice
s.pasteClipboard();
expect(val(s, name, 'B1')).toBe(1);
expect(val(s, name, 'B2')).toBe(2);
expect(val(s, name, 'B3')).toBe(1);
expect(val(s, name, 'B4')).toBe(2);
});
it('pasting a single copied cell into a range fills every cell, each adjusted', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '10');
setCell(s, name, 'B1', '=A1');
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B1') });
s.copySelection();
s.setSelection({ tableId: id, anchor: coords('B2'), focus: coords('B4') });
s.pasteClipboard();
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('=A2');
expect(s.cellInput(id, coords('B3').col, coords('B3').row)).toBe('=A3');
expect(s.cellInput(id, coords('B4').col, coords('B4').row)).toBe('=A4');
});
it('cut only follows references that moved with the cut block; outside refs stay put', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5'); // stays put -- not part of the cut
setCell(s, name, 'B1', '10'); // part of the cut block (B1:B2)
setCell(s, name, 'B2', '=B1+A1'); // one ref moves (B1), one doesn't (A1)
s.setSelection({ tableId: id, anchor: coords('B1'), focus: coords('B2') });
s.cutSelection();
s.setSelection({ tableId: id, anchor: coords('D1'), focus: coords('D1') });
s.pasteClipboard();
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=D1+A1');
expect(val(s, name, 'D1')).toBe(10);
expect(val(s, name, 'D2')).toBe(15);
// the source cells were cleared
expect(s.cellInput(id, coords('B1').col, coords('B1').row)).toBe('');
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('');
// the untouched cell A1 is exactly that -- untouched
expect(val(s, name, 'A1')).toBe(5);
});
it('copies cell formatting along with the value, overwriting the destination', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
setCell(s, s.tableById(id)!.name, 'A1', 'x');
s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true, align: 'right' });
s.setRangeFormat(id, coords('C1'), coords('C1'), { italic: true });
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
s.copySelection();
s.setSelection({ tableId: id, anchor: coords('C1'), focus: coords('C1') });
s.pasteClipboard();
expect(s.cellFormat(id, coords('C1').col, coords('C1').row)).toEqual({ bold: true, align: 'right' });
});
it('Escape drops the buffer without pasting', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
setCell(s, s.tableById(id)!.name, 'A1', '1');
s.setSelection({ tableId: id, anchor: coords('A1'), focus: coords('A1') });
s.copySelection();
expect(s.hasClipboard.value).toBe(true);
s.cancelClipboard();
expect(s.hasClipboard.value).toBe(false);
});
});
describe('text box objects', () => {
it('insert / edit / format / round-trip a text object', () => {
const s = fresh();