Add the fill handle: drag the selection's corner to copy/fill, Excel-style

- store: fillSelection(tableId, source, target) repeats a source rectangle's
  cells (values, formulas -- reference-adjusted via the same shiftFormula()
  a copy uses -- and formatting) across a target rectangle. target may
  extend the source in any single direction (up/down/left/right); a source
  bigger than one row/column cycles through modulo its own size, so it
  still fills correctly past a non-exact-multiple trailing repetition.
- TableGrid.vue: replaces the old decorative per-active-cell square with a
  proper fill handle -- a small square overlaid at the bottom-right corner
  of the *selection* (not just the anchor cell), draggable. Dragging shows
  a live dashed preview of the cells about to be filled (snapping to
  whichever axis -- vertical or horizontal -- is dragged furthest along,
  same as Excel), and on mouseup commits via fillSelection() and grows the
  selection to source + filled area, keeping the original anchor active.

Verified interactively against the dev server with Playwright: a formula
(=A1*2) filled down four rows lands as =A2*2/=A3*2/=A4*2 with correct
values, and a two-row source dragged right/down six cells cycles its
pattern correctly (1,2,1,2,1,2, including the trailing partial cycle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 10:43:54 +02:00
co-authored by Claude Sonnet 5
parent fafb118db6
commit 830dcb8eeb
4 changed files with 231 additions and 9 deletions
+54
View File
@@ -620,7 +620,61 @@ describe('clipboard: copy / cut / paste', () => {
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
expect(val(s, name, 'D2')).toBe(15);
});
});
describe('fill handle (drag to copy/fill)', () => {
it('fills down: repeats the source rows, formulas shifted per repetition', () => {
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*2');
s.fillSelection(id, { c0: 1, c1: 1, r0: 0, r1: 0 }, { c0: 1, c1: 1, r0: 1, r1: 3 });
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('=A2*2');
expect(s.cellInput(id, coords('B3').col, coords('B3').row)).toBe('=A3*2');
expect(s.cellInput(id, coords('B4').col, coords('B4').row)).toBe('=A4*2');
});
it('fills right: a multi-column source cycles, not just repeats the last column', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '1');
setCell(s, name, 'B1', '2');
// 2-wide source dragged 5 columns right -> C,D,E,F,G cycle 1,2,1,2,1
s.fillSelection(id, { c0: 0, c1: 1, r0: 0, r1: 0 }, { c0: 2, c1: 6, r0: 0, r1: 0 });
expect(val(s, name, 'C1')).toBe(1);
expect(val(s, name, 'D1')).toBe(2);
expect(val(s, name, 'E1')).toBe(1);
expect(val(s, name, 'F1')).toBe(2);
expect(val(s, name, 'G1')).toBe(1); // partial trailing cycle, still fills
});
it('fills up/left too (a drag past the source in either direction)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'C3', '9');
s.fillSelection(id, { c0: 2, c1: 2, r0: 2, r1: 2 }, { c0: 2, c1: 2, r0: 0, r1: 1 }); // up
expect(val(s, name, 'C1')).toBe(9);
expect(val(s, name, 'C2')).toBe(9);
});
it('carries formatting along, 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 });
s.fillSelection(id, { c0: 0, c1: 0, r0: 0, r1: 0 }, { c0: 0, c1: 0, r0: 1, r1: 1 });
expect(s.cellFormat(id, coords('A2').col, coords('A2').row)).toEqual({ bold: true });
});
});
describe('text box objects', () => {
+42
View File
@@ -1003,6 +1003,47 @@ function createStore() {
});
}
/**
* The Excel/Numbers "fill handle": repeat `source`'s cells (values,
* formulas -- reference-adjusted the same way a copy is -- and formatting)
* across `target`, a rectangle extending purely up, down, left or right
* from `source` (never both axes at once -- the caller picks the single
* dominant drag direction). Repeats cyclically, so a `target` that isn't an
* exact multiple of `source`'s size still fills its last partial cycle.
*/
function fillSelection(tableId: string, source: Rect, target: Rect) {
const t = tableById(tableId);
if (!t) return;
const srcW = source.c1 - source.c0 + 1;
const srcH = source.r1 - source.r0 + 1;
const cells = new Map<string, ClipboardCell>();
for (let row = source.r0; row <= source.r1; row++) {
for (let col = source.c0; col <= source.c1; col++) {
const raw = cellInput(tableId, col, row);
const format = t.formats?.[fmtKey(col, row)];
if (raw || format) {
cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined });
}
}
}
for (let row = target.r0; row <= target.r1; row++) {
for (let col = target.c0; col <= target.c1; col++) {
// which cell of the source block this target cell cycles back to
const dc = (((col - source.c0) % srcW) + srcW) % srcW;
const dr = (((row - source.r0) % srcH) + srcH) % srcH;
const cell = cells.get(`${dc},${dr}`);
const raw = cell?.raw ?? '';
const deltaC = col - (source.c0 + dc);
const deltaR = row - (source.r0 + dr);
setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw);
clearRangeFormat(tableId, { col, row }, { col, row });
if (cell?.format) {
setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial<Record<keyof CellFormat, unknown>>);
}
}
}
}
// The "active cell" is always the anchor -- the first cell of the current
// selection action (click, shift-click, drag start, ...) -- so it stays put
// while a multi-cell selection is being made, matching Excel/Numbers.
@@ -1197,6 +1238,7 @@ function createStore() {
cancelClipboard,
hasClipboard,
clipboardRect,
fillSelection,
// document
replaceDocument,
resetToSeed,