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:
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user