New store computed selectionRangeName: for a single cell it's identical
to activeCellName ("Umsatz::B2"), for a multi-cell selection it shows
the full primary rectangle ("Umsatz::B2:E6"), and a discontiguous
multi-selection gets a "+N" suffix for the further rectangles.
activeCellName itself is untouched (still anchor-only, since that's what
a formula/edit actually targets) -- only the formula bar's name field and
the status bar's range label switch to the new computed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Document-wide, named formula functions (e.g. MARGE(revenue; cost) =
(revenue - cost) / revenue) callable from any cell like a built-in
(=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through
a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form),
never via JSON handedit.
Engine: a call is inlined into the caller's AST at parse time
(src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and
graph.ts need no changes -- dependencies on cells referenced only inside
a function body (not in the call) are picked up for free. parser.ts
gains an optional paramNames argument to parseFormula() producing a new
ParamNode leaf, used only when parsing a function body.
Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with
inline (not toast) validation -- name/parameter shape, no collision with
a built-in or another function, the body parses, and a definition-time
cycle check across the whole call graph. Renaming/deleting a function
mirrors table rename/delete: existing calls are not rewritten, they
become #NAME? on the next rebuild. Fully undo/redo-able and part of the
JSON document (customFunctions: [], migrated for v1 and older v2 docs).
178 tests passing (32 new), vue-tsc and eslint clean. Manually verified
end-to-end in the running app via Playwright: define/call/recalculate,
rename -> #NAME?, a closing cycle rejected inline at save, undo restores
the function library.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Snapshot-based, like the existing JSON export/import round-trip
(snapshot()/replaceDocument()) rather than a patch/command log -- simple and
provably correct for a document this size.
- document.ts: a deep watch on `doc` tracks a "content fingerprint" (sheets +
locale + csvDelimiter, deliberately excluding selection/activeObjectId, so
moving the cursor is never an undo step) and debounces commits by 150ms --
long enough to coalesce a resize-drag's per-mousemove calls into one step,
short enough that two genuinely separate quick actions don't merge. A new
edit after an undo immediately drops the redo branch (not debounced, so a
bound button is never briefly wrong). undo()/redo() flush any pending
change first, then pop/apply a snapshot via a new applyDocumentState() core
that replaceDocument() also uses -- replaceDocument() (JSON import
"replace", "reset to seed") additionally resets history, since loading a
different document shouldn't leave the old one undoable into it; undo/redo
themselves must not (that's the very stack they're unwinding).
- TableGrid.vue: Strg/Cmd+Z undoes, Strg/Cmd+Y or +Shift+Z redoes, deferring
to a cell editor's native undo while one is open (the existing `if
(editing.value) return` guard already covers this).
- App.vue: a window-level fallback for when no table has focus, skipped
whenever focus sits in any text field so their own native undo keeps
working.
- AppToolbar.vue: ↶/↷ buttons, disabled via store.canUndo/canRedo.
Verified interactively against the dev server with Playwright: the toolbar
button enables the instant a change happens (not after the debounce);
separately-timed edits each undo as their own step; a multi-tick resize drag
undoes in one step back to the original width; Ctrl+Z, Ctrl+Y and the
toolbar buttons all work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
Copying a range showed the dashed marquee around the whole source, but the
distinct border on its first (anchor) cell disappeared the moment you
selected something else -- that border was drawn from the *live* selection's
anchor, which had just moved to the new (paste-destination) cell.
- ClipboardData now also stores the copy/cut selection's own anchor -- the
cell the drag/shift-selection actually started from, which isn't always
rect's top-left corner (a drag can start at any corner).
- clipboardRect exposes it; TableGrid renders a second small overlay,
.clip-anchor, positioned on just that one cell -- independent of the
current selection, so it stays visible for as long as the marquee does.
Verified interactively: copy B2:B5, click a cell elsewhere -- the marquee
and the solid border on B2 both remain, while the new cell gets its own
live-selection border.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The engine already anticipated $A1/A$1/$A$1 in its grammar comment, but the
lexer had no rule for '$' at all -- any formula using it failed to parse.
- lexer.ts: '$' is now a valid word character, so "$A$1" tokenizes as one
IDENT, same as any other bare word (the lexer still doesn't interpret it,
per its "dumb lexer" design -- the parser/evaluator stay untouched, since
evaluation is purely positional and $ has no effect on a value).
- refs.ts: parseA1()/toA1() now carry colAbs/rowAbs through the $ markers
(backward compatible -- existing {col,row} destructuring is unaffected).
Added cycleReferenceLock() for Excel/Numbers-style F4: relative ->
absolute -> row-locked -> col-locked -> relative, toggling whichever
reference sits at the caret (and the correct side of an A1:B2 range).
- document.ts: shiftFormula() now honors the lock on copy (a locked axis
stays exactly as typed) but not on cut (a moved cell's reference follows
it regardless of $, since cut relocates the cell itself rather than
filling a relative offset).
- TableGrid.vue's inline cell editor and FormulaBar.vue both wire up F4 via
the shared cycleReferenceLock() helper.
- Tests: lexer/parser coverage for $ tokenizing/parsing, a new refs.spec.ts
for parseA1/toA1 round-tripping and the F4 cycle (incl. range side-
picking), and document.spec.ts cases for locked-axis copy vs. cut.
- README.md / src/engine/README.md: documented the $ semantics, F4, and
corrected a couple of now-stale claims (test count, "$ is ignored, there's
no copy/fill", "active cell is always the last-clicked one").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The active cell (bold border/handle, formula bar target) used to track
selection.focus, so it followed the mouse/cursor while dragging or
shift-extending a range. Switch it to selection.anchor -- the first cell of
the current selection action -- so it stays put while the range grows,
matching Excel/Numbers. Tab/Enter data-entry flow already keeps anchor ==
focus while it moves the cursor, so that behaviour is unaffected.
- document.ts: activeCell/activeCellName/activeCellInput read the anchor.
- FormulaBar.vue: commits to the anchor cell to match what it displays.
- TableGrid.vue: isActive() (border + handle) and F2/type-to-edit target
the anchor; Home/End keep using the cursor's row via s.focus.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- store: copySelection/cutSelection/pasteClipboard/cancelClipboard hold an
in-memory (unpersisted) clipboard buffer of raw cell inputs + formatting.
- shiftFormula rewrites every relative cell reference in a copied/cut formula
by the move delta (the engine has no $-absolute refs, so all refs qualify).
Copy shifts every reference uniformly; cut only follows references that
point inside the moved block itself, leaving refs to untouched cells alone
and never touching a qualified Table!A1 reference.
- paste anchors on a smaller/single-cell selection (and overflows past it),
or tiles across a selection that's an exact larger multiple of the buffer.
- TableGrid: Strg+C/X/V shortcuts, context-menu entries, a dashed "clip-src"
source marker, and Escape drops a pending buffer.
- document.spec.ts: coverage for reference shifting, tiled/fill paste, cut's
inside/outside reference split, format copy, and Escape cancelling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>