diff --git a/README.md b/README.md index 567f07a..30f2903 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt: ```bash npm install 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 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 hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe „`$`-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: @@ -308,7 +315,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 121 Tests: +`npm run test` — Vitest, 127 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` 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 verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen 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 → Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index e0b9b75..f1ef62a 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -188,7 +188,9 @@ function scrollActiveIntoView() { 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; if (!s) return null; return { @@ -429,6 +431,103 @@ function 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(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) { const t = table.value; if (!t) return; @@ -691,6 +790,7 @@ const totalWidth = computed(() => { onBeforeUnmount(() => { onDragUp(); + onFillDragUp(); onResizeUp(); onRowResizeUp(); }); @@ -778,7 +878,6 @@ defineExpose({ /> @@ -786,6 +885,14 @@ defineExpose({
+
+
@@ -932,16 +1039,24 @@ defineExpose({ box-shadow: inset 0 0 0 2px var(--accent); z-index: 8; } -.handle { +/* small square at the selection's bottom-right corner -- drag to fill/copy */ +.fill-handle { position: absolute; - right: -3px; - bottom: -3px; width: 7px; height: 7px; background: var(--accent); border: 1px solid var(--surface); 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 { position: absolute; diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 5fda3e4..d676e5a 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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', () => { diff --git a/src/store/document.ts b/src/store/document.ts index 44c1cde..80a2e9c 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -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(); + 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>); + } + } + } + } + // 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,