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:
+202
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user