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:
@@ -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();
|
||||
|
||||
@@ -100,6 +100,19 @@ interface ClipboardData {
|
||||
anchor: CellCoord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whatever's currently editing a formula (the in-cell editor, or the formula
|
||||
* bar) registers one of these so a click on the grid -- possibly in a
|
||||
* *different* table entirely -- can insert a reference into it. `tableId` is
|
||||
* the table the edited cell itself lives in (the "home" table for deciding
|
||||
* whether an inserted reference needs a `Name!` qualifier).
|
||||
*/
|
||||
interface FormulaEditorHandle {
|
||||
tableId: string;
|
||||
getState(): { text: string; start: number; end: number };
|
||||
replace(start: number, end: number, value: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
|
||||
* relative-reference adjustment Excel/Numbers apply on copy.
|
||||
@@ -177,6 +190,15 @@ function createStore() {
|
||||
const ui = reactive({ settingsOpen: false });
|
||||
/** Copy/cut buffer -- not part of the persisted document. */
|
||||
const clipboard = ref<ClipboardData | null>(null);
|
||||
/** Whichever formula editor is currently open (in-cell editor or formula
|
||||
* bar), if any -- drives "point mode": a grid click inserts a reference
|
||||
* instead of changing the selection. Not part of the document either. */
|
||||
const formulaEditor = ref<FormulaEditorHandle | null>(null);
|
||||
/** The most recently inserted-by-click reference's span in the editor's
|
||||
* text, so the *next* click replaces it instead of appending another --
|
||||
* until the user types something (a plain assignment, not a ref(), so it
|
||||
* never itself triggers reactivity/undo history). */
|
||||
let pointSpan: { start: number; end: number } | null = null;
|
||||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||
|
||||
function syncWorkbook() {
|
||||
@@ -1172,6 +1194,62 @@ function createStore() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- formula "point mode" (click/drag a cell to insert its reference) ---
|
||||
/** Register (or, with `null`, unregister) the editor a grid click should
|
||||
* insert references into. Also drops any in-flight "replace on next
|
||||
* click" state from whichever editor was registered before. */
|
||||
function registerFormulaEditor(handle: FormulaEditorHandle | null) {
|
||||
formulaEditor.value = handle;
|
||||
pointSpan = null;
|
||||
}
|
||||
/** The user typed (as opposed to a click inserting a reference for them):
|
||||
* the next click should insert a fresh reference, not replace the last
|
||||
* one -- typing anything ends "point mode"'s replace-on-click window,
|
||||
* same as Excel (not just `;`, though that's the most common case, since
|
||||
* it's the argument separator you'd type right after picking one cell
|
||||
* and before picking the next). */
|
||||
function noteFormulaTyped() {
|
||||
pointSpan = null;
|
||||
}
|
||||
/** True while the registered editor's buffer is a formula -- point mode
|
||||
* (click-to-insert-reference) only applies then. */
|
||||
function formulaPointModeActive(): boolean {
|
||||
return !!formulaEditor.value && formulaEditor.value.getState().text.startsWith('=');
|
||||
}
|
||||
function qualifyRef(homeTableId: string, targetTableId: string, body: string): string {
|
||||
if (targetTableId === homeTableId) return body;
|
||||
const name = tableById(targetTableId)?.name ?? targetTableId;
|
||||
const qualifier = /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
|
||||
return `${qualifier}!${body}`;
|
||||
}
|
||||
function applyPointRef(targetTableId: string, body: string): boolean {
|
||||
const ed = formulaEditor.value;
|
||||
if (!ed) return false;
|
||||
const st = ed.getState();
|
||||
if (!st.text.startsWith('=')) return false;
|
||||
const text = qualifyRef(ed.tableId, targetTableId, body);
|
||||
const span = pointSpan ?? { start: st.start, end: st.end };
|
||||
ed.replace(span.start, span.end, text);
|
||||
pointSpan = { start: span.start, end: span.start + text.length };
|
||||
return true;
|
||||
}
|
||||
/** A plain click on (tableId, col, row) while editing a formula: insert or
|
||||
* (immediately after another click/drag, before any typing) replace a
|
||||
* reference at the caret. Returns false when there's no formula editor
|
||||
* active, so the caller can fall back to its normal click handling. */
|
||||
function pointClick(tableId: string, col: number, row: number): boolean {
|
||||
return applyPointRef(tableId, toA1(col, row));
|
||||
}
|
||||
/** A drag from (c0,r0) to (c1,r1) on tableId while editing a formula:
|
||||
* insert/replace a *range* reference, meant to be called live as the drag
|
||||
* updates (each call replaces the same span, growing/shrinking the range
|
||||
* as the user drags), not just once at the end. */
|
||||
function pointDrag(tableId: string, c0: number, r0: number, c1: number, r1: number): boolean {
|
||||
const a = toA1(Math.min(c0, c1), Math.min(r0, r1));
|
||||
const b = toA1(Math.max(c0, c1), Math.max(r0, r1));
|
||||
return applyPointRef(tableId, a === b ? a : `${a}:${b}`);
|
||||
}
|
||||
|
||||
// The "active cell" is always the anchor -- the first cell of the current
|
||||
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
||||
// while a multi-cell selection is being made, matching Excel/Numbers.
|
||||
@@ -1382,6 +1460,12 @@ function createStore() {
|
||||
hasClipboard,
|
||||
clipboardRect,
|
||||
fillSelection,
|
||||
// formula "point mode"
|
||||
registerFormulaEditor,
|
||||
noteFormulaTyped,
|
||||
formulaPointModeActive,
|
||||
pointClick,
|
||||
pointDrag,
|
||||
// document
|
||||
replaceDocument,
|
||||
resetToSeed,
|
||||
|
||||
Reference in New Issue
Block a user