diff --git a/src/components/TableGrid.vue b/src/components/TableGrid.vue index 3ba4d04..7d366d3 100644 --- a/src/components/TableGrid.vue +++ b/src/components/TableGrid.vue @@ -118,6 +118,13 @@ const inSelection = (c: number, r: number) => const colHot = (c: number) => selRects.value.some((k) => c >= k.c0 && c <= k.c1); const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1); +// ---- clipboard source marker (dashed outline, Excel-style "marching ants") -- +const clipRect = computed(() => store.clipboardRect.value); +const inClipSource = (c: number, r: number) => { + const k = clipRect.value; + return !!k && k.tableId === props.tableId && c >= k.c0 && c <= k.c1 && r >= k.r0 && r <= k.r1; +}; + const clampC = (c: number) => Math.max(0, Math.min((table.value?.cols ?? 1) - 1, c)); const clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r)); const focusRoot = () => nextTick(() => root.value?.focus()); @@ -478,6 +485,25 @@ function onKeydown(e: KeyboardEvent) { const ext = e.shiftKey; const mod = e.ctrlKey || e.metaKey; + if (mod) { + const k = e.key.toLowerCase(); + if (k === 'c') { + e.preventDefault(); + e.stopPropagation(); + return store.copySelection(); + } + if (k === 'x') { + e.preventDefault(); + e.stopPropagation(); + return store.cutSelection(); + } + if (k === 'v') { + e.preventDefault(); + e.stopPropagation(); + return store.pasteClipboard(); + } + } + switch (e.key) { case 'ArrowUp': e.preventDefault(); @@ -534,6 +560,7 @@ function onKeydown(e: KeyboardEvent) { return store.clearSelectionCells(); case 'Escape': clearFlow(); + store.cancelClipboard(); return; default: if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { @@ -552,13 +579,21 @@ function onContextMenu(e: MouseEvent) { if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r); menu.value = { x: e.clientX, y: e.clientY, open: true }; } -const menuItems = computed(() => [ - { label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) }, - { label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) }, - { label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) }, - { label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) }, - { label: 'Auswahl leeren', action: () => store.clearSelectionCells() }, -]); +const menuItems = computed(() => { + const items = [ + { label: 'Kopieren', action: () => store.copySelection() }, + { label: 'Ausschneiden', action: () => store.cutSelection() }, + ]; + if (store.hasClipboard.value) items.push({ label: 'Einfügen', action: () => store.pasteClipboard() }); + items.push( + { label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) }, + { label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) }, + { label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) }, + { label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) }, + { label: 'Auswahl leeren', action: () => store.clearSelectionCells() }, + ); + return items; +}); // ---- column resize -------------------------------------- let resize: { col: number; startX: number; startW: number } | null = null; @@ -674,6 +709,7 @@ defineExpose({ :class="{ 'is-active': isActive(c - 1, r - 1), 'in-range': inSelection(c - 1, r - 1), + 'clip-src': inClipSource(c - 1, r - 1), numeric: store.cellIsNumeric(tableId, c - 1, r - 1), error: store.cellIsError(tableId, c - 1, r - 1), frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows), @@ -824,6 +860,22 @@ defineExpose({ .cell.is-active { box-shadow: inset 0 0 0 2px var(--accent); } +.cell.clip-src { + outline: 1px dashed var(--accent); + outline-offset: -1px; + animation: clip-src-march 0.6s linear infinite; +} +@keyframes clip-src-march { + from { + outline-offset: -1px; + } + 50% { + outline-offset: -2px; + } + to { + outline-offset: -1px; + } +} .handle { position: absolute; right: -3px; diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 02e8fb3..bfdf98c 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -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(); diff --git a/src/store/document.ts b/src/store/document.ts index 25247e9..a5366cd 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -18,7 +18,17 @@ */ import { computed, reactive, ref, watch } from 'vue'; -import { Workbook, toA1, isError, type CellValue, type TableModel } from '../engine'; +import { + Workbook, + toA1, + isError, + tokenize, + looksLikeCell, + parseA1, + type CellValue, + type TableModel, + type Token, +} from '../engine'; import { formatValue, isNumeric, @@ -66,6 +76,74 @@ function toTableModels(doc: DocumentState): TableModel[] { })); } +/** A normalised rectangle (corners in either order) used by selection + clipboard. */ +type Rect = { c0: number; c1: number; r0: number; r1: number }; + +/** One cell of a copy/cut buffer, relative to the buffer's top-left corner. */ +interface ClipboardCell { + raw: string; + format?: CellFormat; +} +interface ClipboardData { + tableId: string; + rect: Rect; + width: number; + height: number; + /** "dc,dr" (offset from the buffer's top-left) -> cell. Empty/unformatted cells are absent. */ + cells: Map; + /** cut (Strg+X) vs. copy (Strg+C) -- a cut buffer is consumed by its first paste. */ + cut: boolean; +} + +/** + * Rewrite every cell reference in a formula by (dCol, dRow) -- the same + * relative-reference adjustment Excel/Numbers apply on copy. + * + * `restrict`, when given (a cut/move), narrows this to *only* references that + * point inside that rectangle -- i.e. cells that moved along with the formula. + * A cut otherwise leaves references untouched, since the cells they point at + * did not move (and a qualified `Table!A1` reference is never "inside" a + * local rectangle, so it is always left alone during a cut). + * + * The formula engine has no absolute (`$A$1`) references, so every local + * reference is relative and eligible for this adjustment. + */ +function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): string { + if (!raw.startsWith('=') || (dCol === 0 && dRow === 0)) return raw; + const body = raw.slice(1); + let toks: Token[]; + try { + toks = tokenize(body); + } catch { + return raw; // unparsable -- leave as-is, the evaluator will surface #ERR + } + let out = ''; + let last = 0; + for (let i = 0; i < toks.length; i++) { + const tok = toks[i]; + if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue; + const next = toks[i + 1]; + if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell + const prev = toks[i - 1]; + const qualified = !!prev && (prev.type === 'BANG' || prev.type === 'DBLCOLON'); + if (restrict && qualified) continue; // cut: a reference into another table never moved + const { col, row } = parseA1(tok.value); + if ( + restrict && + !qualified && + !(col >= restrict.c0 && col <= restrict.c1 && row >= restrict.r0 && row <= restrict.r1) + ) { + continue; + } + const nc = Math.max(0, col + dCol); + const nr = Math.max(0, row + dRow); + out += body.slice(last, tok.pos) + toA1(nc, nr); + last = tok.pos + tok.value.length; + } + out += body.slice(last); + return '=' + out; +} + function loadInitial(): DocumentState { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -90,6 +168,8 @@ function createStore() { /** View-only state: not part of the document, not persisted. */ const ui = reactive({ settingsOpen: false }); + /** Copy/cut buffer -- not part of the persisted document. */ + const clipboard = ref(null); const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20)); function syncWorkbook() { @@ -710,7 +790,6 @@ function createStore() { } // ---- selection -------------------------------------- - type Rect = { c0: number; c1: number; r0: number; r1: number }; function rect(a: CellCoord, b: CellCoord): Rect { return { c0: Math.min(a.col, b.col), @@ -800,6 +879,120 @@ function createStore() { setSelection({ tableId, anchor: a, focus: b, ranges }); } + // ---- clipboard (copy / cut / paste) -------------------------- + /** True while a copy/cut buffer is held (enables "Einfügen" in menus). */ + const hasClipboard = computed(() => clipboard.value !== null); + /** Source rectangle of the current buffer -- drives the dashed source marker. */ + const clipboardRect = computed(() => { + const cb = clipboard.value; + return cb ? { tableId: cb.tableId, ...cb.rect, cut: cb.cut } : null; + }); + + function copyOrCut(cut: boolean) { + const s = doc.selection; + const t = s ? tableById(s.tableId) : undefined; + if (!s || !t) return; + const r = rect(s.anchor, s.focus); + const cells = new Map(); + for (let row = r.r0; row <= r.r1; row++) { + for (let col = r.c0; col <= r.c1; col++) { + const raw = cellInput(s.tableId, col, row); + const format = t.formats?.[fmtKey(col, row)]; + if (raw || format) { + cells.set(`${col - r.c0},${row - r.r0}`, { raw, format: format ? { ...format } : undefined }); + } + } + } + clipboard.value = { + tableId: s.tableId, + rect: r, + width: r.c1 - r.c0 + 1, + height: r.r1 - r.r0 + 1, + cells, + cut, + }; + } + const copySelection = () => copyOrCut(false); + const cutSelection = () => copyOrCut(true); + /** Drop the buffer (Escape) without pasting. */ + function cancelClipboard() { + clipboard.value = null; + } + + /** + * Paste the buffer at the current selection. A selection that is an exact, + * larger multiple of the buffer tiles it (Numbers/Excel "fill by paste"); + * a smaller or single-cell selection anchors the buffer there and overflows + * past it, same as pasting a whole block onto one cell. + */ + function pasteClipboard() { + const cb = clipboard.value; + const s = doc.selection; + if (!cb || !s || !tableById(s.tableId)) return; + + const dest = rect(s.anchor, s.focus); + const destW = dest.c1 - dest.c0 + 1; + const destH = dest.r1 - dest.r0 + 1; + const tileX = destW > cb.width && destW % cb.width === 0 ? destW / cb.width : 1; + const tileY = destH > cb.height && destH % cb.height === 0 ? destH / cb.height : 1; + + for (let ty = 0; ty < tileY; ty++) { + for (let tx = 0; tx < tileX; tx++) { + const baseC = dest.c0 + tx * cb.width; + const baseR = dest.r0 + ty * cb.height; + const dCol = baseC - cb.rect.c0; + const dRow = baseR - cb.rect.r0; + for (let dr = 0; dr < cb.height; dr++) { + for (let dc = 0; dc < cb.width; dc++) { + const c = baseC + dc; + const r = baseR + dr; + const cell = cb.cells.get(`${dc},${dr}`); + const raw = cell?.raw ?? ''; + setCellInput( + s.tableId, + c, + r, + raw ? shiftFormula(raw, dCol, dRow, cb.cut ? cb.rect : undefined) : raw, + ); + clearRangeFormat(s.tableId, { col: c, row: r }, { col: c, row: r }); + if (cell?.format) { + setRangeFormat( + s.tableId, + { col: c, row: r }, + { col: c, row: r }, + cell.format as Partial>, + ); + } + } + } + } + } + + if (cb.cut) { + // clear the moved-from cells, except any the paste itself just wrote into + const destC1 = dest.c0 + tileX * cb.width - 1; + const destR1 = dest.r0 + tileY * cb.height - 1; + for (let row = cb.rect.r0; row <= cb.rect.r1; row++) { + for (let col = cb.rect.c0; col <= cb.rect.c1; col++) { + const overlap = + cb.tableId === s.tableId && + col >= dest.c0 && col <= destC1 && row >= dest.r0 && row <= destR1; + if (!overlap) { + setCellInput(cb.tableId, col, row, ''); + clearRangeFormat(cb.tableId, { col, row }, { col, row }); + } + } + } + clipboard.value = null; // a cut buffer is consumed by its first paste + } + + setSelection({ + tableId: s.tableId, + anchor: { col: dest.c0, row: dest.r0 }, + focus: { col: dest.c0 + tileX * cb.width - 1, row: dest.r0 + tileY * cb.height - 1 }, + }); + } + const activeCell = computed(() => doc.selection?.focus ?? { col: 0, row: 0 }); const activeCellName = computed(() => { @@ -984,6 +1177,13 @@ function createStore() { activeCellName, activeCellInput, selectionAggregate, + // clipboard + copySelection, + cutSelection, + pasteClipboard, + cancelClipboard, + hasClipboard, + clipboardRect, // document replaceDocument, resetToSeed,