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:
@@ -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 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);
|
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 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 clampR = (r: number) => Math.max(0, Math.min((table.value?.rows ?? 1) - 1, r));
|
||||||
const focusRoot = () => nextTick(() => root.value?.focus());
|
const focusRoot = () => nextTick(() => root.value?.focus());
|
||||||
@@ -478,6 +485,25 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
const ext = e.shiftKey;
|
const ext = e.shiftKey;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
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) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -534,6 +560,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
return store.clearSelectionCells();
|
return store.clearSelectionCells();
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
clearFlow();
|
clearFlow();
|
||||||
|
store.cancelClipboard();
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
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);
|
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
|
||||||
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
||||||
}
|
}
|
||||||
const menuItems = computed(() => [
|
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: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
|
||||||
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
||||||
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
||||||
{ label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) },
|
{ label: 'Letzte Spalte entfernen', action: () => store.removeLastColumn(props.tableId) },
|
||||||
{ label: 'Auswahl leeren', action: () => store.clearSelectionCells() },
|
{ label: 'Auswahl leeren', action: () => store.clearSelectionCells() },
|
||||||
]);
|
);
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
// ---- column resize --------------------------------------
|
// ---- column resize --------------------------------------
|
||||||
let resize: { col: number; startX: number; startW: number } | null = null;
|
let resize: { col: number; startX: number; startW: number } | null = null;
|
||||||
@@ -674,6 +709,7 @@ defineExpose({
|
|||||||
:class="{
|
:class="{
|
||||||
'is-active': isActive(c - 1, r - 1),
|
'is-active': isActive(c - 1, r - 1),
|
||||||
'in-range': inSelection(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),
|
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
|
||||||
error: store.cellIsError(tableId, c - 1, r - 1),
|
error: store.cellIsError(tableId, c - 1, r - 1),
|
||||||
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
|
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
|
||||||
@@ -824,6 +860,22 @@ defineExpose({
|
|||||||
.cell.is-active {
|
.cell.is-active {
|
||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
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 {
|
.handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -3px;
|
right: -3px;
|
||||||
|
|||||||
@@ -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', () => {
|
describe('text box objects', () => {
|
||||||
it('insert / edit / format / round-trip a text object', () => {
|
it('insert / edit / format / round-trip a text object', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
+202
-2
@@ -18,7 +18,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
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 {
|
import {
|
||||||
formatValue,
|
formatValue,
|
||||||
isNumeric,
|
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 {
|
function loadInitial(): DocumentState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -90,6 +168,8 @@ function createStore() {
|
|||||||
|
|
||||||
/** View-only state: not part of the document, not persisted. */
|
/** View-only state: not part of the document, not persisted. */
|
||||||
const ui = reactive({ settingsOpen: false });
|
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));
|
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||||
|
|
||||||
function syncWorkbook() {
|
function syncWorkbook() {
|
||||||
@@ -710,7 +790,6 @@ function createStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- selection --------------------------------------
|
// ---- selection --------------------------------------
|
||||||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
|
||||||
function rect(a: CellCoord, b: CellCoord): Rect {
|
function rect(a: CellCoord, b: CellCoord): Rect {
|
||||||
return {
|
return {
|
||||||
c0: Math.min(a.col, b.col),
|
c0: Math.min(a.col, b.col),
|
||||||
@@ -800,6 +879,120 @@ function createStore() {
|
|||||||
setSelection({ tableId, anchor: a, focus: b, ranges });
|
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 activeCell = computed<CellCoord>(() => doc.selection?.focus ?? { col: 0, row: 0 });
|
||||||
|
|
||||||
const activeCellName = computed(() => {
|
const activeCellName = computed(() => {
|
||||||
@@ -984,6 +1177,13 @@ function createStore() {
|
|||||||
activeCellName,
|
activeCellName,
|
||||||
activeCellInput,
|
activeCellInput,
|
||||||
selectionAggregate,
|
selectionAggregate,
|
||||||
|
// clipboard
|
||||||
|
copySelection,
|
||||||
|
cutSelection,
|
||||||
|
pasteClipboard,
|
||||||
|
cancelClipboard,
|
||||||
|
hasClipboard,
|
||||||
|
clipboardRect,
|
||||||
// document
|
// document
|
||||||
replaceDocument,
|
replaceDocument,
|
||||||
resetToSeed,
|
resetToSeed,
|
||||||
|
|||||||
Reference in New Issue
Block a user