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
+14 -3
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 121 Tests npm run test # Vitest-Suite (Engine + Store + IO), 127 Tests
npm run build # Typecheck + Produktionsbuild npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
``` ```
@@ -75,7 +75,14 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber
hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe
„`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die „`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die
Kopierquelle, `Esc` verwirft den Puffer. Kopierquelle (mit eigenem Rahmen um deren erste Zelle, bleibt stehen, auch
wenn die aktuelle Auswahl längst woanders ist), `Esc` verwirft den Puffer.
- **Ausfüllkästchen** (das kleine Quadrat unten rechts an der Auswahl, wie in
Excel/Numbers): ziehen wiederholt die Auswahl — Werte, Formeln
(bezugsangepasst) und Formatierung — in die überstrichenen Zellen. Nur eine
Achse gleichzeitig (rein runter/hoch/rechts/links, je nachdem wohin weiter
gezogen wird); ein mehrzeiliger/-spaltiger Ausgangsbereich wird zyklisch
wiederholt, auch über eine unvollständige letzte Wiederholung hinweg.
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle: **Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
@@ -308,7 +315,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 121 Tests: `npm run test` — Vitest, 127 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -353,6 +360,10 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der
verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen
unangetastet und leert die Quelle; `Esc` verwirft den Puffer. unangetastet und leert die Quelle; `Esc` verwirft den Puffer.
- **Ausfüllkästchen** — füllt runter/rechts mit bezugsangepassten Formeln;
ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
Formatierung wird mitkopiert.
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei → - **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
+121 -6
View File
@@ -188,7 +188,9 @@ function scrollActiveIntoView() {
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight; else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
} }
function primaryRect() { type Rect = { c0: number; c1: number; r0: number; r1: number };
function primaryRect(): Rect | null {
const s = mine.value; const s = mine.value;
if (!s) return null; if (!s) return null;
return { return {
@@ -429,6 +431,103 @@ function onDragUp() {
window.removeEventListener('blur', onDragUp); window.removeEventListener('blur', onDragUp);
} }
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
* copy --, and formatting) into the cells dragged over. Extends in a single
* direction only, whichever axis is dragged further along, same as Excel. */
let fillDrag: { source: Rect; anchor: { col: number; row: number } } | null = null;
const fillPreview = ref<Rect | null>(null);
const fillHandleStyle = computed(() => {
if (editing.value || dragging.value || fillDrag) return null;
const r = primaryRect();
if (!r) return null;
return { left: colLeft(r.c1 + 1) - 3 + 'px', top: rowTop(r.r1 + 1) - 3 + 'px' };
});
const fillPreviewStyle = computed(() => {
const p = fillPreview.value;
if (!p) return null;
const left = colLeft(p.c0);
const top = rowTop(p.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(p.c1 + 1) - left + 'px',
height: rowTop(p.r1 + 1) - top + 'px',
};
});
function onFillHandleDown(e: MouseEvent) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const s = mine.value;
const r = primaryRect();
if (!s || !r) return;
if (editing.value) commitEdit();
fillDrag = { source: r, anchor: s.anchor };
fillPreview.value = null;
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onFillDragMove, true);
window.addEventListener('mouseup', onFillDragUp, true);
window.addEventListener('blur', onFillDragUp);
}
function onFillDragMove(e: MouseEvent) {
if (!fillDrag) return;
if (e.buttons === 0) {
onFillDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const src = fillDrag.source;
const c = clampC(hit.c);
const r = clampR(hit.r);
const down = r - src.r1;
const up = src.r0 - r;
const right = c - src.c1;
const left = src.c0 - c;
const vAmount = Math.max(down, up, 0);
const hAmount = Math.max(right, left, 0);
if (vAmount === 0 && hAmount === 0) {
fillPreview.value = null; // back over the source -- nothing to fill
return;
}
fillPreview.value =
vAmount >= hAmount
? down > 0
? { c0: src.c0, c1: src.c1, r0: src.r1 + 1, r1: r }
: { c0: src.c0, c1: src.c1, r0: r, r1: src.r0 - 1 }
: right > 0
? { c0: src.c1 + 1, c1: c, r0: src.r0, r1: src.r1 }
: { c0: c, c1: src.c0 - 1, r0: src.r0, r1: src.r1 };
}
function onFillDragUp() {
if (!fillDrag) return;
const { source, anchor } = fillDrag;
const target = fillPreview.value;
fillDrag = null;
fillPreview.value = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onFillDragMove, true);
window.removeEventListener('mouseup', onFillDragUp, true);
window.removeEventListener('blur', onFillDragUp);
if (!target) return;
store.fillSelection(props.tableId, source, target);
// grow the selection to source + filled area, keeping the original anchor active
const c0 = Math.min(source.c0, target.c0);
const c1 = Math.max(source.c1, target.c1);
const r0 = Math.min(source.r0, target.r0);
const r1 = Math.max(source.r1, target.r1);
store.setSelection({
tableId: props.tableId,
anchor,
focus: { col: anchor.col === c0 ? c1 : c0, row: anchor.row === r0 ? r1 : r0 },
});
focusRoot();
}
function selectColumn(c: number, e?: MouseEvent) { function selectColumn(c: number, e?: MouseEvent) {
const t = table.value; const t = table.value;
if (!t) return; if (!t) return;
@@ -691,6 +790,7 @@ const totalWidth = computed(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
onDragUp(); onDragUp();
onFillDragUp();
onResizeUp(); onResizeUp();
onRowResizeUp(); onRowResizeUp();
}); });
@@ -778,7 +878,6 @@ defineExpose({
/> />
<template v-else> <template v-else>
{{ store.cellDisplay(tableId, c - 1, r - 1) }} {{ store.cellDisplay(tableId, c - 1, r - 1) }}
<span v-if="isActive(c - 1, r - 1)" class="handle" />
</template> </template>
</td> </td>
</tr> </tr>
@@ -786,6 +885,14 @@ defineExpose({
</table> </table>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" /> <div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" /> <div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
class="fill-handle"
:style="fillHandleStyle"
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
</div> </div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" /> <ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -932,16 +1039,24 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8; z-index: 8;
} }
.handle { /* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute; position: absolute;
right: -3px;
bottom: -3px;
width: 7px; width: 7px;
height: 7px; height: 7px;
background: var(--accent); background: var(--accent);
border: 1px solid var(--surface); border: 1px solid var(--surface);
border-radius: 2px; border-radius: 2px;
z-index: 6; z-index: 9;
cursor: crosshair;
}
/* preview of the cells a fill-handle drag is about to fill */
.fill-preview {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
background: var(--accent-weak);
z-index: 8;
} }
.cell-input { .cell-input {
position: absolute; position: absolute;
+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(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
expect(val(s, name, 'D2')).toBe(15); 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', () => { 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 // The "active cell" is always the anchor -- the first cell of the current
// selection action (click, shift-click, drag start, ...) -- so it stays put // selection action (click, shift-click, drag start, ...) -- so it stays put
// while a multi-cell selection is being made, matching Excel/Numbers. // while a multi-cell selection is being made, matching Excel/Numbers.
@@ -1197,6 +1238,7 @@ function createStore() {
cancelClipboard, cancelClipboard,
hasClipboard, hasClipboard,
clipboardRect, clipboardRect,
fillSelection,
// document // document
replaceDocument, replaceDocument,
resetToSeed, resetToSeed,