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
+59 -7
View File
@@ -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;