Add cell selection copy/cut/paste with formula reference adjustment

- store: copySelection/cutSelection/pasteClipboard/cancelClipboard hold an
  in-memory (unpersisted) clipboard buffer of raw cell inputs + formatting.
- shiftFormula rewrites every relative cell reference in a copied/cut formula
  by the move delta (the engine has no $-absolute refs, so all refs qualify).
  Copy shifts every reference uniformly; cut only follows references that
  point inside the moved block itself, leaving refs to untouched cells alone
  and never touching a qualified Table!A1 reference.
- paste anchors on a smaller/single-cell selection (and overflows past it),
  or tiles across a selection that's an exact larger multiple of the buffer.
- TableGrid: Strg+C/X/V shortcuts, context-menu entries, a dashed "clip-src"
  source marker, and Escape drops a pending buffer.
- document.spec.ts: coverage for reference shifting, tiled/fill paste, cut's
  inside/outside reference split, format copy, and Escape cancelling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 09:55:09 +02:00
co-authored by Claude Sonnet 5
parent 31108977b9
commit 44f3314137
3 changed files with 364 additions and 9 deletions
+202 -2
View File
@@ -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<string, ClipboardCell>;
/** 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<ClipboardData | null>(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<string, ClipboardCell>();
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<Record<keyof CellFormat, unknown>>,
);
}
}
}
}
}
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<CellCoord>(() => 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,