Add formula "point mode": click/drag a cell to insert its reference

Excel/Numbers-style reference picking: while editing a formula (buffer
starts with "="), a click on any grid cell -- in the same table or a
completely different one -- inserts its reference at the caret instead of
changing the selection; a drag inserts/grows a range reference live as you
drag. A further click/drag before typing anything replaces the just-inserted
reference; typing anything (";" to separate SUM() arguments included, but
not special-cased -- any character ends the window, same as Excel) makes the
next click append a fresh one instead.

- document.ts: whichever editor is open (in-cell editor or formula bar)
  registers a small handle (home tableId + read/replace the buffer);
  pointClick()/pointDrag() insert or replace a reference into it, qualifying
  with `Name!` (quoted if needed) when the clicked table differs from the
  editor's own. formulaPointModeActive() gates it on the buffer being a
  formula; noteFormulaTyped() (wired to real typing, not the programmatic
  inserts) ends the "replace" window.
- TableGrid.vue: onCellMouseDown checks point mode before its normal
  selection/edit handling, tracking a drag the same way normal selection
  does; dblclick during point mode no longer hijacks into editing that cell.
- FormulaBar.vue: registers on focus, same click/drag targeting works from
  there without disturbing the grid's own selection.

Verified interactively against the dev server with Playwright, including a
real bug the store-level tests (a mocked editor handle, no real DOM) missed
entirely: preventDefault() alone kept focus on the editor, but the mousedown
still bubbled to CanvasObject.vue's own handler, which selects/brings the
clicked table to front -- closing the very editor being pointed into. Fixed
with stopPropagation() alongside it. After that fix: same-table and
cross-table click/drag both insert correctly-qualified references, a second
click replaces before typing and appends after, and the live selection/
active object are untouched by a point-mode click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 14:41:27 +02:00
co-authored by Claude Sonnet 5
parent 5a06d08a6a
commit fb5915d5be
5 changed files with 339 additions and 6 deletions
+86 -3
View File
@@ -415,6 +415,17 @@ function startDrag(mode: 'replace' | 'additive') {
}
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
if (e.button !== 0) return;
if (store.formulaPointModeActive()) {
e.preventDefault(); // keep focus on the formula editor -- must not blur/commit it
// ... and don't let it bubble to the canvas object's own mousedown
// (CanvasObject.vue selects/brings-to-front the clicked table on any
// mousedown; a point-mode click must stay invisible to that, same as
// Excel doesn't change your object selection while you're just picking
// a reference for a formula elsewhere).
e.stopPropagation();
startPointDrag(c, r);
return;
}
if (editing.value) commitEdit();
clearFlow();
lastPointer = { x: e.clientX, y: e.clientY };
@@ -451,6 +462,37 @@ function onDragUp() {
window.removeEventListener('blur', onDragUp);
}
// ---- formula "point mode" (click/drag a cell to insert its reference) ---
let pointDragStart: { c: number; r: number } | null = null;
function startPointDrag(c: number, r: number) {
pointDragStart = { c, r };
store.pointClick(props.tableId, c, r);
window.addEventListener('mousemove', onPointDragMove, true);
window.addEventListener('mouseup', onPointDragUp, true);
window.addEventListener('blur', onPointDragUp);
}
function onPointDragMove(e: MouseEvent) {
if (!pointDragStart) return;
if (e.buttons === 0) {
onPointDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
if (hit.c === pointDragStart.c && hit.r === pointDragStart.r) {
store.pointClick(props.tableId, hit.c, hit.r);
} else {
store.pointDrag(props.tableId, pointDragStart.c, pointDragStart.r, hit.c, hit.r);
}
}
function onPointDragUp() {
if (!pointDragStart) return;
pointDragStart = null;
window.removeEventListener('mousemove', onPointDragMove, true);
window.removeEventListener('mouseup', onPointDragUp, true);
window.removeEventListener('blur', onPointDragUp);
}
// ---- 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
@@ -624,9 +666,35 @@ function resizeEditor() {
ta.style.height = Math.min(mirror.offsetHeight, EDIT_MAX_H) + 'px';
}
/** Lets the store insert a reference into this editor when the user clicks
* a grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
function formulaEditorHandle() {
return {
tableId: props.tableId,
getState: () => {
const el = editInput.value;
return {
text: editValue.value,
start: el?.selectionStart ?? editValue.value.length,
end: el?.selectionEnd ?? editValue.value.length,
};
},
replace: (start: number, end: number, value: string) => {
editValue.value = editValue.value.slice(0, start) + value + editValue.value.slice(end);
nextTick(() => {
const el = editInput.value;
el?.focus();
el?.setSelectionRange(start + value.length, start + value.length);
resizeEditor();
});
},
};
}
function beginEdit(c: number, r: number, initial?: string) {
editing.value = { col: c, row: r };
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
store.registerFormulaEditor(formulaEditorHandle());
nextTick(() => {
const el = editInput.value;
if (!el) return;
@@ -642,11 +710,13 @@ function commitEdit() {
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
editing.value = null;
store.registerFormulaEditor(null);
focusRoot();
setTimeout(() => (committing = false), 0);
}
function cancelEdit() {
editing.value = null;
store.registerFormulaEditor(null);
focusRoot();
}
function onEditKeydown(e: KeyboardEvent) {
@@ -709,6 +779,18 @@ function toggleReferenceLock() {
function onEditBlur() {
if (!committing && editing.value) commitEdit();
}
/** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */
function onEditInput() {
resizeEditor();
store.noteFormulaTyped();
}
/** Clicking inside the field to reposition the caret should also end that
* window (mousedown so it fires before any selection change is read). */
function onEditMousedown(e: MouseEvent) {
e.stopPropagation();
store.noteFormulaTyped();
}
// ---- keyboard navigation (Excel-like) ------------------------
function onKeydown(e: KeyboardEvent) {
@@ -889,6 +971,7 @@ const totalWidth = computed(() => {
onBeforeUnmount(() => {
onDragUp();
onPointDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
@@ -960,7 +1043,7 @@ defineExpose({
}"
:style="cellStyle(c - 1, r - 1)"
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
@dblclick="beginEdit(c - 1, r - 1)"
@dblclick="!store.formulaPointModeActive() && beginEdit(c - 1, r - 1)"
>
<div
class="cell-content"
@@ -998,9 +1081,9 @@ defineExpose({
spellcheck="false"
rows="1"
@keydown="onEditKeydown"
@input="resizeEditor"
@input="onEditInput"
@blur="onEditBlur"
@mousedown.stop
@mousedown="onEditMousedown"
/>
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
</div>