Add the fill handle: drag the selection's corner to copy/fill, Excel-style

- store: fillSelection(tableId, source, target) repeats a source rectangle's
  cells (values, formulas -- reference-adjusted via the same shiftFormula()
  a copy uses -- and formatting) across a target rectangle. target may
  extend the source in any single direction (up/down/left/right); a source
  bigger than one row/column cycles through modulo its own size, so it
  still fills correctly past a non-exact-multiple trailing repetition.
- TableGrid.vue: replaces the old decorative per-active-cell square with a
  proper fill handle -- a small square overlaid at the bottom-right corner
  of the *selection* (not just the anchor cell), draggable. Dragging shows
  a live dashed preview of the cells about to be filled (snapping to
  whichever axis -- vertical or horizontal -- is dragged furthest along,
  same as Excel), and on mouseup commits via fillSelection() and grows the
  selection to source + filled area, keeping the original anchor active.

Verified interactively against the dev server with Playwright: a formula
(=A1*2) filled down four rows lands as =A2*2/=A3*2/=A4*2 with correct
values, and a two-row source dragged right/down six cells cycles its
pattern correctly (1,2,1,2,1,2, including the trailing partial cycle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 10:43:54 +02:00
co-authored by Claude Sonnet 5
parent fafb118db6
commit 830dcb8eeb
4 changed files with 231 additions and 9 deletions
+121 -6
View File
@@ -188,7 +188,9 @@ function scrollActiveIntoView() {
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
}
function primaryRect() {
type Rect = { c0: number; c1: number; r0: number; r1: number };
function primaryRect(): Rect | null {
const s = mine.value;
if (!s) return null;
return {
@@ -429,6 +431,103 @@ function onDragUp() {
window.removeEventListener('blur', onDragUp);
}
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
* copy --, and formatting) into the cells dragged over. Extends in a single
* direction only, whichever axis is dragged further along, same as Excel. */
let fillDrag: { source: Rect; anchor: { col: number; row: number } } | null = null;
const fillPreview = ref<Rect | null>(null);
const fillHandleStyle = computed(() => {
if (editing.value || dragging.value || fillDrag) return null;
const r = primaryRect();
if (!r) return null;
return { left: colLeft(r.c1 + 1) - 3 + 'px', top: rowTop(r.r1 + 1) - 3 + 'px' };
});
const fillPreviewStyle = computed(() => {
const p = fillPreview.value;
if (!p) return null;
const left = colLeft(p.c0);
const top = rowTop(p.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(p.c1 + 1) - left + 'px',
height: rowTop(p.r1 + 1) - top + 'px',
};
});
function onFillHandleDown(e: MouseEvent) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const s = mine.value;
const r = primaryRect();
if (!s || !r) return;
if (editing.value) commitEdit();
fillDrag = { source: r, anchor: s.anchor };
fillPreview.value = null;
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onFillDragMove, true);
window.addEventListener('mouseup', onFillDragUp, true);
window.addEventListener('blur', onFillDragUp);
}
function onFillDragMove(e: MouseEvent) {
if (!fillDrag) return;
if (e.buttons === 0) {
onFillDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const src = fillDrag.source;
const c = clampC(hit.c);
const r = clampR(hit.r);
const down = r - src.r1;
const up = src.r0 - r;
const right = c - src.c1;
const left = src.c0 - c;
const vAmount = Math.max(down, up, 0);
const hAmount = Math.max(right, left, 0);
if (vAmount === 0 && hAmount === 0) {
fillPreview.value = null; // back over the source -- nothing to fill
return;
}
fillPreview.value =
vAmount >= hAmount
? down > 0
? { c0: src.c0, c1: src.c1, r0: src.r1 + 1, r1: r }
: { c0: src.c0, c1: src.c1, r0: r, r1: src.r0 - 1 }
: right > 0
? { c0: src.c1 + 1, c1: c, r0: src.r0, r1: src.r1 }
: { c0: c, c1: src.c0 - 1, r0: src.r0, r1: src.r1 };
}
function onFillDragUp() {
if (!fillDrag) return;
const { source, anchor } = fillDrag;
const target = fillPreview.value;
fillDrag = null;
fillPreview.value = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onFillDragMove, true);
window.removeEventListener('mouseup', onFillDragUp, true);
window.removeEventListener('blur', onFillDragUp);
if (!target) return;
store.fillSelection(props.tableId, source, target);
// grow the selection to source + filled area, keeping the original anchor active
const c0 = Math.min(source.c0, target.c0);
const c1 = Math.max(source.c1, target.c1);
const r0 = Math.min(source.r0, target.r0);
const r1 = Math.max(source.r1, target.r1);
store.setSelection({
tableId: props.tableId,
anchor,
focus: { col: anchor.col === c0 ? c1 : c0, row: anchor.row === r0 ? r1 : r0 },
});
focusRoot();
}
function selectColumn(c: number, e?: MouseEvent) {
const t = table.value;
if (!t) return;
@@ -691,6 +790,7 @@ const totalWidth = computed(() => {
onBeforeUnmount(() => {
onDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
});
@@ -778,7 +878,6 @@ defineExpose({
/>
<template v-else>
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
<span v-if="isActive(c - 1, r - 1)" class="handle" />
</template>
</td>
</tr>
@@ -786,6 +885,14 @@ defineExpose({
</table>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
class="fill-handle"
:style="fillHandleStyle"
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
</div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -932,16 +1039,24 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8;
}
.handle {
/* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute;
right: -3px;
bottom: -3px;
width: 7px;
height: 7px;
background: var(--accent);
border: 1px solid var(--surface);
border-radius: 2px;
z-index: 6;
z-index: 9;
cursor: crosshair;
}
/* preview of the cells a fill-handle drag is about to fill */
.fill-preview {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
background: var(--accent-weak);
z-index: 8;
}
.cell-input {
position: absolute;