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:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
npm run dev # Vite-Devserver
|
||||||
npm run test # Vitest-Suite (Engine + Store + IO), 138 Tests
|
npm run test # Vitest-Suite (Engine + Store + IO), 146 Tests
|
||||||
npm run build # Typecheck + Produktionsbuild
|
npm run build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -136,6 +136,17 @@ an der unteren Kante lässt sie sich per **Maus** (natives `resize: vertical`)
|
|||||||
auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. `Alt`+`Enter`
|
auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. `Alt`+`Enter`
|
||||||
bzw. `Cmd`+`Enter`, `F4` und `Esc` verhalten sich wie im In-Zellen-Editor.
|
bzw. `Cmd`+`Enter`, `F4` und `Esc` verhalten sich wie im In-Zellen-Editor.
|
||||||
|
|
||||||
|
**Bezüge per Maus einfügen ("Point-Mode")**: sobald der Editor (Zelle oder
|
||||||
|
Formelleiste) eine Formel (`=…`) enthält, fügt ein **Klick** auf eine Zelle —
|
||||||
|
in derselben Tabelle **oder einer ganz anderen**, mit `Name!`-Qualifier bei
|
||||||
|
Bedarf `'in Quotes'` — deren Bezug an der Cursorposition ein; **Ziehen**
|
||||||
|
fügt stattdessen einen Bereichsbezug (`A1:C3`) ein, live wachsend während
|
||||||
|
der Zug. Ein weiterer Klick/Zug **ersetzt** den zuletzt eingefügten Bezug,
|
||||||
|
solange nichts dazwischen getippt wurde — **jede** Eingabe (ein `;` als
|
||||||
|
Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster,
|
||||||
|
der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des
|
||||||
|
Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund.
|
||||||
|
|
||||||
### Zell-Formatierung
|
### Zell-Formatierung
|
||||||
|
|
||||||
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
|
||||||
@@ -348,7 +359,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 138 Tests:
|
`npm run test` — Vitest, 146 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
@@ -405,6 +416,11 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie
|
einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie
|
||||||
ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht
|
ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht
|
||||||
nur `setCellInput`); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
|
nur `setCellInput`); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
|
||||||
|
- **Formel-Point-Mode** — Klick fügt einen lokalen Bezug an der Cursorposition
|
||||||
|
ein; ein zweiter Klick ohne Zwischen-Tippen ersetzt ihn, mit Tippen dazwischen
|
||||||
|
hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick
|
||||||
|
in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
|
||||||
|
ohne Formel im Puffer greift nichts.
|
||||||
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
||||||
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
||||||
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cycleReferenceLocks } from '../engine';
|
|||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
|
const fieldEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
// keep the field in sync with the active cell unless the user is editing it
|
// keep the field in sync with the active cell unless the user is editing it
|
||||||
watch(
|
watch(
|
||||||
@@ -20,6 +21,31 @@ function commit() {
|
|||||||
const sel = store.doc.selection;
|
const sel = store.doc.selection;
|
||||||
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
|
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
|
||||||
}
|
}
|
||||||
|
/** Lets the store insert a reference into this field when the user clicks a
|
||||||
|
* grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
|
||||||
|
function registerAsFormulaEditor() {
|
||||||
|
const sel = store.doc.selection;
|
||||||
|
if (!sel) return;
|
||||||
|
store.registerFormulaEditor({
|
||||||
|
tableId: sel.tableId,
|
||||||
|
getState: () => ({
|
||||||
|
text: draft.value,
|
||||||
|
start: fieldEl.value?.selectionStart ?? draft.value.length,
|
||||||
|
end: fieldEl.value?.selectionEnd ?? draft.value.length,
|
||||||
|
}),
|
||||||
|
replace: (start, end, value) => {
|
||||||
|
draft.value = draft.value.slice(0, start) + value + draft.value.slice(end);
|
||||||
|
nextTick(() => {
|
||||||
|
fieldEl.value?.focus();
|
||||||
|
fieldEl.value?.setSelectionRange(start + value.length, start + value.length);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onFocus() {
|
||||||
|
focused.value = true;
|
||||||
|
registerAsFormulaEditor();
|
||||||
|
}
|
||||||
function onEnter() {
|
function onEnter() {
|
||||||
commit();
|
commit();
|
||||||
(document.activeElement as HTMLElement)?.blur();
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
@@ -27,11 +53,21 @@ function onEnter() {
|
|||||||
function onBlur() {
|
function onBlur() {
|
||||||
focused.value = false;
|
focused.value = false;
|
||||||
commit();
|
commit();
|
||||||
|
store.registerFormulaEditor(null);
|
||||||
}
|
}
|
||||||
function onEscape() {
|
function onEscape() {
|
||||||
draft.value = store.activeCellInput.value;
|
draft.value = store.activeCellInput.value;
|
||||||
(document.activeElement as HTMLElement)?.blur();
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
}
|
}
|
||||||
|
/** Actual typing (not a point-mode click inserting a reference programmatically)
|
||||||
|
* ends the "next click replaces the last reference" window. */
|
||||||
|
function onInput() {
|
||||||
|
store.noteFormulaTyped();
|
||||||
|
}
|
||||||
|
/** Clicking inside the field to reposition the caret should also end that window. */
|
||||||
|
function onMousedown() {
|
||||||
|
store.noteFormulaTyped();
|
||||||
|
}
|
||||||
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
|
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
|
||||||
* "Bezug sperren") -- for the reference at the caret, or every reference a
|
* "Bezug sperren") -- for the reference at the caret, or every reference a
|
||||||
* real text selection overlaps at once. */
|
* real text selection overlaps at once. */
|
||||||
@@ -77,15 +113,18 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="fx">fx</span>
|
<span class="fx">fx</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="fieldEl"
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
class="formula-input"
|
class="formula-input"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="Wert oder =Formel"
|
placeholder="Wert oder =Formel"
|
||||||
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
||||||
@focus="focused = true"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
|
@input="onInput"
|
||||||
|
@mousedown="onMousedown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -415,6 +415,17 @@ function startDrag(mode: 'replace' | 'additive') {
|
|||||||
}
|
}
|
||||||
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
|
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
|
||||||
if (e.button !== 0) return;
|
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();
|
if (editing.value) commitEdit();
|
||||||
clearFlow();
|
clearFlow();
|
||||||
lastPointer = { x: e.clientX, y: e.clientY };
|
lastPointer = { x: e.clientX, y: e.clientY };
|
||||||
@@ -451,6 +462,37 @@ function onDragUp() {
|
|||||||
window.removeEventListener('blur', 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) ---------------
|
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
|
||||||
/** Small square at the selection's bottom-right corner; dragging it repeats
|
/** Small square at the selection's bottom-right corner; dragging it repeats
|
||||||
* the selection's cells (values, formulas -- reference-adjusted like a
|
* 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';
|
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) {
|
function beginEdit(c: number, r: number, initial?: string) {
|
||||||
editing.value = { col: c, row: r };
|
editing.value = { col: c, row: r };
|
||||||
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
|
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
|
||||||
|
store.registerFormulaEditor(formulaEditorHandle());
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = editInput.value;
|
const el = editInput.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -642,11 +710,13 @@ function commitEdit() {
|
|||||||
const { col, row } = editing.value;
|
const { col, row } = editing.value;
|
||||||
store.setCellInput(props.tableId, col, row, editValue.value);
|
store.setCellInput(props.tableId, col, row, editValue.value);
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
|
store.registerFormulaEditor(null);
|
||||||
focusRoot();
|
focusRoot();
|
||||||
setTimeout(() => (committing = false), 0);
|
setTimeout(() => (committing = false), 0);
|
||||||
}
|
}
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
|
store.registerFormulaEditor(null);
|
||||||
focusRoot();
|
focusRoot();
|
||||||
}
|
}
|
||||||
function onEditKeydown(e: KeyboardEvent) {
|
function onEditKeydown(e: KeyboardEvent) {
|
||||||
@@ -709,6 +779,18 @@ function toggleReferenceLock() {
|
|||||||
function onEditBlur() {
|
function onEditBlur() {
|
||||||
if (!committing && editing.value) commitEdit();
|
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) ------------------------
|
// ---- keyboard navigation (Excel-like) ------------------------
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
@@ -889,6 +971,7 @@ const totalWidth = computed(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
onDragUp();
|
onDragUp();
|
||||||
|
onPointDragUp();
|
||||||
onFillDragUp();
|
onFillDragUp();
|
||||||
onResizeUp();
|
onResizeUp();
|
||||||
onRowResizeUp();
|
onRowResizeUp();
|
||||||
@@ -960,7 +1043,7 @@ defineExpose({
|
|||||||
}"
|
}"
|
||||||
:style="cellStyle(c - 1, r - 1)"
|
:style="cellStyle(c - 1, r - 1)"
|
||||||
@mousedown="onCellMouseDown($event, 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
|
<div
|
||||||
class="cell-content"
|
class="cell-content"
|
||||||
@@ -998,9 +1081,9 @@ defineExpose({
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="onEditKeydown"
|
@keydown="onEditKeydown"
|
||||||
@input="resizeEditor"
|
@input="onEditInput"
|
||||||
@blur="onEditBlur"
|
@blur="onEditBlur"
|
||||||
@mousedown.stop
|
@mousedown="onEditMousedown"
|
||||||
/>
|
/>
|
||||||
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
|
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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', () => {
|
describe('text box objects', () => {
|
||||||
it('insert / edit / format / round-trip a text object', () => {
|
it('insert / edit / format / round-trip a text object', () => {
|
||||||
const s = fresh();
|
const s = fresh();
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ interface ClipboardData {
|
|||||||
anchor: CellCoord;
|
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
|
* Rewrite every cell reference in a formula by (dCol, dRow) -- the same
|
||||||
* relative-reference adjustment Excel/Numbers apply on copy.
|
* relative-reference adjustment Excel/Numbers apply on copy.
|
||||||
@@ -177,6 +190,15 @@ function createStore() {
|
|||||||
const ui = reactive({ settingsOpen: false });
|
const ui = reactive({ settingsOpen: false });
|
||||||
/** Copy/cut buffer -- not part of the persisted document. */
|
/** Copy/cut buffer -- not part of the persisted document. */
|
||||||
const clipboard = ref<ClipboardData | null>(null);
|
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));
|
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||||
|
|
||||||
function syncWorkbook() {
|
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
|
// The "active cell" is always the anchor -- the first cell of the current
|
||||||
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
||||||
// while a multi-cell selection is being made, matching Excel/Numbers.
|
// while a multi-cell selection is being made, matching Excel/Numbers.
|
||||||
@@ -1382,6 +1460,12 @@ function createStore() {
|
|||||||
hasClipboard,
|
hasClipboard,
|
||||||
clipboardRect,
|
clipboardRect,
|
||||||
fillSelection,
|
fillSelection,
|
||||||
|
// formula "point mode"
|
||||||
|
registerFormulaEditor,
|
||||||
|
noteFormulaTyped,
|
||||||
|
formulaPointModeActive,
|
||||||
|
pointClick,
|
||||||
|
pointDrag,
|
||||||
// document
|
// document
|
||||||
replaceDocument,
|
replaceDocument,
|
||||||
resetToSeed,
|
resetToSeed,
|
||||||
|
|||||||
Reference in New Issue
Block a user