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
+111
View File
@@ -800,6 +800,117 @@ describe('undo / redo', () => {
});
});
/** A minimal in-memory stand-in for the in-cell editor / formula bar, for
* exercising store.pointClick/pointDrag without a DOM. */
function mockEditor(tableId: string, initialText: string) {
let text = initialText;
let start = text.length;
let end = text.length;
return {
handle: {
tableId,
getState: () => ({ text, start, end }),
replace: (s: number, e: number, value: string) => {
text = text.slice(0, s) + value + text.slice(e);
start = end = s + value.length;
},
},
get text() {
return text;
},
};
}
describe('formula "point mode" (click/drag a cell to insert a reference)', () => {
// registerFormulaEditor() is store-level state a real editor would always
// clear on blur/commit -- tests need the same discipline, since the store
// is a module-level singleton and fresh() doesn't touch it.
afterEach(() => useDocument().registerFormulaEditor(null));
it('a click inserts a local reference at the caret', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
expect(s.pointClick(id, 0, 0)).toBe(true);
expect(ed.text).toBe('=A1');
});
it('does nothing (returns false) when the buffer is not a formula', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, 'plain text');
s.registerFormulaEditor(ed.handle);
expect(s.pointClick(id, 0, 0)).toBe(false);
expect(ed.text).toBe('plain text');
});
it('a second click before typing replaces the just-inserted reference', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
s.pointClick(id, 0, 0); // A1
s.pointClick(id, 1, 1); // replaces it -> B2
expect(ed.text).toBe('=B2');
});
it('typing between two clicks makes the second an insert, not a replace', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
s.pointClick(id, 0, 0); // =A1
s.noteFormulaTyped(); // the user typed something themselves (e.g. ";")
const end = ed.handle.getState().end;
ed.handle.replace(end, end, ';');
s.pointClick(id, 1, 0); // appends a fresh reference after the ";"
expect(ed.text).toBe('=A1;B1');
});
it('a drag inserts/grows a range reference, replacing itself as it grows', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=SUM(');
s.registerFormulaEditor(ed.handle);
s.pointDrag(id, 0, 0, 0, 0); // A1
expect(ed.text).toBe('=SUM(A1');
s.pointDrag(id, 0, 0, 2, 3); // grows to A1:C4 -- replaces, doesn't append
expect(ed.text).toBe('=SUM(A1:C4');
});
it('a click on a different table qualifies the reference with its name', () => {
const s = fresh();
const home = s.addTable(s.doc.activeSheetId);
const other = s.addTable(s.doc.activeSheetId);
s.renameObject(other, 'Kosten Q3'); // needs quoting (space)
const ed = mockEditor(home, '=');
s.registerFormulaEditor(ed.handle);
s.pointClick(other, 0, 0);
expect(ed.text).toBe("='Kosten Q3'!A1");
});
it("a click on the editor's own table needs no qualifier", () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
s.pointClick(id, 3, 4);
expect(ed.text).toBe('=D5');
});
it('formulaPointModeActive reflects the registered editor, if any', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
expect(s.formulaPointModeActive()).toBe(false);
const ed = mockEditor(id, '=');
s.registerFormulaEditor(ed.handle);
expect(s.formulaPointModeActive()).toBe(true);
s.registerFormulaEditor(null);
expect(s.formulaPointModeActive()).toBe(false);
});
});
describe('text box objects', () => {
it('insert / edit / format / round-trip a text object', () => {
const s = fresh();