64 Commits
Author SHA1 Message Date
wkhandClaude Sonnet 5 c3fde283c8 Add user-defined functions, editable exclusively via a new UI
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>
2026-09-02 15:37:04 +02:00
wkhandClaude Sonnet 5 fb5915d5be 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>
2026-09-02 14:41:27 +02:00
wkhandClaude Sonnet 5 5a06d08a6a Implement document-wide undo/redo
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>
2026-09-02 14:19:43 +02:00
wkhandClaude Sonnet 5 67b011637d Make the formula bar mouse-resizable and wrapping
Switches the formula bar from a single-line <input> to a <textarea>:

- A plain <textarea> gets a free, native mouse-draggable resize handle in
  its bottom-right corner (CSS resize: vertical) -- exactly the "erweiterbar/
  erhöhbar mit der Maus" ask, no custom drag code needed.
- Unlike the old <input>, it actually wraps a long formula instead of just
  scrolling it sideways, so dragging the handle taller has something to
  show (min-height keeps the collapsed case looking like before; max-height
  caps it at half the viewport).
- Brought its keyboard handling in line with the in-cell editor from the
  last two changes: Alt+Enter/Cmd+Enter insert a literal newline, F4 cycles
  every reference a selection overlaps, Escape reverts and blurs.
- .formula-bar switches to align-items: flex-start (with matching top
  margins on the labels) so the cell-name/fx labels stay pinned to the
  first line instead of vertically centering in a now-tall box.

Verified interactively: dragging the handle down 80px grows the bar from
~34px to ~113px; Alt+Enter inserts a real newline in the (now multi-line-
capable) field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:11:01 +02:00
wkhandClaude Sonnet 5 8c9f3bbc87 Fix Alt+Enter newline not registering on Mac: add Cmd+Enter fallback
macOS's Option key is a compose key (used to type accented/special
characters), which can make it unreliable as a plain modifier in some
browser/layout combinations -- Alt+Enter alone wasn't reaching the handler
for this user on Mac. Microsoft's own Mac Excel shortcut for this is
inconsistent across versions too (Control+Option+Return traditionally,
plain Option+Return only in 365).

- onEditKeydown now accepts Enter with either altKey OR metaKey (Cmd),
  giving a Mac-safe alternative that doesn't depend on Option's compose
  behavior.
- Also checks e.code ('Enter'/'NumpadEnter') alongside e.key, in case
  composition ever alters what .key reports for the combination (.code
  reflects the physical key regardless).

Verified interactively: Alt+Enter, Cmd+Enter, and plain Enter (still
commits normally) all produce the expected value/behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:08:21 +02:00
wkhandClaude Sonnet 5 cc76ea547f Auto-sizing cell editor, multi-ref F4, and Alt+Enter line breaks
1. In-cell editor width/height ("nur in der Breite der Zelle angezeigt"):
   the editor now lives as an overlay (like the clip/fill markers) instead
   of inside its <td>, so it can grow past the cell -- width first, up to
   an approximated display-width cap, then wrapping and growing height.
   Sized via a hidden mirror element (same font/padding, real DOM
   measurement -- a <textarea> can't self-size to content with CSS alone).

2. F4 now cycles every reference a real text selection overlaps, each from
   its own lock state, not just the one at the caret -- select a stretch of
   formula spanning several references and F4 adjusts all of them at once,
   same as Excel. cycleReferenceLock(formula, caret) -> cycleReferenceLocks
   (formula, start, end); a collapsed selection (start === end) keeps the
   old single-reference caret behaviour. Wired into both the in-cell editor
   and the formula bar.

3. Alt+Enter inserts a literal newline in the editor instead of committing
   (explicit splice at the caret, not relying on the textarea's native
   Enter handling, which isn't consistently reachable for Alt+Enter). A
   cell holding a newline displays it wrapped (.cell.multiline) instead of
   collapsing to one ellipsised line.

Along the way: a <td>'s own max-height doesn't cap table-row growth in any
browser (a table-layout quirk) -- a wrapped multi-line cell was silently
growing its row past rowH(r), which would have desynced every pixel
position computed from rowH()/rowTop() (the overlays, sticky frozen panes,
the row-resize handle). Fixed by moving cell text into its own block-level
.cell-content child, which *does* respect max-height + overflow:hidden.

Verified interactively against the dev server with Playwright: a long
formula gets a ~300px editor instead of the 104px cell; Alt+Enter produces
a real "\n" that survives commit and displays wrapped, with the row height
unchanged (26px, not the ~40px it grew to before the .cell-content fix);
F4 on a full-formula selection turns every reference absolute in one press
and keeps cycling all of them on repeated presses; existing selection,
copy/paste and fill-handle behaviour all regression-checked clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:04:45 +02:00
wkhandClaude Sonnet 5 830dcb8eeb Add the fill handle: drag the selection's corner to copy/fill, Excel-style
- 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>
2026-09-02 10:43:54 +02:00
wkhandClaude Sonnet 5 fafb118db6 Keep marking the copy source's first cell after the selection moves on
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>
2026-09-02 10:35:35 +02:00
wkhandClaude Sonnet 5 47afe8fc20 Fix copy marquee (one rectangle, not per-cell) and arrow-key collapse point
- Copy source marker: was outline:dashed on every td in the copied range,
  so each cell got its own little dashed box. Replaced with one absolutely
  positioned .clip-marquee overlay sized/positioned from the range's pixel
  bounds (colLeft/rowTop), giving a single dashed rectangle around the whole
  range, same as Excel/Numbers.
- Arrow keys (no Shift) after selecting a multi-cell range collapsed beside
  the *focus* (wherever the drag/shift-selection ended), not the *anchor*
  (the active, first-selected cell) -- inconsistent with the anchor-stays-
  active fix from the previous change and with Excel/Numbers, where a plain
  arrow key always steps from the active cell. navigate()/jumpTo()/Home/End
  now step from the anchor when collapsing (ext=false) and keep stepping
  from the focus when extending (Shift/Ctrl+Shift), which is unchanged.

Verified interactively against the dev server with Playwright (isolated
empty sheet, to sidestep the seed data's cascaded/overlapping tables):
selecting B2:B5 keeps the active-cell border on B2, copying shows exactly
one .clip-marquee, and ArrowLeft afterwards lands on A2, not A5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:23:06 +02:00
wkhandClaude Sonnet 5 8e43cc294c Support $-locked references: relative, mixed and absolute
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>
2026-09-02 10:11:10 +02:00
wkhandClaude Sonnet 5 e45cdfd077 Keep the first-selected cell marked as active during a multi-cell selection
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>
2026-09-02 09:59:29 +02:00
wkhandClaude Sonnet 5 44f3314137 Add cell selection copy/cut/paste with formula reference adjustment
- 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>
2026-09-02 09:55:09 +02:00
wkh 31108977b9 - before copy paste 2026-09-02 09:45:44 +02:00
wkh c7885067e7 - init 2026-08-29 17:47:12 +02:00