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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user