9 Commits
Author SHA1 Message Date
wkh 1db1562a38 Add concept paper on argument hints and a formula assistant
Two options, not decided yet; kept with the project so the reasoning is at hand when one is picked.
2026-10-24 00:38:01 +02:00
wkh 59b99e5715 Drop the generated comments from database.yml
Settings come from .env now; the stock Rails hints about roles, sockets and ports no longer describe how this app is configured.
2026-10-24 00:38:01 +02:00
wkh 0fca61eb65 Add in-app help, formula reference outlines, underline and auto-closing
- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
2026-10-24 00:38:01 +02:00
wkhandClaude Opus 5.5 0368e96e0c Merge the server concept into the README; add CLAUDE.md
The README gets a chapter on storage, accounts and live collaboration
that describes what is built (local vs server documents, flows, roles,
architecture, Yjs layer, channel protocol, data model, API, .env
configuration, security, open points). The planning document with its
outdated parts (guest accounts, y-rb, edit lock, phases) is removed.
CLAUDE.md collects commands, layout and the rules that are easy to break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:00:44 +02:00
wkhandClaude Opus 5.5 f22963a666 Read database, host and SMTP settings from a .env file
- gem dotenv loads .env (and .env.<environment>[.local]) at startup; real
  environment variables take precedence.
- database.yml takes host, port, user, password and name from
  NUMOLUS_DATABASE_*; the database is called numolus by default.
- .env.example documents every setting; .env itself stays ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:57:43 +02:00
wkhandClaude Opus 5.5 ed099cfbda Two random words as the name of anonymous collaborators; no z-order change when read-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:50:16 +02:00
wkhandClaude Opus 5.5 5871524421 Keep the keyboard on the grid after the formula bar; editor uses cell colours
- Enter/Escape in the formula bar (and a jump via the Name Box) hand the
  focus back to the table of the selection, so the arrow keys move the
  active cell again instead of doing nothing.
- The in-cell editor takes the cell's own text/fill colour and bold/
  italic, so a light-on-dark cell no longer turns into dark text on a
  dark fill while editing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:50:16 +02:00
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:41:02 +02:00
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00
204 changed files with 9352 additions and 1474 deletions
+26
View File
@@ -0,0 +1,26 @@
# numolus settings -- copy to .env (same directory) and fill in.
# .env is read at startup (gem dotenv) and never committed. Variables that
# are already set in the real environment take precedence over this file.
# Environment-specific files are read too: .env.production, .env.development
# (and *.local variants).
# ---- Database (PostgreSQL) --------------------------------------------------
# Unset host/user/password = local socket as the current OS user (development).
NUMOLUS_DATABASE_NAME=numolus
NUMOLUS_DATABASE_HOST=localhost
NUMOLUS_DATABASE_PORT=5432
NUMOLUS_DATABASE_USER=numolus
NUMOLUS_DATABASE_PASSWORD=
# ---- Server -------------------------------------------------------------------
# Host name used in links in mails (sign-in, invitations)
NUMOLUS_HOST=numolus.wicom.li
# Rails secret (production): generate with bin/rails secret
SECRET_KEY_BASE=
# ---- Mail (production; development shows mails at /letter_opener) ------------
NUMOLUS_MAIL_FROM="numolus <noreply@wicom.li>"
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_USER_NAME=
SMTP_PASSWORD=
+23
View File
@@ -5,3 +5,26 @@ coverage
.DS_Store .DS_Store
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
# Vite Ruby
/public/vite*
node_modules
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vite.dev/guide/env-and-mode.html#env-files
*.local
# Rails
/.bundle
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
!/tmp/pids/
/tmp/pids/*
!/tmp/pids/.keep
/storage/*
!/storage/.keep
/config/master.key
/.env*
!/.env.example
/vendor/gems
+1
View File
@@ -0,0 +1 @@
ruby-3.4.9
+84
View File
@@ -0,0 +1,84 @@
# CLAUDE.md
numolus: spreadsheet à la Apple Numbers (free canvas with table/chart/text/button
objects) with a hand-written formula engine. Branch `with-server` = Rails 8 app
with the Vue 3 frontend in `app/frontend/`; accounts, sharing, live collaboration.
Details for users and architecture: README.md, chapter "Speichern, Konten &
Zusammenarbeit".
## Branches
- `main` (tag `client-only`): browser-only app, documents in IndexedDB. Keep it
free of server code; fixes that apply to both go in their own commit so they
can be cherry-picked.
- `with-server`: everything below.
## Commands
```bash
bin/setup # gems, npm packages, database
bin/dev # Rails :3000 + Vite dev server :3036 (open localhost:3000)
npm test # Vitest (engine, store, io, collab)
bin/rails test # Minitest (models, API, auth, sharing, DocumentChannel)
npx vue-tsc --noEmit -p . # typecheck
npm run lint # ESLint
npm run build # typecheck + production build into public/vite
bin/brakeman -q --no-pager
```
Run all of them before committing. Mails in development: localhost:3000/letter_opener.
## Layout
- `app/frontend/engine/` formula engine (framework-agnostic, own README)
- `app/frontend/store/document.ts` the document store (~3300 lines, one open document)
- `app/frontend/store/library.ts` which document is open, local (IndexedDB) vs server, URLs
- `app/frontend/store/session.ts` signed-in user; `io/api.ts` REST client; `io/library.ts` IndexedDB
- `app/frontend/collab/` live collaboration: `ydoc.ts` (store ↔ Yjs), `session.ts` (ActionCable, undo, awareness)
- `app/channels/document_channel.rb` stores/relays Yjs updates, checks access
- `app/models/document.rb` `role_for(user:, token:)` is the one access check
## Rules that are easy to break
- **Cell addressing stays positional**: `TableObject.cells["col,row"]` in store,
engine and file format (`.num`/`.json`). Stable row/column ids exist only in
the Yjs layer (`collab/ydoc.ts`); don't change the file format for them.
- **Every content-changing store function calls `markDirty()`** (selection-only
changes call `scheduleSave()`), and must be listed in `READ_ONLY_GUARDED` in
`document.ts`, else viewers of a shared document can change it locally.
- **Mid-table row/column insert/delete must call `collabHooks?.onStructure(...)`**
(as `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` do). Edge growth/shrink
needs nothing. Without it, concurrent edits land in the wrong row.
- View state is per person: `selection`, `activeSheetId`, `activeObjectId`, sheet
`zoom` are not synced. `applyExternalState` keeps them when others' changes arrive.
- Remote changes: flush pending local changes into Yjs first, then apply, then
rebuild the store (see `CollabSession.process`). Keep that order.
- Local documents persist via `setPersistHandler` (IndexedDB); server documents
only via the collab session. Undo: snapshots locally, `Y.UndoManager` for server docs.
- Solid Queue/Cache/Cable live in the same PostgreSQL database as the app data
(plain migrations in `db/migrate`), no separate databases (user decision).
- Settings and secrets come from `.env` (gem `dotenv`, template `.env.example`),
never committed.
- Share link tokens are stored in plain text on purpose (owner can copy the link
again); magic link tokens/codes only as SHA-256.
## Conventions
- UI text and user-facing messages in German; code, comments, commit messages in
English. Comments explain *why*, in the surrounding (fairly thorough) style.
- Commit messages: short imperative subject, body with the reasons.
- The user sometimes commits on their own: check `git status`/`git log` fresh
before committing; don't push unless asked.
## Environment gotchas
- `vite-plugin-ruby` makes `app/frontend` the Vite root: Vitest `include`
patterns in `vite.config.ts` are relative to it (`**/*.spec.ts`).
- Vite is pinned to v5 (Vitest 2 and `@vitejs/plugin-vue` 5 don't support newer);
`vite install`/`vite upgrade` may bump it again.
- Gems install into `vendor/gems` (global `BUNDLE_PATH`), ignored by git.
- Node comes from mise; outside the project directory scripts may need
`MISE_NODE_VERSION=22.21.1`.
- Browser checks: there is no Playwright in the repo; drive Chrome
(`/Applications/Google Chrome.app`) with `playwright-core` from a scratch
directory against `bin/dev`.
+50
View File
@@ -0,0 +1,50 @@
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.3", ">= 8.1.3.1"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
# Settings and secrets (database, SMTP, host) from a .env file in the project
# directory; real environment variables win -- see .env.example
gem "dotenv"
# Vite as the frontend build, integrated into Rails [https://vite-ruby.netlify.app]
gem "vite_rails"
group :development do
# Mails open in the browser at /letter_opener instead of being sent
gem "letter_opener_web"
end
+317
View File
@@ -0,0 +1,317 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.19)
railties
actioncable (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
mail (>= 2.8.0)
actionmailer (8.1.4)
actionpack (= 8.1.4)
actionview (= 8.1.4)
activejob (= 8.1.4)
activesupport (= 8.1.4)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.4)
actionview (= 8.1.4)
activesupport (= 8.1.4)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.4)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.4)
activesupport (= 8.1.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.4)
activesupport (= 8.1.4)
globalid (>= 0.3.6)
activemodel (8.1.4)
activesupport (= 8.1.4)
activerecord (8.1.4)
activemodel (= 8.1.4)
activesupport (= 8.1.4)
timeout (>= 0.4.0)
activestorage (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activesupport (= 8.1.4)
marcel (~> 1.0)
activesupport (8.1.4)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bigdecimal (4.1.3)
bindex (0.8.1)
bootsnap (1.26.0)
msgpack (~> 1.5)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.2.0)
drb (2.2.3)
dry-cli (1.4.1)
erb (6.0.7)
erubi (1.13.1)
et-orbi (1.4.2)
tzinfo
fugit (1.14.0)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.4.0)
activesupport (>= 6.1)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (3.0.2)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
actionmailer (>= 6.1)
letter_opener (~> 1.9)
railties (>= 6.1)
rexml
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.2.1)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.5)
mutex_m (0.3.0)
net-imap (0.6.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
raabro (1.5.0)
racc (1.8.1)
rack (3.2.7)
rack-proxy (2.0.1)
rack (>= 2.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.4)
actioncable (= 8.1.4)
actionmailbox (= 8.1.4)
actionmailer (= 8.1.4)
actionpack (= 8.1.4)
actiontext (= 8.1.4)
actionview (= 8.1.4)
activejob (= 8.1.4)
activemodel (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
bundler (>= 1.15.0)
railties (= 8.1.4)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
rexml (3.4.4)
securerandom (0.4.1)
solid_cable (4.1.0)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.7.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11)
vite_rails (3.11.1)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.11.0)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (>= 0.6.1)
zeitwerk (~> 2.2)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.8.3)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
bootsnap
brakeman
bundler-audit
debug
dotenv
letter_opener_web
pg (~> 1.1)
puma (>= 5.0)
rails (~> 8.1.3, >= 8.1.3.1)
solid_cable
solid_cache
solid_queue
tzinfo-data
vite_rails
web-console
BUNDLED WITH
2.6.9
+2
View File
@@ -0,0 +1,2 @@
web: bin/rails server -p 3000
vite: bin/vite dev
+319 -30
View File
@@ -4,21 +4,34 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**.
1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine 1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug- (Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
Erkennung). Dokumentiert in [`src/engine/README.md`](src/engine/README.md). Erkennung). Dokumentiert in [`app/frontend/engine/README.md`](app/frontend/engine/README.md).
2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine 2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine
**freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und **freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und
Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt. Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt.
```bash ```bash
npm install bin/setup # Gems, npm-Pakete, Datenbank (PostgreSQL) anlegen
npm run dev # Vite-Devserver bin/dev # Rails (Port 3000) + Vite-Devserver mit HMR (Port 3036)
npm run test # Vitest-Suite (Engine + Store + IO), 570 Tests npm run test # Vitest-Suite (Engine + Store + IO)
npm run build # Typecheck + Produktionsbuild bin/rails test # Server-Tests (Models, API, Anmeldung, Teilen)
npm run build # Typecheck + Produktionsbuild nach public/vite
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
``` ```
Alles läuft **rein clientseitig** — kein Server, keine Netzwerk-Requests. Datei- Die App ist eine **Rails-8-Anwendung** mit dem Vue-Frontend unter
I/O über File API und Blob-URLs. [`app/frontend/`](app/frontend/) (eingebunden über `vite_rails`). Ohne Konto
bleibt alles **im Browser** (IndexedDB). Mit Konto (Anmeldung per Magic Link,
kein Passwort) liegen Dokumente auf dem Server und lassen sich teilen – per
Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Alle mit
Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen
kommen über ActionCable sofort bei den anderen an (Yjs, siehe
[`app/frontend/collab/`](app/frontend/collab/)), jede Person hat ihr eigenes
Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details:
[Speichern, Konten & Zusammenarbeit](#speichern-konten--zusammenarbeit).
**Konfiguration:** Datenbank-Zugang, Server-Name, SMTP und `SECRET_KEY_BASE`
stehen in einer `.env` im Projektverzeichnis: `cp .env.example .env` und
ausfüllen (siehe [Konfiguration & Betrieb](#konfiguration--betrieb)).
--- ---
@@ -164,7 +177,7 @@ Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll. Undo dieses Feldes benutzen soll.
**Performance bei großen Tabellen**: Undo-Tracking und die (debounced) **Performance bei großen Tabellen**: Undo-Tracking und die (debounced)
`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ..., Speicherung werden **nicht** über einen `watch(doc, ...,
{deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den {deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den
kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand
proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung
@@ -268,7 +281,7 @@ eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
sich automatisch umschreiben ließe, nur ihren Aufruf-Namen. sich automatisch umschreiben ließe, nur ihren Aufruf-Namen.
- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt** - Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
(Inlining, keine Laufzeit-Closures) — siehe (Inlining, keine Laufzeit-Closures) — siehe
[`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die [`app/frontend/engine/userFunctions.ts`](app/frontend/engine/userFunctions.ts) und die
Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung, Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung,
und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus. Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
@@ -336,7 +349,7 @@ berechenbar ist. Eine `FOR`-Schleife über insgesamt mehr als 10.000 Zellen
Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat, Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat,
ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details: nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt. [`app/frontend/engine/README.md`](app/frontend/engine/README.md)s „Schleifen"-Abschnitt.
#### SUMIF/COUNTIF/AVERAGEIF nachbauen #### SUMIF/COUNTIF/AVERAGEIF nachbauen
@@ -638,13 +651,27 @@ Inspector unter **Objekt · Auswahl**: horizontale **Ausrichtung**
**Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit **Prozent** 0/2 Stellen, **Datum**/**Zeit**/**Datum + Zeit** — kombiniert mit
der Locale unten — sowie **Datum (ISO)**, das immer `JJJJ-MM-TT` zeigt, der Locale unten — sowie **Datum (ISO)**, das immer `JJJJ-MM-TT` zeigt,
unabhängig von der Locale), **Zell-Textfarbe**, unabhängig von der Locale), **Zell-Textfarbe**,
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte **Zell-Füllfarbe**, **Fett**/**Kursiv**/**Unterstrichen**, „Formatierung
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle. entfernen". Auch exakte **Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
Per Tastatur: `Strg`/`Cmd`+`Umschalt`+`B` (oder `F`) fett, `+I` kursiv, `+U`
unterstrichen; jeweils ein, wenn noch nicht jede Zelle der Auswahl es hat,
sonst aus.
### Hilfe (`?`, F1)
Das `?` in der Symbolleiste, **Datei › Hilfe …** oder `F1` öffnet die
eingebaute Hilfe ([`HelpModal.vue`](app/frontend/components/HelpModal.vue)):
Themen für Benutzer, alle Tastenkürzel und die Formelfunktionen samt
Rechenzeichen, mit einer Suche über alles. Themen und Kürzel stehen in
[`data/help.ts`](app/frontend/data/help.ts) und werden von Hand gepflegt, wer
ein Kürzel oder eine sichtbare Funktion ändert, passt sie dort mit an. Die
Funktionsliste kommt aus `data/codeReference.ts` und ist damit immer
vollständig.
### Einstellungen (⚙, links neben dem Titel) ### Einstellungen (⚙, links neben dem Titel)
Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade
([`SettingsPanel.vue`](src/components/SettingsPanel.vue)), die die Fläche zur ([`SettingsPanel.vue`](app/frontend/components/SettingsPanel.vue)), die die Fläche zur
Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle
wichtigen Punkte: wichtigen Punkte:
@@ -685,8 +712,8 @@ Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise
gerastert. gerastert.
Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` / Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` /
`Sheet`) und werden damit in **localStorage gespeichert und in `.json` `Sheet`) und werden damit **mit dem Dokument gespeichert und in `.json`
exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import exportiert** (der Zoom bleibt bei geteilten Dokumenten pro Person) – Round-Trip verlustfrei, ungültige Werte werden beim Import
verworfen bzw. geklemmt. verworfen bzw. geklemmt.
### Zahlenformat (Ländereinstellung) ### Zahlenformat (Ländereinstellung)
@@ -1034,7 +1061,7 @@ Fehlercode nötig).
## Architektur der Engine-Module ## Architektur der Engine-Module
Framework-agnostisches ES-Modul unter [`src/engine/`](src/engine/) — **keine** Framework-agnostisches ES-Modul unter [`app/frontend/engine/`](app/frontend/engine/) — **keine**
Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit
ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle
Tabellen aller Blätter. Tabellen aller Blätter.
@@ -1054,7 +1081,7 @@ Tabellen aller Blätter.
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht `graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
betroffene Zellen behalten ihren Wert. betroffene Zellen behalten ihren Wert.
**Anbindung an Vue.** [`src/store/document.ts`](src/store/document.ts) ist das **Anbindung an Vue.** [`app/frontend/store/document.ts`](app/frontend/store/document.ts) ist das
„eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen „eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen
`recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt — `recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt —
nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also
@@ -1097,7 +1124,8 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
### Import ### Import
`<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`. `<input type="file">` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`.
- **`.num`** / **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*. - **`.num`** / **`.json`** → *als neues Dokument* öffnen (Standard), das offene
Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2 **v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**. wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
@@ -1108,14 +1136,263 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
- Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh. - Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh.
Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**. Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**.
## Persistenz ## Speichern, Konten & Zusammenarbeit
Das gesamte Dokument wird (debounced) nach `localStorage` gespiegelt, robust Ohne Konto bleibt alles **im Browser**. Ein Konto braucht es nur, wenn man
gegen leeren/kaputten State — Fallback ist ein **leeres Dokument** (ein Blatt seine Dokumente auf mehreren Geräten haben oder sie mit anderen teilen will.
„Blatt 1" mit einer leeren „Tabelle 1"). Ein v1-State im Storage wird beim Mit Konto liegen Dokumente auf dem Server und werden **live gemeinsam**
Laden migriert. **Datei › Neu** legt ein neues leeres Dokument an, **Datei › bearbeitet.
Muster · Umsatzanalyse** lädt das vorbefüllte Musterdokument (beides nach
Rückfrage, die bisherigen Änderungen gehen verloren). ### Grundsätze
- **Anonym zuerst.** App öffnen, arbeiten, ohne Registrierung. Kein
Gast-Konto auf dem Server.
- **Konto nur bei Bedarf**, Anmeldung nur per **Magic Link** (E-Mail, dazu ein
6-stelliger Code). Keine Passwörter. Die erste Anmeldung legt das Konto an.
- **Nichts geht verloren.** Nach der ersten Anmeldung fragt die App, welche
lokalen Dokumente ins Konto hochgeladen werden sollen.
- **Links sind Zugänge.** Ein Lesen- oder Bearbeiten-Link funktioniert auch
ohne Konto und lässt sich jederzeit widerrufen.
- **Gleichzeitig statt nacheinander.** Änderungen werden per CRDT (Yjs)
zusammengeführt, ohne Sperren; jede Person hat ihr eigenes Undo.
- **Dateien bleiben.** `.num`, `.json`, CSV und XLSX bleiben als
Import/Export.
### Wo Dokumente liegen
| | Lokal | Server |
| --- | --- | --- |
| Wer | alle, auch ohne Konto | mit Konto, oder per Link |
| Wo | IndexedDB dieses Browsers | PostgreSQL |
| Adresse | `/?doc=<id>` | `/d/<uuid>` (Link: `/d/<uuid>#k=<token>`) |
| Speichern | automatisch, 250 ms nach der Änderung | live, jede Änderung sofort an alle |
| Undo | Snapshots des Dokuments | pro Person (`Y.UndoManager`) |
| Teilen | – (erst hochladen) | per Link oder Einladung |
**Datei › Dokumente …** listet alles in bis zu drei Gruppen: *Mein Konto*,
*Mit mir geteilt* (Einladungen und Links) und *In diesem Browser*. Dort lässt
sich öffnen, umbenennen, duplizieren, löschen bzw. aus der Liste entfernen und
ein lokales Dokument ins Konto hochladen. Der Dokumentname steht in der
Toolbar und ist direkt umbenennbar; er ist auch der Dateiname beim Export.
**Neues Dokument** und **Muster** legen je ein weiteres Dokument an: mit Konto
auf dem Server, sonst lokal. Ein alter Einzel-Speicherstand aus
`localStorage` wird beim ersten Start als „Mein Dokument“ übernommen.
**Mehrere Tabs:** Jeder Tab kann ein anderes Dokument offen haben. Dieselbe
lokale Datei in zwei Tabs folgt den Speicherungen des anderen Tabs
(`BroadcastChannel`); tippen beide gleichzeitig, gewinnt die letzte
Speicherung, darauf weist ein Hinweis hin. Server-Dokumente führen
gleichzeitige Änderungen immer zusammen.
### Abläufe
1. **Anonym arbeiten** – Dokumente liegen in IndexedDB. Ein anderer Browser
oder gelöschte Browserdaten bedeuten: diese Dokumente sind dort nicht da.
2. **Anmelden** – E-Mail eingeben, Mail mit Link und Code. Der Link öffnet eine
Bestätigungsseite; angemeldet wird erst per Klick (`POST`), damit
Mail-Scanner, die Links vorab aufrufen, ihn nicht verbrauchen. Alternativ
den Code in der App eingeben (Link am Handy, App am Desktop). Danach: Rückfrage
zum Hochladen lokaler Dokumente; hochgeladene werden lokal entfernt.
3. **Teilen per Link** – *Teilen* → Link „kann ansehen“ bzw. „kann bearbeiten“
erstellen und kopieren. Funktioniert ohne Konto; der Token wird im Browser
gemerkt (`localStorage`) und nach einer späteren Anmeldung dem Konto
zugeordnet. *Neu* erzeugt einen neuen Link; der alte und alle darüber
entstandenen Zugänge sind dann ungültig.
4. **Einladen** – E-Mail + Rolle. Die Einladung (7 Tage gültig) meldet beim
Öffnen gleichzeitig an und legt bei Bedarf das Konto an.
5. **Gemeinsam bearbeiten** – Änderungen kommen in ~150 ms bei den anderen an.
Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen, oben
rechts die Avatare. Anonyme Personen heißen zwei zufällige Begriffe
(„Quelle Mond“). Undo nimmt nur eigene Änderungen zurück. Wer nur lesen
darf, sieht alles live, kann aber nichts ändern („Nur Lesen“).
Ein lokales Dokument teilen: *Teilen* → ohne Konto erst anmelden, dann
*Hochladen und teilen*.
### Rollen
| Aktion | Besitzer | Bearbeiten | Ansehen |
| --- | --- | --- | --- |
| Lesen, Anwesenheit der anderen sehen | ja | ja | ja |
| Bearbeiten, Umbenennen | ja | ja | nein |
| Exportieren, Duplizieren (ins eigene Konto) | ja | ja | ja |
| Links und Personen verwalten | ja | nein | nein |
| Löschen | ja | – | – |
| Aus der eigenen Liste entfernen | – | ja | ja |
Zugang entsteht durch **Besitz** (wer anlegt), **Einladung** (Mitgliedschaft
`invite`, bleibt beim Widerruf eines Links bestehen) oder **Link**
(angemeldet: Mitgliedschaft `link`; anonym: nur der Token im Browser). Ein
Link schwächt nie eine bestehende Rolle ab. Der Token steht im URL-Fragment
(`#k=…`), das Browser nicht an Server schicken; die App entfernt es nach dem
Einlösen aus der Adresszeile.
Geprüft wird zentral über `Document#role_for(user:, token:)`: im REST-API per
Controller (kein Zugang → `404`, nicht `403`), im WebSocket beim Verbinden.
Ändern sich Links oder Mitgliedschaften, prüft jede offene Verbindung ihre
Rolle sofort neu: Wer den Zugang verliert, wird getrennt; wer herabgestuft
wird, ist sofort im Nur-Lesen-Modus.
### Architektur
```
┌─ Browser (je Person) ─────────┐ ┌─ Rails 8 ──────────────────────┐
│ Vue-App (app/frontend) │ HTTPS │ REST-API /api, Login /auth │ ┌────────────┐
│ Store (DocumentState, │◄────────►│ Magic Link, Liste, Teilen │────►│ PostgreSQL │
│ positionsbasiert "c,r") │ │ │ │ Daten + │
│ ▲ ▼ collab/ydoc.ts │ WebSocket│ DocumentChannel (ActionCable) │────►│ Solid Queue│
│ Y.Doc (feste Zeilen-/ │◄────────►│ speichert + verteilt Updates │ │ Cache,Cable│
│ Spalten-IDs), Awareness │ /cable │ │ └────────────┘
│ IndexedDB (lokale Dokumente) │ │ Mailer (Solid Queue) ──► SMTP │
└───────────────────────────────┘ └────────────────────────────────┘
```
- **Rails 8** mit dem Vue-Frontend über `vite_rails`; `SpaController` liefert
die App für `/` und `/d/:id` und bettet den angemeldeten User ein
(`#numolus-boot`).
- **Solid Queue, Solid Cache, Solid Cable** liegen als normale Migrationen in
**derselben** PostgreSQL-Datenbank wie die Daten; kein Redis.
- Der Server versteht den Inhalt der Yjs-Updates nicht und muss es nicht: er
speichert und verteilt sie nur. Formeln rechnet weiterhin jeder Browser selbst.
### Live-Bearbeitung (Yjs)
Der **Store bleibt unverändert positionsbasiert** (`cells["col,row"]`), Engine
und Dateiformat ebenso. Die Schicht [`app/frontend/collab/`](app/frontend/collab/)
übersetzt:
- [`ydoc.ts`](app/frontend/collab/ydoc.ts) – Yjs-Form des Dokuments und die
Übersetzung in beide Richtungen. Jede Tabelle hat `rowOrder`/`colOrder` mit
**festen IDs**; Zellen, Formate, Breiten und Höhen hängen an diesen IDs
(`"colId|rowId"`). `Binding.sync()` schreibt nur, was sich geändert hat
(eine Zelle, eine Eigenschaft), damit Änderungen verschiedener Personen
zusammengeführt statt überschrieben werden. Zeile/Spalte einfügen oder
löschen melden die vier Store-Funktionen `insertRow`/`deleteRow`/
`insertColumn`/`deleteColumn` ausdrücklich (`onStructure`), denn aus
verschobenen Zellen allein ist das nicht erkennbar. So landet eine Eingabe
in Zeile 7 in der richtigen Zeile, auch wenn gleichzeitig jemand darüber
eine Zeile einfügt.
- [`session.ts`](app/frontend/collab/session.ts) – eine Sitzung je offenem
Server-Dokument: ActionCable-Abo, `Y.UndoManager` (nur eigene Änderungen),
Awareness (Name, Farbe, Auswahl als Zeilen-/Spalten-IDs). Reihenfolge ist
wichtig: Vor jeder fremden Änderung gehen eigene, noch nicht übertragene
Änderungen zuerst nach Yjs; danach wird der Store mit
`applyExternalState` neu aufgebaut, wobei Auswahl, aktives Blatt und Zoom
der Person erhalten bleiben (die eigene Auswahl folgt eingefügten Zeilen).
- **Erste Umwandlung** eines JSON-Dokuments in Yjs geschieht im Browser,
deterministisch (feste Client-ID, IDs `r0…`/`c0…`): Öffnen zwei Personen es
gleichzeitig, entstehen identische Updates, die Yjs zu einem verschmilzt.
- **Zusammenfassen:** Ab 200 gespeicherten Updates schickt ein Editor den
Gesamtstand; der Server ersetzt genau die aufgelisteten Updates durch ihn.
Editoren halten außerdem `documents.content` als JSON-Snapshot aktuell
(alle 30 s bei Änderung, beim Verlassen, beim Zusammenfassen) – für
Duplizieren, Export und die Liste.
- **Wiederverbinden:** ActionCable verbindet neu, der Server schickt alle
Updates, der Browser schickt, was dem Server fehlt. Anzeige „Offline“ in der
Toolbar, solange die Verbindung weg ist.
- **Zoom** ist pro Person und wird nicht geteilt; Auswahl, aktives Blatt und
aktives Objekt ebenso nicht. Die Z-Reihenfolge (Klick holt ein Objekt nach
vorn) ist Dokumentinhalt; im Nur-Lesen-Modus ändert ein Klick sie nicht.
**Nur-Lesen im Store:** Ist `store.ui.readOnly` gesetzt, sind alle
inhaltsändernden Store-Funktionen wirkungslos (Liste `READ_ONLY_GUARDED` in
[`document.ts`](app/frontend/store/document.ts)); ein Versuch zeigt den Grund als Hinweis.
**DocumentChannel** ([`document_channel.rb`](app/channels/document_channel.rb)),
Binärdaten Base64:
| Richtung | Nachricht | Inhalt |
| --- | --- | --- |
| Browser → Server | `update` | ein Yjs-Update (nur Editoren) |
| Browser → Server | `awareness` | Auswahl/Name, wird nicht gespeichert |
| Browser → Server | `compact` | Gesamtstand + Liste der ersetzten Update-IDs + JSON-Snapshot |
| Browser → Server | `snapshot` | JSON-Snapshot |
| Server → Browser | `init` | Rolle, Titel, alle Updates; solange es keine gibt: das JSON zum Umwandeln |
| Server → Browser | `update`, `awareness`, `compacted` | weitergereicht bzw. bestätigt |
| Server → Browser | `meta`, `role`, `revoked` | Titel geändert, Rolle geändert, Zugang weg |
### Datenmodell
| Tabelle | Wichtige Spalten |
| --- | --- |
| `users` | `email` (citext, eindeutig), `display_name`, `color`, `last_seen_at` |
| `sessions` | `user_id`, `user_agent`, `ip_address`, `last_active_at` – signierte Session-ID im Cookie |
| `magic_links` | `email`, `token_digest`, `code_digest` (nur SHA-256), `purpose` (login/invitation), `document_id`, `role`, `invited_by_id`, `return_to`, `failed_attempts`, `expires_at`, `used_at` |
| `documents` | `id` (UUID, in der URL), `owner_id`, `title`, `content` (JSON-Snapshot), `updated_by_id`, `content_updated_at` |
| `document_updates` | `document_id`, `user_id`, `data` (ein Yjs-Update oder ein zusammengefasster Stand) |
| `document_memberships` | `document_id`, `user_id` (eindeutig zusammen), `role` (viewer/editor), `source` (invite/link), `share_link_id` |
| `share_links` | `document_id`, `role`, `token` (Klartext, damit der Besitzer den Link erneut kopieren kann), `created_by_id`, `revoked_at` |
Der Besitzer steht nur in `documents.owner_id`. Pro Dokument gibt es höchstens
einen aktiven Link je Rolle.
### API
REST unter `/api`, JSON, Session-Cookie, CSRF-Token aus dem Meta-Tag;
Link-Zugang über den Header `X-Share-Token`.
| Methode & Pfad | Zweck |
| --- | --- |
| `POST /api/auth/magic_link` | Link + Code anfordern `{email, return_to}` – Antwort immer gleich |
| `POST /api/auth/code` | mit Code anmelden `{email, code}` |
| `DELETE /api/auth/session` | abmelden |
| `GET/PATCH /api/me` | angemeldeter User / Anzeigename |
| `GET/POST /api/documents` | eigene + geteilte Dokumente / anlegen oder hochladen `{title, content}` |
| `GET /api/documents/:id` | Metadaten, Rolle, JSON-Snapshot (`?content=0` ohne) |
| `PATCH /api/documents/:id` | umbenennen `{title}` |
| `DELETE /api/documents/:id` | Besitzer: löschen; sonst: aus der Liste entfernen |
| `POST /api/documents/:id/join` | Link-Token einlösen `{token}` |
| `GET /api/documents/:id/share` | Links, Mitglieder, offene Einladungen (Besitzer) |
| `POST/DELETE /api/documents/:id/share/links[/:id]` | Link je Rolle neu erzeugen / widerrufen |
| `POST /api/documents/:id/share/invitations` | einladen `{email, role}` |
| `PATCH/DELETE /api/documents/:id/share/members/:user_id` | Rolle ändern / entfernen |
| `GET/POST /auth/verify?token=` | Bestätigungsseite / Anmeldung aus dem Mail-Link |
### Konfiguration & Betrieb
Einstellungen stehen in einer **`.env`** im Projektverzeichnis (Gem `dotenv`;
auch `.env.production` usw.). Vorlage: [`.env.example`](.env.example) –
`cp .env.example .env` und ausfüllen. Echte Umgebungsvariablen haben Vorrang;
`.env` wird nie committet.
| Variable | Wofür |
| --- | --- |
| `NUMOLUS_DATABASE_NAME`, `_HOST`, `_PORT`, `_USER`, `_PASSWORD` | PostgreSQL (Standardname `numolus`; ohne Host/User: lokaler Socket) |
| `NUMOLUS_HOST` | Host in den Links der Mails |
| `SECRET_KEY_BASE` | Produktion (`bin/rails secret`) |
| `NUMOLUS_MAIL_FROM`, `SMTP_ADDRESS`, `SMTP_PORT`, `SMTP_USER_NAME`, `SMTP_PASSWORD` | Mailversand in Produktion |
In der Entwicklung werden Mails nicht verschickt, sondern unter
<http://localhost:3000/letter_opener> angezeigt. Produktion braucht einen
Rails-Server (Puma) mit PostgreSQL; `bin/jobs` bzw. `SOLID_QUEUE_IN_PUMA=1`
für den Mailversand.
### Sicherheit
- Magic-Link-Token und Code nur als SHA-256 gespeichert, 15 Minuten gültig,
einmal verwendbar; nach 5 falschen Codes ist der Link verbrannt.
- Rate Limits: 20 Link-Anforderungen pro IP und Stunde (dazu 5 pro E-Mail),
30 Code-Versuche pro IP in 15 Minuten.
- `return_to` nur als Pfad derselben Seite; `404` statt `403`; UUIDs.
- Cookies `HttpOnly`, `SameSite=Lax`, in Produktion `Secure`; CSRF-Schutz im
REST-API.
- Yjs-Updates max. 10 MB, JSON-Snapshots max. 20 MB und nur, wenn sie wie ein
numolus-Dokument aussehen.
### Offen
- **Formeln bei gleichzeitigem Einfügen:** Tippt jemand eine Formel, während
ein anderer eine Zeile einfügt, wird deren Bezug nicht angepasst (wie in
Google Sheets). Später denkbar: Bezüge intern über IDs.
- **Große Dokumente:** Jede fremde Änderung baut den ganzen Store neu auf;
später nur die betroffene Tabelle.
- **Makros aus fremder Hand** laufen im eigenen Browser, sobald man sie
startet; eine Bestätigung für fremde Makros fehlt noch.
- Papierkorb, Aufräumjobs (abgelaufene Magic Links, alte Sessions),
„Überall abmelden“, Konto löschen / Datenexport, Offline-Kopie von
Server-Dokumenten (`y-indexeddb`), Backups und Monitoring.
- Datenschutzerklärung (gespeichert werden E-Mail, Anzeigename, IP und
User-Agent je Session).
## Vorbefülltes Dokument (Datei › Muster) ## Vorbefülltes Dokument (Datei › Muster)
@@ -1123,7 +1400,7 @@ Blatt **„Analyse"** mit drei Tabellen-Objekten (**Umsatz**, **Kosten**,
**Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt **Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt
**„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier **„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier
Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
`E2 = =D2/B2*100`. Siehe [`src/data/seed.ts`](src/data/seed.ts). `E2 = =D2/B2*100`. Siehe [`app/frontend/data/seed.ts`](app/frontend/data/seed.ts).
> **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 = > **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 =
> 6070". Die explizit angegebene Nord-Zeile ergibt jedoch > 6070". Die explizit angegebene Nord-Zeile ergibt jedoch
@@ -1133,7 +1410,8 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 570 Tests: `npm run test` — Vitest (Engine, Store, IO, Yjs-Abbildung);
`bin/rails test` — Minitest (Models, REST-API, Anmeldung, Teilen, DocumentChannel):
- **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** `::`,
@@ -1498,10 +1776,21 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Projektstruktur ## Projektstruktur
``` ```
src/ app/
models/ # User, Session, MagicLink, Document, DocumentUpdate, DocumentMembership, ShareLink
controllers/ # SpaController (liefert die App), AuthController (Mail-Link), api/* (REST)
channels/ # DocumentChannel (Live-Bearbeitung), ApplicationCable::Connection
mailers/, views/ # AuthMailer (Anmeldung, Einladung), Bestätigungsseite, SPA-Seite
config/, db/ # Rails-Konfiguration, Migrationen (inkl. Solid Queue/Cache/Cable)
test/ # Minitest: Models, API, Channel
.env.example # Vorlage für Datenbank, Host, SMTP
app/frontend/
entrypoints/ # application.ts – Einstieg (vite_rails)
engine/ # Formel-Engine (Eigenbau) + Unit-Tests engine/ # Formel-Engine (Eigenbau) + Unit-Tests
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung store/ # Dokument-Store (v2 Canvas-Modell), Formatierung, library.ts (lokal/Server), session.ts (Konto)
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy) collab/ # Live-Bearbeitung: ydoc.ts (Yjs-Abbildung), session.ts (ActionCable, Undo, Awareness)
io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy),
# library.ts (IndexedDB), api.ts (REST-Client)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject, components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
# TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs, # TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
# StatusBar, TheLogo, … # StatusBar, TheLogo, …
+6
View File
@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
@@ -0,0 +1,12 @@
module ApplicationCable
# The WebSocket knows the signed-in user from the same session cookie as
# the rest of the app. Anonymous connections are allowed: a share link
# token (a subscription parameter) can grant access on its own.
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = Session.find_by(id: cookies.signed[:session_id])&.user if cookies.signed[:session_id]
end
end
end
+114
View File
@@ -0,0 +1,114 @@
# Live collaboration on one document (Yjs). Every edit is a Yjs update: the
# server stores it and passes it on to everyone connected; it never needs
# to understand what's inside. Binary data travels Base64-encoded.
#
# From the browser (perform):
# update { data } an edit (editors only)
# awareness { data } cursor/selection/name, not stored
# compact { state, ids, snapshot } replace the listed updates by one
# state; snapshot = JSON of the document
# snapshot { content } JSON of the document, for export etc.
#
# To the browser:
# init { role, title, updates: [{ id, data }], content? }
# content (JSON) only while no update exists yet: the browser
# then builds the Yjs form and sends it as the first update
# update { id, data, from } from = the sending subscription
# awareness { data, from }
# compacted { removed: [ids], id }
# meta { title }
# role { role } access changed
# revoked access gone (or document deleted)
class DocumentChannel < ApplicationCable::Channel
def subscribed
@document = Document.find_by(id: params[:id])
@token = params[:token].presence
@key = params[:key].to_s[0, 64]
@role = @document&.role_for(user: current_user, token: @token)
return reject unless @role
stream_for @document, coder: ActiveSupport::JSON do |message|
relay(message)
end
transmit init_message
end
def update(data)
return unless editor?
bytes = decode(data["data"]) or return
row = @document.updates.create!(data: bytes, user: current_user)
@document.update_columns(content_updated_at: Time.current, updated_by_id: current_user&.id)
broadcast({ type: "update", id: row.id, data: data["data"], from: @key })
rescue ActiveRecord::RecordInvalid
transmit({ type: "error", error: "too_large" })
end
def awareness(data)
return unless data["data"].is_a?(String) && data["data"].bytesize < 64.kilobytes
broadcast({ type: "awareness", data: data["data"], from: @key })
end
def compact(data)
return unless editor?
state = decode(data["state"]) or return
ids = Array(data["ids"]).map(&:to_i)
row = nil
@document.with_lock do
@document.updates.where(id: ids).delete_all
row = @document.updates.create!(data: state, user: current_user)
store_snapshot(data["snapshot"])
end
broadcast({ type: "compacted", removed: ids, id: row.id })
end
def snapshot(data)
return unless editor?
store_snapshot(data["content"])
end
private
def editor? = Document.role_at_least?(@role, :editor)
def broadcast(message) = DocumentChannel.broadcast_to(@document, message)
def relay(message)
case message["type"]
when "revalidate" then revalidate
when "awareness" then transmit(message) unless message["from"] == @key
else transmit(message)
end
end
# Membership or link changed: is this connection still allowed in?
def revalidate
role = Document.find_by(id: @document.id)&.role_for(user: current_user, token: @token)
if role.nil?
@role = nil
transmit({ type: "revoked" })
stop_all_streams
elsif role != @role
@role = role
transmit({ type: "role", role: })
end
end
def init_message
updates = @document.updates.order(:id).map(&:as_message)
message = { type: "init", role: @role, title: @document.title, updates: }
message[:content] = @document.content if updates.empty?
message
end
def decode(b64)
return unless b64.is_a?(String)
Base64.strict_decode64(b64)
rescue ArgumentError
nil
end
def store_snapshot(json)
return unless json.is_a?(String) && json.bytesize <= Document::MAX_CONTENT_BYTES && Document.valid_snapshot?(json)
@document.update_columns(content: json)
end
end
+37
View File
@@ -0,0 +1,37 @@
module Api
class AuthController < BaseController
rate_limit to: 20, within: 1.hour, only: :magic_link, with: -> { too_many }
rate_limit to: 30, within: 15.minutes, only: :code, with: -> { too_many }
# POST /api/auth/magic_link { email, return_to }
# Always the same answer, so it can't be used to find out who has an account.
def magic_link
email = params.require(:email).to_s
unless MagicLink.throttled?(email)
link = MagicLink.issue!(email:, return_to: params[:return_to])
AuthMailer.magic_link(link, link.token, link.code).deliver_later
end
render json: { ok: true }
rescue ActiveRecord::RecordInvalid
render json: { error: "invalid_email" }, status: :unprocessable_entity
end
# POST /api/auth/code { email, code } -- the 6-digit code from the mail
def code
link = MagicLink.find_by_code(params.require(:email), params.require(:code))
return render(json: { error: "invalid_code" }, status: :unprocessable_entity) unless link
start_session_for(link.redeem!)
render json: { user: Current.user.as_json, redirect: link.destination }
end
# DELETE /api/auth/session
def logout
terminate_session
render json: { ok: true }
end
private
def too_many = render(json: { error: "rate_limited" }, status: :too_many_requests)
end
end
+21
View File
@@ -0,0 +1,21 @@
module Api
class BaseController < ApplicationController
# the app sends the CSRF token from the page's meta tag as X-CSRF-Token
protect_from_forgery with: :exception
rescue_from ActiveRecord::RecordNotFound do
render json: { error: "not_found" }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: "invalid", messages: e.record.errors.full_messages }, status: :unprocessable_entity
end
rescue_from ActionController::InvalidAuthenticityToken do
render json: { error: "csrf" }, status: :unprocessable_entity
end
private
# a share link token, for visitors (with or without account) who got the link
def share_token = request.headers["X-Share-Token"].presence
end
end
@@ -0,0 +1,78 @@
module Api
# Server documents. Listing and creating need an account; reading and
# writing a single document also work with just a share link token.
class DocumentsController < BaseController
before_action :require_authentication, only: %i[index create]
before_action :load_document, except: %i[index create]
# GET /api/documents -- owned + shared with me
def index
user = Current.user
memberships = user.document_memberships.index_by(&:document_id)
docs = user.accessible_documents.includes(:owner, :updated_by).order(content_updated_at: :desc)
render json: {
documents: docs.map { |d| d.meta_json(role: d.owner_id == user.id ? :owner : memberships[d.id]&.role) }
}
end
# POST /api/documents { title, content } -- also used to upload a local document
def create
doc = Current.user.owned_documents.create!(
title: params.require(:title), content: content_param, updated_by: Current.user
)
render json: { document: doc.meta_json(role: :owner) }, status: :created
end
# GET /api/documents/:id[?content=0]
# The content is the JSON snapshot the editors keep current (the live
# content comes through DocumentChannel).
def show
touch_membership
meta = @doc.meta_json(role: @role)
meta[:content] = @doc.content unless params[:content] == "0"
render json: { document: meta }
end
# PATCH /api/documents/:id { title }
def update
return forbidden unless Document.role_at_least?(@role, :editor)
@doc.update!(title: params.require(:title))
@doc.broadcast({ type: "meta", title: @doc.title })
render json: { document: @doc.meta_json(role: @role) }
end
# DELETE /api/documents/:id -- the owner deletes it; anyone else leaves it
def destroy
if @role == :owner
@doc.destroy!
@doc.broadcast({ type: "revoked" })
elsif Current.user
@doc.document_memberships.where(user: Current.user).delete_all
@doc.revalidate_access!
else
return forbidden
end
head :no_content
end
private
def load_document
@doc = Document.find(params[:id])
@role = @doc.role_for(user: Current.user, token: share_token)
raise ActiveRecord::RecordNotFound unless @role # 404, not 403: don't confirm it exists
end
def content_param
c = params.require(:content)
c.is_a?(String) ? c : c.to_unsafe_h.to_json
end
def touch_membership
return unless Current.user
@doc.document_memberships.where(user: Current.user).update_all(last_opened_at: Time.current)
end
def forbidden = render(json: { error: "forbidden" }, status: :forbidden)
end
end
+17
View File
@@ -0,0 +1,17 @@
module Api
# POST /api/documents/:document_id/join { token }
# Redeems a share link. Signed in: the document is added to the account
# (a membership). Not signed in: only answers what the token grants -- the
# browser keeps the token and sends it along (X-Share-Token).
class JoinsController < BaseController
def create
doc = Document.find(params[:document_id])
link = doc.share_links.active.find_by(token: params.require(:token))
raise ActiveRecord::RecordNotFound unless link
if Current.user
DocumentMembership.grant!(document: doc, user: Current.user, role: link.role, source: :link, share_link: link)
end
render json: { document: doc.meta_json(role: doc.role_for(user: Current.user, token: link.token)) }
end
end
end
+16
View File
@@ -0,0 +1,16 @@
module Api
class MeController < BaseController
before_action :require_authentication, only: :update
# GET /api/me
def show
render json: { user: Current.user&.as_json }
end
# PATCH /api/me { display_name }
def update
Current.user.update!(params.permit(:display_name))
render json: { user: Current.user.as_json }
end
end
end
+78
View File
@@ -0,0 +1,78 @@
module Api
# Everything about who has access to a document. Owner only.
class SharesController < BaseController
before_action :require_authentication
before_action :load_owned_document
# GET /api/documents/:document_id/share
def show
render json: share_json
end
# POST /api/documents/:document_id/share/links { role }
# One active link per role: creating one replaces (revokes) the previous.
def create_link
role = params.require(:role)
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless ShareLink.roles.key?(role)
@doc.transaction do
@doc.share_links.active.where(role:).find_each(&:revoke!)
@doc.share_links.create!(role:, created_by: Current.user)
end
@doc.revalidate_access!
render json: share_json
end
# DELETE /api/documents/:document_id/share/links/:id
def revoke_link
@doc.share_links.active.find(params[:id]).revoke!
@doc.revalidate_access!
render json: share_json
end
# POST /api/documents/:document_id/share/invitations { email, role }
def invite
role = params.require(:role)
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless MagicLink.roles.key?(role)
link = MagicLink.issue!(email: params.require(:email), purpose: :invitation, document: @doc, role:, invited_by: Current.user)
if link.email == Current.user.email
link.destroy!
return render(json: { error: "self_invite" }, status: :unprocessable_entity)
end
AuthMailer.invitation(link, link.token).deliver_later
render json: share_json
end
# PATCH /api/documents/:document_id/share/members/:user_id { role }
def update_member
m = @doc.document_memberships.find_by!(user_id: params[:user_id])
m.update!(role: params.require(:role))
@doc.revalidate_access!
render json: share_json
end
# DELETE /api/documents/:document_id/share/members/:user_id
def remove_member
@doc.document_memberships.where(user_id: params[:user_id]).delete_all
@doc.revalidate_access!
render json: share_json
end
private
def load_owned_document
@doc = Current.user.owned_documents.find(params[:document_id])
end
def share_json
{
links: @doc.share_links.active.order(:created_at).map(&:as_json),
members: @doc.document_memberships.includes(:user).order(:created_at).map do |m|
{ user_id: m.user_id, name: m.user.name, email: m.user.email, role: m.role, source: m.source }
end,
invitations: MagicLink.usable.invitation.where(document: @doc).order(:created_at).map do |i|
{ email: i.email, role: i.role, expires_at: i.expires_at.iso8601 }
end
}
end
end
end
@@ -0,0 +1,6 @@
class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
end
+22
View File
@@ -0,0 +1,22 @@
# The page behind the link in a sign-in / invitation mail. Opening the link
# (GET) only shows a button; the sign-in happens on POST. Mail scanners
# fetch links on their own and would otherwise use up the one-time token.
class AuthController < ApplicationController
layout "auth"
rate_limit to: 30, within: 15.minutes, only: :create
def show
@token = params[:token].to_s
@link = MagicLink.find_by_token(@token)
render :invalid, status: :not_found unless @link
end
def create
link = MagicLink.find_by_token(params[:token])
return render(:invalid, status: :not_found) unless link
start_session_for(link.redeem!)
redirect_to link.destination
rescue ActiveRecord::RecordNotFound
render :invalid, status: :not_found
end
end
View File
@@ -0,0 +1,43 @@
# Cookie session (signed session id), resumed on every request. Everything
# is usable without signing in; controllers call `require_authentication`
# where an account is needed.
module Authentication
extend ActiveSupport::Concern
included do
before_action :resume_session
helper_method :signed_in?
end
private
def signed_in? = Current.user.present?
def resume_session
Current.session = Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
touch_session if Current.session
end
def touch_session
s = Current.session
return if s.last_active_at && s.last_active_at > 1.hour.ago
s.update_columns(last_active_at: Time.current)
s.user.update_columns(last_seen_at: Time.current)
end
def require_authentication
render json: { error: "unauthenticated" }, status: :unauthorized unless signed_in?
end
def start_session_for(user)
reset_session
Current.session = user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, last_active_at: Time.current)
cookies.signed.permanent[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax, secure: Rails.env.production? }
end
def terminate_session
Current.session&.destroy
Current.session = nil
cookies.delete(:session_id)
end
end
+9
View File
@@ -0,0 +1,9 @@
# Serves the Vue app for every client-side route (/, /d/<id>). The signed-in
# user is embedded so the app knows it before its first render.
class SpaController < ApplicationController
layout false
def show
@boot = { user: Current.user&.as_json }
end
end
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { Binding, initialUpdate, readDocument, REMOTE } from '../ydoc';
import type { DocumentState, TableObject } from '../../store/types';
import { createSeedDocument } from '../../data/seed';
function docWithTable(cells: Record<string, string>, rows = 5, cols = 3): DocumentState {
const t: TableObject = {
kind: 'table',
id: 't1',
name: 'Tabelle 1',
x: 0,
y: 0,
rows,
cols,
colWidths: { 1: 120 },
rowHeights: { 2: 40 },
cells,
formats: { '0,0': { bold: true } },
view: { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 },
};
return {
version: 2,
sheets: [{ id: 's1', name: 'Blatt 1', objects: [t] }],
activeSheetId: 's1',
activeObjectId: null,
selection: null,
locale: 'de-DE',
csvDelimiter: ',',
customFunctions: [],
namedRanges: [],
macros: [],
};
}
const table = (d: DocumentState) => d.sheets[0].objects[0] as TableObject;
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
/** two people on the same document, updates exchanged explicitly */
function pair(start: DocumentState) {
const a = new Y.Doc();
const b = new Y.Doc();
Y.applyUpdate(a, initialUpdate(start), REMOTE);
Y.applyUpdate(b, initialUpdate(start), REMOTE);
const exchange = () => {
Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)), REMOTE);
Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)), REMOTE);
};
return { a, b, ba: new Binding(a), bb: new Binding(b), exchange };
}
describe('Yjs document form', () => {
it('round-trips the whole document (seed)', () => {
const seed = createSeedDocument();
const y = new Y.Doc();
Y.applyUpdate(y, initialUpdate(seed));
const back = readDocument(y);
const strip = (d: DocumentState) => ({
...d,
activeSheetId: '',
activeObjectId: null,
selection: null,
sheets: d.sheets.map((s) => ({ ...s, zoom: undefined })),
});
expect(JSON.parse(JSON.stringify(strip(back)))).toEqual(JSON.parse(JSON.stringify(strip(seed))));
});
it('initial update is deterministic, so two conversions merge into one', () => {
const d = docWithTable({ '0,0': 'x' });
expect(initialUpdate(d)).toEqual(initialUpdate(d));
const y = new Y.Doc();
Y.applyUpdate(y, initialUpdate(d));
Y.applyUpdate(y, initialUpdate(d));
expect(table(readDocument(y)).cells).toEqual({ '0,0': 'x' });
});
it('edits to different cells by two people merge', () => {
const { a, b, ba, bb, exchange } = pair(docWithTable({}));
const da = readDocument(a);
table(da).cells['0,0'] = 'Anna';
ba.sync(da);
const db = readDocument(b);
table(db).cells['2,4'] = 'Bob';
bb.sync(db);
exchange();
expect(table(readDocument(a)).cells).toEqual({ '0,0': 'Anna', '2,4': 'Bob' });
expect(readDocument(b)).toEqual(readDocument(a));
});
it('a row inserted by one person does not misplace what the other types', () => {
const start = docWithTable({ '0,3': 'row 4 before' });
const { a, b, ba, bb, exchange } = pair(start);
// Anna inserts a row at index 1 (the store shifts cells, and reports it)
const da = readDocument(a);
const ta = table(da);
ta.cells = { '0,4': 'row 4 before' };
ta.rowHeights = { 3: 40 };
ta.rows += 1;
ba.structure('t1', 'row', 1, 1);
ba.sync(da);
// meanwhile Bob, not having seen that, types next to "row 4 before"
const db = readDocument(b);
table(db).cells['1,3'] = 'Bob';
bb.sync(db);
exchange();
const cells = table(readDocument(a)).cells;
expect(cells).toEqual({ '0,4': 'row 4 before', '1,4': 'Bob' });
expect(table(readDocument(a)).rows).toBe(6);
expect(table(readDocument(a)).rowHeights).toEqual({ 3: 40 });
expect(readDocument(b)).toEqual(readDocument(a));
});
it('deleting a column removes its cells and widths', () => {
const { a, ba } = pair(docWithTable({ '0,0': 'a', '1,0': 'b', '2,0': 'c' }));
const d = readDocument(a);
const t = table(d);
t.cells = { '0,0': 'a', '1,0': 'c' };
t.colWidths = {};
t.cols = 2;
ba.structure('t1', 'col', 1, -1);
ba.sync(d);
const back = table(readDocument(a));
expect(back.cells).toEqual({ '0,0': 'a', '1,0': 'c' });
expect(back.colWidths).toEqual({});
expect(back.cols).toBe(2);
});
it('growing and shrinking at the edge needs no structure event', () => {
const { a, ba } = pair(docWithTable({ '0,4': 'last' }));
const d = readDocument(a);
table(d).rows = 8;
table(d).cells['0,7'] = 'new last';
ba.sync(d);
expect(table(readDocument(a)).rows).toBe(8);
const d2 = readDocument(a);
table(d2).rows = 5;
delete table(d2).cells['0,7'];
ba.sync(d2);
expect(table(readDocument(a)).rows).toBe(5);
expect(table(readDocument(a)).cells).toEqual({ '0,4': 'last' });
});
it('sheets, objects and lists: add, change, reorder, remove', () => {
const { a, b, ba, exchange } = pair(docWithTable({}));
const d = clone(readDocument(a));
d.sheets.push({ id: 's2', name: 'Zwei', objects: [] });
d.sheets[0].objects.push({ kind: 'text', id: 'x1', name: 'Text', x: 1, y: 2, width: 10, height: 10, text: 'Hi', fontSize: 14, align: 'left' });
d.customFunctions.push({ id: 'f1', name: 'DOPPELT', params: ['x'], body: 'x*2', kind: 'expression' });
ba.sync(d);
exchange();
const got = readDocument(b);
expect(got.sheets.map((s) => s.id)).toEqual(['s1', 's2']);
expect(got.sheets[0].objects.map((o) => o.id)).toEqual(['t1', 'x1']);
expect(got.customFunctions[0].name).toBe('DOPPELT');
const d2 = clone(readDocument(a));
d2.sheets.reverse();
d2.sheets[1].objects.reverse(); // z-order
d2.customFunctions = [];
ba.sync(d2);
exchange();
const got2 = readDocument(b);
expect(got2.sheets.map((s) => s.id)).toEqual(['s2', 's1']);
expect(got2.sheets[1].objects.map((o) => o.id)).toEqual(['x1', 't1']);
expect(got2.customFunctions).toEqual([]);
});
it('a sync without changes produces no update', () => {
const { a, ba } = pair(docWithTable({ '0,0': 'x' }));
let updates = 0;
a.on('update', () => updates++);
ba.sync(readDocument(a));
expect(updates).toBe(0);
});
});
+401
View File
@@ -0,0 +1,401 @@
/**
* One live-editing session on a server document: the Yjs document, its
* connection to the server (ActionCable, see app/channels/document_channel.rb),
* per-person undo and the presence of the others (awareness: who is here,
* what they have selected).
*
* The store stays the single source for the UI. Local edits flow
* store → Binding.sync → Yjs → server; the others' edits flow
* server → Yjs → readDocument → store.applyExternalState. Before a remote
* change is applied, any not-yet-synced local change goes into Yjs first, so
* neither side can overwrite the other.
*/
import * as Y from 'yjs';
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
import { fromBase64, toBase64 } from 'lib0/buffer';
import { createConsumer, type Consumer, type Subscription } from '@rails/actioncable';
import { reactive, ref, watch, type WatchStopHandle } from 'vue';
import { useDocument, setCollabHooks, contentFingerprint, type UndoProvider } from '../store/document';
import type { DocumentState } from '../store/types';
import type { Role } from '../io/api';
import { Binding, LOCAL, REMOTE, allAxes, initialUpdate, isInitialized, readDocument, type Axes } from './ydoc';
import { validateDocument } from '../io/json';
const SYNC_DELAY = 40; // ms: coalesce a burst of store changes into one Yjs transaction
const SNAPSHOT_EVERY = 30_000; // JSON snapshot for the server (export, duplicate)
const COMPACT_AFTER = 200; // stored updates before they're merged into one
let consumer: Consumer | null = null;
const cable = () => (consumer ??= createConsumer('/cable'));
export interface Peer {
clientId: number;
name: string;
color: string;
sheetId: string | null;
tableId: string | null;
/** selection rectangle in current positions, or null */
rect: { c0: number; r0: number; c1: number; r1: number } | null;
}
export interface SessionEvents {
onRole(role: Role): void;
onRevoked(): void;
onTitle(title: string): void;
}
interface InitMsg {
type: 'init';
role: Role;
title: string;
updates: { id: number; data: string }[];
content?: string;
}
type Msg =
| InitMsg
| { type: 'update'; id: number; data: string; from: string }
| { type: 'awareness'; data: string; from: string }
| { type: 'compacted'; removed: number[]; id: number }
| { type: 'meta'; title: string }
| { type: 'role'; role: Role }
| { type: 'revoked' }
| { type: 'error'; error: string };
type SelIds = { tableId: string; a: [string, string]; f: [string, string] } | null;
export class CollabSession {
readonly ydoc = new Y.Doc();
readonly awareness = new Awareness(this.ydoc);
readonly peers = reactive<Peer[]>([]);
readonly online = ref(false);
role: Role = 'viewer';
private store = useDocument();
private binding = new Binding(this.ydoc);
private undoManager: Y.UndoManager | null = null;
private sub: Subscription | null = null;
private key = Math.random().toString(36).slice(2) + Date.now().toString(36);
private syncTimer: ReturnType<typeof setTimeout> | undefined;
private snapshotTimer: ReturnType<typeof setInterval> | undefined;
private compactTimer: ReturnType<typeof setTimeout> | undefined;
private queue: Msg[] = [];
private queued = false;
private knownIds = new Set<number>();
private axes = new Map<string, Axes>();
private lastSnapshot = '';
private initialized = false;
private stopWatch: WatchStopHandle | null = null;
private attached = false;
private ready!: { resolve: (d: DocumentState) => void; reject: (e: Error) => void };
readonly canUndo = ref(false);
readonly canRedo = ref(false);
constructor(
private docId: string,
private token: string | null,
private user: { name: string; color: string },
private events: SessionEvents,
) {
this.ydoc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === REMOTE || !this.canEdit) return;
this.sub?.perform('update', { data: toBase64(update) });
});
this.awareness.on('update', ({ added, updated, removed }: Record<string, number[]>, origin: unknown) => {
if (origin !== 'local') return;
const mine = [...added, ...updated, ...removed].filter((c) => c === this.ydoc.clientID);
if (mine.length) this.sub?.perform('awareness', { data: toBase64(encodeAwarenessUpdate(this.awareness, mine)) });
});
this.awareness.on('change', () => this.updatePeers());
}
get canEdit() {
return this.role !== 'viewer';
}
/** Connect; resolves with the document once the server sent it. */
start(): Promise<DocumentState> {
const done = new Promise<DocumentState>((resolve, reject) => (this.ready = { resolve, reject }));
this.sub = cable().subscriptions.create(
{ channel: 'DocumentChannel', id: this.docId, token: this.token, key: this.key },
{
received: (m: Msg) => this.receive(m),
connected: () => (this.online.value = true),
disconnected: () => (this.online.value = false),
rejected: () => this.ready.reject(new Error('Kein Zugriff auf dieses Dokument.')),
},
);
return done;
}
/** Take over the store: sync hooks, per-person undo, own presence. */
attach(): UndoProvider {
this.attached = true;
this.axes = allAxes(this.ydoc);
setCollabHooks({
onChange: () => this.scheduleSync(),
onStructure: (tableId, axis, at, delta) => this.binding.structure(tableId, axis, at, delta),
});
const root = this.ydoc.getMap('numolus');
this.undoManager = new Y.UndoManager(root, { trackedOrigins: new Set([LOCAL]), captureTimeout: 400 });
const refresh = () => {
this.canUndo.value = this.undoManager!.canUndo();
this.canRedo.value = this.undoManager!.canRedo();
};
this.undoManager.on('stack-item-added', refresh);
this.undoManager.on('stack-item-popped', refresh);
this.undoManager.on('stack-cleared', refresh);
this.awareness.setLocalState({ user: this.user, sheetId: null, sel: null });
this.stopWatch = watch(
() => [this.store.doc.selection, this.store.doc.activeSheetId] as const,
() => this.publishSelection(),
{ deep: true, immediate: true },
);
this.snapshotTimer = setInterval(() => this.sendSnapshot(), SNAPSHOT_EVERY);
this.lastSnapshot = contentFingerprint(this.store.doc);
this.rebuild(); // whatever arrived since start() resolved
return {
undo: () => this.undoRedo('undo'),
redo: () => this.undoRedo('redo'),
canUndo: this.canUndo,
canRedo: this.canRedo,
};
}
/** Leave: send what's pending, say goodbye to the others, disconnect. */
destroy() {
this.flushLocal();
this.sendSnapshot();
if (this.attached) setCollabHooks(null);
this.stopWatch?.();
clearInterval(this.snapshotTimer);
clearTimeout(this.compactTimer);
removeAwarenessStates(this.awareness, [this.ydoc.clientID], 'local');
this.awareness.destroy();
this.undoManager?.destroy();
this.sub?.unsubscribe();
this.sub = null;
this.ydoc.destroy();
}
// ---- local → Yjs ---------------------------------------------------------
private scheduleSync() {
if (!this.canEdit) return;
clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(() => this.flushLocal(), SYNC_DELAY);
}
/** Put not-yet-synced store changes into Yjs now. */
flushLocal() {
if (this.syncTimer === undefined && !this.binding.hasPending) return;
clearTimeout(this.syncTimer);
this.syncTimer = undefined;
if (!this.canEdit || !isInitialized(this.ydoc)) return;
this.binding.sync(this.store.doc);
this.axes = allAxes(this.ydoc);
}
private undoRedo(which: 'undo' | 'redo') {
this.flushLocal();
if (which === 'undo') this.undoManager?.undo();
else this.undoManager?.redo();
this.rebuild();
}
// ---- server → Yjs ----------------------------------------------------------
private receive(m: Msg) {
// bursts are applied together, after the current task
this.queue.push(m);
if (this.queued) return;
this.queued = true;
queueMicrotask(() => {
this.queued = false;
const msgs = this.queue.splice(0);
this.process(msgs);
});
}
private process(msgs: Msg[]) {
let changed = false;
this.flushLocal();
for (const m of msgs) {
switch (m.type) {
case 'init':
this.init(m);
changed = this.initialized;
break;
case 'update':
this.knownIds.add(m.id);
if (m.from !== this.key) {
Y.applyUpdate(this.ydoc, fromBase64(m.data), REMOTE);
changed = true;
}
break;
case 'awareness':
applyAwarenessUpdate(this.awareness, fromBase64(m.data), 'remote');
break;
case 'compacted':
for (const id of m.removed) this.knownIds.delete(id);
this.knownIds.add(m.id);
clearTimeout(this.compactTimer);
this.compactTimer = undefined;
break;
case 'meta':
this.events.onTitle(m.title);
break;
case 'role':
this.role = m.role;
this.events.onRole(m.role);
break;
case 'revoked':
this.events.onRevoked();
return;
}
}
if (changed && this.attached) this.rebuild();
this.maybeCompact();
}
private init(m: InitMsg) {
const first = !this.initialized;
this.role = m.role;
// what the server has, to find out what it's missing from us (reconnect)
const server = new Y.Doc();
for (const u of m.updates) {
this.knownIds.add(u.id);
const bytes = fromBase64(u.data);
Y.applyUpdate(server, bytes);
Y.applyUpdate(this.ydoc, bytes, REMOTE);
}
if (!m.updates.length && m.content && !isInitialized(this.ydoc)) {
// not yet converted: everyone converts the same way (see initialUpdate)
let json: DocumentState;
try {
json = validateDocument(JSON.parse(m.content));
} catch (err) {
this.ready.reject(err as Error);
return;
}
const u = initialUpdate(json);
Y.applyUpdate(this.ydoc, u, REMOTE);
if (this.canEdit) this.sub?.perform('update', { data: toBase64(u) });
} else if (!first && this.canEdit) {
const missing = Y.encodeStateAsUpdate(this.ydoc, Y.encodeStateVector(server));
if (missing.length > 2) this.sub?.perform('update', { data: toBase64(missing) });
}
server.destroy();
if (!isInitialized(this.ydoc)) {
this.ready.reject(new Error('Das Dokument ist leer.'));
return;
}
this.initialized = true;
if (first) {
this.events.onTitle(m.title);
this.ready.resolve(readDocument(this.ydoc));
} else {
this.events.onRole(m.role);
}
}
/** Show the current Yjs state in the store, keeping this person's view. */
private rebuild() {
const selIds = this.selectionIds();
this.store.applyExternalState(readDocument(this.ydoc));
this.axes = allAxes(this.ydoc);
this.restoreSelection(selIds);
this.updatePeers();
}
// ---- selection by ids (survives rows inserted by others) --------------------
private idsAt(tableId: string, c: number, r: number): [string, string] | null {
const ax = this.axes.get(tableId);
return ax && ax.cols[c] !== undefined && ax.rows[r] !== undefined ? [ax.cols[c], ax.rows[r]] : null;
}
private posOf(tableId: string, ids: [string, string]): { col: number; row: number } | null {
const ax = this.axes.get(tableId);
if (!ax) return null;
const col = ax.cols.indexOf(ids[0]);
const row = ax.rows.indexOf(ids[1]);
return col < 0 || row < 0 ? null : { col, row };
}
private selectionIds(): SelIds {
const s = this.store.doc.selection;
if (!s) return null;
const a = this.idsAt(s.tableId, s.anchor.col, s.anchor.row);
const f = this.idsAt(s.tableId, s.focus.col, s.focus.row);
return a && f ? { tableId: s.tableId, a, f } : null;
}
private restoreSelection(ids: SelIds) {
const s = this.store.doc.selection;
if (!ids || !s || s.tableId !== ids.tableId) return;
const anchor = this.posOf(ids.tableId, ids.a);
const focus = this.posOf(ids.tableId, ids.f);
if (!anchor || !focus) return; // its row/column is gone: stay where it was (clamped)
if (anchor.col === s.anchor.col && anchor.row === s.anchor.row && focus.col === s.focus.col && focus.row === s.focus.row) return;
this.store.doc.selection = { tableId: ids.tableId, anchor, focus };
}
// ---- presence ---------------------------------------------------------------
private publishSelection() {
const sel = this.selectionIds();
this.awareness.setLocalStateField('sheetId', this.store.doc.activeSheetId);
this.awareness.setLocalStateField('sel', sel);
}
private updatePeers() {
const out: Peer[] = [];
for (const [clientId, st] of this.awareness.getStates()) {
if (clientId === this.ydoc.clientID || !st.user) continue;
const sel = st.sel as SelIds;
let rect: Peer['rect'] = null;
if (sel) {
const a = this.posOf(sel.tableId, sel.a);
const f = this.posOf(sel.tableId, sel.f);
if (a && f) {
rect = {
c0: Math.min(a.col, f.col),
c1: Math.max(a.col, f.col),
r0: Math.min(a.row, f.row),
r1: Math.max(a.row, f.row),
};
}
}
out.push({
clientId,
name: String(st.user.name ?? '?'),
color: String(st.user.color ?? '#888'),
sheetId: (st.sheetId as string) ?? null,
tableId: sel?.tableId ?? null,
rect,
});
}
this.peers.splice(0, this.peers.length, ...out);
}
// ---- housekeeping ------------------------------------------------------------
/** JSON of the document for the server (export, duplicate, list), when changed. */
private sendSnapshot() {
if (!this.canEdit || !this.initialized || !this.attached) return;
const fp = contentFingerprint(this.store.doc);
if (fp === this.lastSnapshot) return;
this.lastSnapshot = fp;
this.sub?.perform('snapshot', { content: JSON.stringify(this.store.doc) });
}
/** Many stored updates → one; whoever notices first does it. */
private maybeCompact() {
if (!this.canEdit || this.compactTimer || this.knownIds.size < COMPACT_AFTER) return;
this.compactTimer = setTimeout(() => {
this.flushLocal();
this.sub?.perform('compact', {
state: toBase64(Y.encodeStateAsUpdate(this.ydoc)),
ids: [...this.knownIds],
snapshot: JSON.stringify(this.store.doc),
});
// if no confirmation arrives, try again later
this.compactTimer = setTimeout(() => (this.compactTimer = undefined), 30_000);
}, 1000 + Math.random() * 3000);
}
}
+432
View File
@@ -0,0 +1,432 @@
/**
* The shared (Yjs) form of a numolus document, and the two directions
* between it and the store's plain `DocumentState`:
*
* - `readDocument(ydoc)` Yjs → DocumentState (after someone else's change)
* - `Binding.sync(state)` DocumentState → Yjs, as the *smallest* set of
* changes (a cell, a property), so that edits by
* different people merge instead of overwriting
*
* Layout (root = ydoc.getMap('numolus')):
*
* locale, csvDelimiter plain values
* sheetOrder: Y.Array<id> sheets: Y.Map<id, Y.Map>
* sheet: name, locale, background, ... plain values (zoom stays local)
* objectOrder: Y.Array<id> objects: Y.Map<id, Y.Map>
* object: kind, name, x, y, ... plain values
* table additionally:
* rowOrder / colOrder: Y.Array<rowId | colId>
* cells: Y.Map<"colId|rowId", raw input>
* formats: Y.Map<"colId|rowId", CellFormat>
* colWidths: Y.Map<colId, px> rowHeights: Y.Map<rowId, px>
* fnOrder/fns, nameOrder/names, macroOrder/macros id-keyed lists
*
* Rows and columns have stable ids: the store addresses cells by position
* ("col,row"), Yjs by id. Inserting a row inserts one id into `rowOrder` --
* no cell moves -- so someone typing into row 7 while someone else inserts
* a row above still lands in the row they meant. The store reports such
* inserts/deletes explicitly (`Binding.structure`), because a diff of
* positional cells can't tell "row inserted" from "every cell below changed".
*/
import * as Y from 'yjs';
import type {
CustomFunction,
DocumentState,
Macro,
NamedRange,
Sheet,
SheetObject,
TableObject,
} from '../store/types';
/** transaction origin of this person's own edits (tracked by undo) */
export const LOCAL = Symbol('local');
/** transaction origin of changes received from the server */
export const REMOTE = Symbol('remote');
type YMap = Y.Map<unknown>;
type YArr = Y.Array<string>;
const TABLE_STRUCT = new Set(['rows', 'cols', 'cells', 'formats', 'colWidths', 'rowHeights']);
const SHEET_STRUCT = new Set(['id', 'objects', 'zoom']);
const LISTS = [
['fnOrder', 'fns', 'customFunctions'],
['nameOrder', 'names', 'namedRanges'],
['macroOrder', 'macros', 'macros'],
] as const;
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b);
const randomId = (p: string) => p + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
// ---- building ------------------------------------------------------------
type IdGen = (axis: 'r' | 'c', index: number) => string;
const deterministicIds: IdGen = (axis, i) => `${axis}${i}`;
const randomIds: IdGen = (axis) => randomId(axis);
function setPlain(m: YMap, obj: object, skip: Set<string>) {
for (const [k, v] of Object.entries(obj)) {
if (skip.has(k) || v === undefined) continue;
m.set(k, v);
}
}
function buildTable(m: YMap, t: TableObject, ids: IdGen) {
const rowIds = Array.from({ length: t.rows }, (_, i) => ids('r', i));
const colIds = Array.from({ length: t.cols }, (_, i) => ids('c', i));
const rowOrder = new Y.Array<string>();
rowOrder.push(rowIds);
const colOrder = new Y.Array<string>();
colOrder.push(colIds);
m.set('rowOrder', rowOrder);
m.set('colOrder', colOrder);
const cells = new Y.Map<string>();
const formats = new Y.Map<unknown>();
const colWidths = new Y.Map<number>();
const rowHeights = new Y.Map<number>();
const key = (k: string) => {
const [c, r] = k.split(',').map(Number);
return c < colIds.length && r < rowIds.length ? `${colIds[c]}|${rowIds[r]}` : null;
};
for (const [k, v] of Object.entries(t.cells)) {
const id = key(k);
if (id) cells.set(id, v);
}
for (const [k, v] of Object.entries(t.formats ?? {})) {
const id = key(k);
if (id) formats.set(id, v);
}
for (const [c, w] of Object.entries(t.colWidths ?? {})) if (colIds[+c]) colWidths.set(colIds[+c], w);
for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rowIds[+r]) rowHeights.set(rowIds[+r], h);
m.set('cells', cells);
m.set('formats', formats);
m.set('colWidths', colWidths);
m.set('rowHeights', rowHeights);
}
function buildObject(o: SheetObject, ids: IdGen): YMap {
const m = new Y.Map<unknown>();
if (o.kind === 'table') {
setPlain(m, o, TABLE_STRUCT);
buildTable(m, o, ids);
} else {
setPlain(m, o, new Set());
}
return m;
}
function buildSheet(s: Sheet, ids: IdGen): YMap {
const m = new Y.Map<unknown>();
setPlain(m, s, SHEET_STRUCT);
m.set('id', s.id);
const order = new Y.Array<string>();
order.push(s.objects.map((o) => o.id));
const objects = new Y.Map<unknown>();
for (const o of s.objects) objects.set(o.id, buildObject(o, ids));
m.set('objectOrder', order);
m.set('objects', objects);
return m;
}
function build(root: YMap, d: DocumentState, ids: IdGen) {
root.set('locale', d.locale);
root.set('csvDelimiter', d.csvDelimiter);
const order = new Y.Array<string>();
order.push(d.sheets.map((s) => s.id));
const sheets = new Y.Map<unknown>();
for (const s of d.sheets) sheets.set(s.id, buildSheet(s, ids));
root.set('sheetOrder', order);
root.set('sheets', sheets);
for (const [orderKey, itemsKey, field] of LISTS) {
const list = d[field] as { id: string }[];
const o = new Y.Array<string>();
o.push(list.map((x) => x.id));
const items = new Y.Map<unknown>();
for (const x of list) items.set(x.id, x);
root.set(orderKey, o);
root.set(itemsKey, items);
}
}
/**
* The Yjs update that turns an empty document into `d`. Deterministic: the
* same `d` always gives byte-identical output (fixed client id, row/column
* ids `r0…`, `c0…`), so two people opening a not-yet-converted document at
* the same moment produce the same update, which Yjs merges into one.
*/
export function initialUpdate(d: DocumentState): Uint8Array {
const y = new Y.Doc();
y.clientID = 1;
y.transact(() => build(y.getMap('numolus'), d, deterministicIds));
return Y.encodeStateAsUpdate(y);
}
export const isInitialized = (y: Y.Doc) => y.getMap('numolus').has('sheets');
// ---- reading ----------------------------------------------------------------
function orderedItems<T>(order: YArr | undefined, items: Y.Map<unknown> | undefined): T[] {
if (!order || !items) return [];
const seen = new Set<string>();
const out: T[] = [];
for (const id of order.toArray()) {
if (seen.has(id) || !items.has(id)) continue; // a concurrent double insert
seen.add(id);
out.push(items.get(id) as T);
}
return out;
}
export interface Axes {
rows: string[];
cols: string[];
}
/** Row/column ids of a table (duplicates from concurrent moves dropped). */
export function axesOf(t: YMap): Axes {
const dedupe = (a: string[]) => [...new Set(a)];
return {
rows: dedupe((t.get('rowOrder') as YArr).toArray()),
cols: dedupe((t.get('colOrder') as YArr).toArray()),
};
}
function readTable(m: YMap): TableObject {
const t: Record<string, unknown> = {};
for (const [k, v] of m.entries()) {
if (!(v instanceof Y.AbstractType)) t[k] = v;
}
const { rows, cols } = axesOf(m);
const ri = new Map(rows.map((id, i) => [id, i]));
const ci = new Map(cols.map((id, i) => [id, i]));
const pos = (key: string) => {
const [c, r] = key.split('|');
const cc = ci.get(c);
const rr = ri.get(r);
return cc === undefined || rr === undefined ? null : `${cc},${rr}`;
};
const cells: Record<string, string> = {};
for (const [k, v] of (m.get('cells') as Y.Map<string>).entries()) {
const p = pos(k);
if (p) cells[p] = v;
}
const formats: Record<string, unknown> = {};
for (const [k, v] of (m.get('formats') as Y.Map<unknown>).entries()) {
const p = pos(k);
if (p) formats[p] = v;
}
const colWidths: Record<number, number> = {};
for (const [k, v] of (m.get('colWidths') as Y.Map<number>).entries()) {
const c = ci.get(k);
if (c !== undefined) colWidths[c] = v;
}
const rowHeights: Record<number, number> = {};
for (const [k, v] of (m.get('rowHeights') as Y.Map<number>).entries()) {
const r = ri.get(k);
if (r !== undefined) rowHeights[r] = v;
}
t.rows = Math.max(1, rows.length);
t.cols = Math.max(1, cols.length);
t.cells = cells;
t.colWidths = colWidths;
if (Object.keys(formats).length) t.formats = formats;
if (Object.keys(rowHeights).length) t.rowHeights = rowHeights;
return t as unknown as TableObject;
}
function readObject(m: YMap): SheetObject {
if (m.get('kind') === 'table') return readTable(m);
return Object.fromEntries(m.entries()) as unknown as SheetObject;
}
function readSheet(m: YMap): Sheet {
const s: Record<string, unknown> = {};
for (const [k, v] of m.entries()) {
if (!(v instanceof Y.AbstractType)) s[k] = v;
}
s.objects = orderedItems<YMap>(m.get('objectOrder') as YArr, m.get('objects') as Y.Map<unknown>).map(readObject);
return s as unknown as Sheet;
}
/** The whole document as the store holds it (view fields left at defaults). */
export function readDocument(y: Y.Doc): DocumentState {
const root = y.getMap('numolus');
const sheets = orderedItems<YMap>(root.get('sheetOrder') as YArr, root.get('sheets') as Y.Map<unknown>).map(readSheet);
const list = <T>(o: string, i: string) => orderedItems<T>(root.get(o) as YArr, root.get(i) as Y.Map<unknown>);
return {
version: 2,
sheets,
activeSheetId: sheets[0]?.id ?? '',
activeObjectId: null,
selection: null,
locale: (root.get('locale') as DocumentState['locale']) ?? 'de-DE',
csvDelimiter: (root.get('csvDelimiter') as ',' | ';') ?? ',',
customFunctions: list<CustomFunction>('fnOrder', 'fns'),
namedRanges: list<NamedRange>('nameOrder', 'names'),
macros: list<Macro>('macroOrder', 'macros'),
};
}
/** Current row/column ids of every table (for mapping positions to ids). */
export function allAxes(y: Y.Doc): Map<string, Axes> {
const out = new Map<string, Axes>();
const sheets = y.getMap('numolus').get('sheets') as Y.Map<unknown> | undefined;
if (!sheets) return out;
for (const s of sheets.values() as IterableIterator<YMap>) {
for (const [id, o] of (s.get('objects') as Y.Map<unknown>).entries() as IterableIterator<[string, YMap]>) {
if (o.get('kind') === 'table') out.set(id, axesOf(o));
}
}
return out;
}
// ---- writing: store state → Yjs, as small changes ------------------------------
interface StructureEvent {
tableId: string;
axis: 'row' | 'col';
at: number;
delta: 1 | -1;
}
function syncPlain(m: YMap, obj: object, skip: Set<string>, keepKeys: Set<string>) {
const entries = obj as Record<string, unknown>;
for (const [k, v] of Object.entries(entries)) {
if (skip.has(k)) continue;
if (v === undefined) {
if (m.has(k)) m.delete(k);
} else if (!same(m.get(k), v)) {
m.set(k, v);
}
}
for (const k of [...m.keys()]) {
if (!keepKeys.has(k) && !skip.has(k) && !(k in entries)) m.delete(k);
}
}
/** Make `order` equal `ids`, touching only what differs. */
function syncOrder(order: YArr, ids: string[]) {
const want = new Set(ids);
const cur = order.toArray();
for (let i = cur.length - 1; i >= 0; i--) {
if (!want.has(cur[i]) || cur.indexOf(cur[i]) !== i) order.delete(i, 1);
}
for (let i = 0; i < ids.length; i++) {
const now = order.toArray();
if (now[i] === ids[i]) continue;
const j = now.indexOf(ids[i]);
if (j >= 0) order.delete(j, 1); // moved
order.insert(i, [ids[i]]);
}
}
function syncKeyedMap<T>(ymap: Y.Map<T>, want: Map<string, T>) {
for (const [k, v] of want) if (!same(ymap.get(k), v)) ymap.set(k, v);
for (const k of [...ymap.keys()]) if (!want.has(k)) ymap.delete(k);
}
function syncTable(m: YMap, t: TableObject, events: StructureEvent[]) {
syncPlain(m, t, TABLE_STRUCT, new Set(['rowOrder', 'colOrder', 'cells', 'formats', 'colWidths', 'rowHeights']));
const rowOrder = m.get('rowOrder') as YArr;
const colOrder = m.get('colOrder') as YArr;
// mid-table inserts/deletes first, in the order they happened ...
for (const e of events) {
const order = e.axis === 'row' ? rowOrder : colOrder;
if (e.delta > 0) order.insert(Math.min(e.at, order.length), [randomId(e.axis === 'row' ? 'r' : 'c')]);
else if (e.at < order.length) order.delete(e.at, 1);
}
// ... then edge growth/shrink (addRows, setTableRows, ...)
const fit = (order: YArr, n: number, p: 'r' | 'c') => {
if (order.length < n) order.push(Array.from({ length: n - order.length }, () => randomId(p)));
else if (order.length > n) order.delete(n, order.length - n);
};
fit(rowOrder, t.rows, 'r');
fit(colOrder, t.cols, 'c');
const { rows, cols } = axesOf(m);
const key = (k: string) => {
const [c, r] = k.split(',').map(Number);
return cols[c] !== undefined && rows[r] !== undefined ? `${cols[c]}|${rows[r]}` : null;
};
const cells = new Map<string, string>();
for (const [k, v] of Object.entries(t.cells)) {
const id = key(k);
if (id) cells.set(id, v);
}
syncKeyedMap(m.get('cells') as Y.Map<string>, cells);
const formats = new Map<string, unknown>();
for (const [k, v] of Object.entries(t.formats ?? {})) {
const id = key(k);
if (id) formats.set(id, v);
}
syncKeyedMap(m.get('formats') as Y.Map<unknown>, formats);
const widths = new Map<string, number>();
for (const [c, w] of Object.entries(t.colWidths ?? {})) if (cols[+c]) widths.set(cols[+c], w);
syncKeyedMap(m.get('colWidths') as Y.Map<number>, widths);
const heights = new Map<string, number>();
for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rows[+r]) heights.set(rows[+r], h);
syncKeyedMap(m.get('rowHeights') as Y.Map<number>, heights);
}
function syncObjects(sheet: YMap, s: Sheet, events: StructureEvent[]) {
const objects = sheet.get('objects') as Y.Map<unknown>;
const want = new Set(s.objects.map((o) => o.id));
for (const id of [...objects.keys()]) if (!want.has(id)) objects.delete(id);
for (const o of s.objects) {
const m = objects.get(o.id) as YMap | undefined;
if (!m || m.get('kind') !== o.kind) {
objects.set(o.id, buildObject(o, randomIds));
} else if (o.kind === 'table') {
syncTable(m, o, events.filter((e) => e.tableId === o.id));
} else {
syncPlain(m, o, new Set(), new Set());
}
}
syncOrder(sheet.get('objectOrder') as YArr, s.objects.map((o) => o.id));
}
export class Binding {
private events: StructureEvent[] = [];
constructor(private y: Y.Doc) {}
/** A row/column was inserted/deleted in the store (before the next sync). */
structure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1) {
this.events.push({ tableId, axis, at, delta });
}
get hasPending() {
return this.events.length > 0;
}
/** Bring Yjs in line with the store, as one local transaction. */
sync(d: DocumentState) {
const events = this.events;
this.events = [];
const root = this.y.getMap('numolus');
this.y.transact(() => {
if (!same(root.get('locale'), d.locale)) root.set('locale', d.locale);
if (!same(root.get('csvDelimiter'), d.csvDelimiter)) root.set('csvDelimiter', d.csvDelimiter);
const sheets = root.get('sheets') as Y.Map<unknown>;
const want = new Set(d.sheets.map((s) => s.id));
for (const id of [...sheets.keys()]) if (!want.has(id)) sheets.delete(id);
for (const s of d.sheets) {
const m = sheets.get(s.id) as YMap | undefined;
if (!m) {
sheets.set(s.id, buildSheet(s, randomIds));
continue;
}
syncPlain(m, s, SHEET_STRUCT, new Set(['objectOrder', 'objects']));
syncObjects(m, s, events);
}
syncOrder(root.get('sheetOrder') as YArr, d.sheets.map((s) => s.id));
for (const [orderKey, itemsKey, field] of LISTS) {
const list = d[field] as { id: string }[];
syncKeyedMap(root.get(itemsKey) as Y.Map<unknown>, new Map(list.map((x) => [x.id, x])));
syncOrder(root.get(orderKey) as YArr, list.map((x) => x.id));
}
}, LOCAL);
}
}
@@ -4,11 +4,16 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme'; import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue'; import BaseModal from './BaseModal.vue';
import HelpModal from './HelpModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue'; import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue'; import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue'; import MacrosModal from './MacrosModal.vue';
import DocumentsModal from './DocumentsModal.vue'; import DocumentsModal from './DocumentsModal.vue';
import LoginModal from './LoginModal.vue';
import ShareModal from './ShareModal.vue';
import UploadModal from './UploadModal.vue';
import { useLibrary } from '../store/library'; import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { parseDocument } from '../io/json'; import { parseDocument } from '../io/json';
import TheLogo from './TheLogo.vue'; import TheLogo from './TheLogo.vue';
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue'; import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
@@ -32,6 +37,7 @@ import type { Delimiter } from '../io/csv';
const store = useDocument(); const store = useDocument();
const library = useLibrary(); const library = useLibrary();
const session = useSession();
const { mode: themeMode, cycle: cycleTheme } = useTheme(); const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts(); const toasts = useToasts();
@@ -110,15 +116,59 @@ const functionsOpen = ref(false);
const namedRangesOpen = ref(false); const namedRangesOpen = ref(false);
const macrosOpen = ref(false); const macrosOpen = ref(false);
const documentsOpen = ref(false); const documentsOpen = ref(false);
const shareOpen = ref(false);
const uploadOpen = ref(false);
const loginOpen = ref(false);
const loginReason = ref<string | undefined>();
const accountMenuOpen = ref(false);
const helpOpen = ref(false);
// F1 opens the help from anywhere (also out of a cell editor -- the browser's
// own F1 help is what preventDefault keeps away)
function onHelpKey(e: KeyboardEvent) {
if (e.key !== 'F1') return;
e.preventDefault();
helpOpen.value = true;
}
onMounted(() => window.addEventListener('keydown', onHelpKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onHelpKey));
function openLogin(reason?: string) {
loginReason.value = reason;
documentsOpen.value = false;
shareOpen.value = false;
loginOpen.value = true;
}
// after signing in: offer once (per account, per browser) to move this
// browser's documents into the account
onMounted(() => {
const user = session.state.user;
if (!user || !library.state.docs.length) return;
const key = `numolus:upload-asked:${user.id}`;
try {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, '1');
} catch {
return;
}
uploadOpen.value = true;
});
const statusNote = computed(() =>
library.state.current?.source === 'server' && !library.online.value
? 'Offline – Änderungen werden übertragen, sobald die Verbindung zurück ist'
: '',
);
// ---- document title (inline rename) ------------------------------------ // ---- document title (inline rename) ------------------------------------
const titleDraft = ref(library.currentTitle.value); const titleDraft = ref(library.currentTitle.value);
watch(library.currentTitle, (t) => (titleDraft.value = t)); watch(library.currentTitle, (t) => (titleDraft.value = t));
function commitTitle() { function commitTitle() {
const id = library.state.currentId; const ref = library.state.current;
const t = titleDraft.value.trim(); const t = titleDraft.value.trim();
if (!id || !t) titleDraft.value = library.currentTitle.value; if (!ref || !t) titleDraft.value = library.currentTitle.value;
else if (t !== library.currentTitle.value) void library.rename(id, t); else if (t !== library.currentTitle.value) void library.rename(ref, t);
} }
const busy = ref(false); const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -323,6 +373,7 @@ async function onSample() {
title="Dokumentname (klicken zum Umbenennen)" title="Dokumentname (klicken zum Umbenennen)"
aria-label="Dokumentname" aria-label="Dokumentname"
:size="Math.max(8, Math.min(28, titleDraft.length + 1))" :size="Math.max(8, Math.min(28, titleDraft.length + 1))"
:readonly="library.currentRole.value === 'viewer'"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" @keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()" @keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
@blur="commitTitle()" @blur="commitTitle()"
@@ -400,8 +451,22 @@ async function onSample() {
</button> </button>
</div> </div>
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">Nur Lesen</span>
<span v-if="statusNote" class="status-note" :title="statusNote">Offline</span>
<span v-if="library.peers.value.length" class="peers" :title="library.peers.value.map((p) => p.name).join(', ')">
<span
v-for="p in library.peers.value.slice(0, 5)"
:key="p.clientId"
class="avatar peer"
:style="{ background: p.color }"
>{{ p.name.slice(0, 1).toUpperCase() }}</span>
<span v-if="library.peers.value.length > 5" class="more">+{{ library.peers.value.length - 5 }}</span>
</span>
<div class="spacer" /> <div class="spacer" />
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
<div ref="fileMenuWrap" class="menu-wrap"> <div ref="fileMenuWrap" class="menu-wrap">
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button> <button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false"> <ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
@@ -417,10 +482,13 @@ async function onSample() {
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li> <li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li> <li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" /> <li class="sep" />
<li><button @click="helpOpen = true">Hilfe … (F1)</button></li>
<li><button @click="aboutOpen = true">Über Numolus …</button></li> <li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul> </ul>
</div> </div>
<button class="ibtn help" title="Hilfe (F1)" aria-label="Hilfe" @click="helpOpen = true">?</button>
<button <button
class="ibtn" class="ibtn"
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`" :title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
@@ -430,6 +498,26 @@ async function onSample() {
<ToolbarIcon :name="themeIcon" /> <ToolbarIcon :name="themeIcon" />
</button> </button>
<div class="menu-wrap">
<button
v-if="session.state.user"
class="btn account"
:title="session.state.user.email"
@click="accountMenuOpen = !accountMenuOpen"
@blur="accountMenuOpen = false"
>
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
{{ session.state.user.name }} ▾
</button>
<button v-else class="btn" @click="openLogin()">Anmelden</button>
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
<li class="menu-note">{{ session.state.user?.email }}</li>
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
<li class="sep" />
<li><button @click="session.logout()">Abmelden</button></li>
</ul>
</div>
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" /> <input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
<span v-if="busy" class="busy">Verarbeite …</span> <span v-if="busy" class="busy">Verarbeite …</span>
</header> </header>
@@ -507,7 +595,15 @@ async function onSample() {
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" /> <CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" /> <NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" /> <MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" /> <DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
<ShareModal
:open="shareOpen"
@close="shareOpen = false"
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
/>
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
<HelpModal :open="helpOpen" @close="helpOpen = false" />
<!-- Über / Impressum --> <!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false"> <BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
@@ -661,6 +757,64 @@ async function onSample() {
.btn-link:hover { .btn-link:hover {
color: var(--accent); color: var(--accent);
} }
.ro-badge,
.status-note {
font-size: 12px;
white-space: nowrap;
}
.ro-badge {
padding: 3px 9px;
border-radius: 999px;
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
cursor: help;
}
.status-note {
padding: 3px 9px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-weight: 600;
cursor: help;
}
.peers {
display: inline-flex;
align-items: center;
}
.peer {
margin-left: -5px;
box-shadow: 0 0 0 2px var(--surface);
cursor: help;
}
.peer:first-child {
margin-left: 0;
}
.more {
margin-left: 4px;
font-size: 12px;
color: var(--text-dim);
}
.account {
display: inline-flex;
align-items: center;
gap: 6px;
}
.avatar {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: #fff;
font-size: 11px;
font-weight: 700;
}
.menu-note {
padding: 6px 12px;
font-size: 12px;
color: var(--text-dim);
}
.doc-title { .doc-title {
min-width: 0; min-width: 0;
max-width: 240px; max-width: 240px;
@@ -728,6 +882,11 @@ async function onSample() {
cursor: pointer; cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease; transition: background-color 0.12s ease, color 0.12s ease;
} }
/* the help button is a plain "?" rather than an icon */
.ibtn.help {
font-size: 15px;
font-weight: 700;
}
.tgroup.accent .ibtn { .tgroup.accent .ibtn {
color: var(--accent); color: var(--accent);
} }
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
/**
* "Dokumente" -- every document the user can open, in up to three groups:
* their account's documents (server), documents shared with them (server:
* invitations, links), and documents stored only in this browser
* (IndexedDB). Open, rename, duplicate, delete/leave, upload, create.
*/
import { computed, nextTick, ref, watch } from 'vue';
import { useLibrary, type DocRef } from '../store/library';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
const library = useLibrary();
const session = useSession();
interface Row {
ref: DocRef;
title: string;
updatedAt: number;
note: string;
canRename: boolean;
canDuplicate: boolean;
removeLabel: string;
canUpload: boolean;
}
const ROLE_NOTE = { owner: '', editor: 'bearbeiten', viewer: 'nur lesen' } as const;
const groups = computed(() => {
const own: Row[] = [];
const shared: Row[] = [];
for (const d of library.state.serverDocs) {
const row: Row = {
ref: { source: 'server', id: d.id },
title: d.title,
updatedAt: Date.parse(d.updated_at),
note: d.role === 'owner' ? '' : `von ${d.owner_name} · ${ROLE_NOTE[d.role]}`,
canRename: d.role !== 'viewer',
canDuplicate: true,
removeLabel: d.role === 'owner' ? 'Löschen' : 'Entfernen',
canUpload: false,
};
(d.role === 'owner' ? own : shared).push(row);
}
for (const l of library.state.linkDocs) {
shared.push({
ref: { source: 'server', id: l.id },
title: l.title,
updatedAt: 0,
note: `per Link · ${ROLE_NOTE[l.role]}`,
canRename: false,
canDuplicate: false,
removeLabel: 'Entfernen',
canUpload: false,
});
}
const local: Row[] = library.state.docs.map((d) => ({
ref: { source: 'local', id: d.id },
title: d.title,
updatedAt: d.updatedAt,
note: '',
canRename: true,
canDuplicate: true,
removeLabel: 'Löschen',
canUpload: session.signedIn.value,
}));
return [
{ key: 'own', title: 'Mein Konto', rows: own, show: session.signedIn.value },
{ key: 'shared', title: 'Mit mir geteilt', rows: shared, show: shared.length > 0 },
{
key: 'local',
title: session.signedIn.value ? 'Nur in diesem Browser' : 'In diesem Browser',
rows: local,
show: library.state.available && (local.length > 0 || !session.signedIn.value),
},
].filter((g) => g.show);
});
const isCurrent = (r: DocRef) =>
library.state.current?.source === r.source && library.state.current.id === r.id;
const keyOf = (r: DocRef) => `${r.source}:${r.id}`;
const renaming = ref<string | null>(null);
const renameValue = ref('');
const renameInput = ref<HTMLInputElement[] | null>(null);
watch(
() => props.open,
(o) => {
if (o) {
renaming.value = null;
void library.refreshServerList();
}
},
);
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
function when(ms: number): string {
if (!ms) return '';
const diff = Date.now() - ms;
if (diff < 60_000) return 'gerade eben';
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
return fmt.format(ms);
}
function subline(r: Row): string {
return [isCurrent(r.ref) ? 'geöffnet' : '', r.note, when(r.updatedAt)].filter(Boolean).join(' · ');
}
async function openDoc(r: Row) {
if (renaming.value) return;
if (await library.open(r.ref)) emit('close');
}
async function startRename(r: Row) {
renaming.value = keyOf(r.ref);
renameValue.value = r.title;
await nextTick();
renameInput.value?.[0]?.select();
}
async function commitRename(r: Row) {
if (renaming.value !== keyOf(r.ref)) return;
renaming.value = null;
if (renameValue.value.trim() && renameValue.value !== r.title) await library.rename(r.ref, renameValue.value);
}
async function remove(r: Row) {
const q =
r.removeLabel === 'Löschen'
? `„${r.title}" endgültig löschen?`
: `„${r.title}" aus deiner Liste entfernen? Du verlierst den Zugriff, bis es wieder mit dir geteilt wird.`;
if (confirm(q)) await library.remove(r.ref);
}
async function upload(r: Row) {
await library.upload([r.ref.id]);
}
async function createNew() {
await library.createBlank();
emit('close');
}
</script>
<template>
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
<p v-if="!library.state.available && !session.signedIn.value" class="hint">
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar, z. B. im
privaten Modus). Mit einem Konto werden Dokumente auf dem Server gespeichert.
</p>
<section v-for="g in groups" :key="g.key" class="group">
<h3>{{ g.title }}</h3>
<p v-if="!g.rows.length" class="empty">Noch keine Dokumente.</p>
<ul v-else class="doc-list">
<li v-for="r in g.rows" :key="keyOf(r.ref)" class="doc-row" :class="{ current: isCurrent(r.ref) }">
<input
v-if="renaming === keyOf(r.ref)"
ref="renameInput"
v-model="renameValue"
class="rename"
type="text"
@keydown.enter.prevent="commitRename(r)"
@keydown.esc.stop.prevent="renaming = null"
@blur="commitRename(r)"
/>
<button v-else class="doc-open" :title="`${r.title} öffnen`" @click="openDoc(r)">
<span class="doc-name">{{ r.title }}</span>
<span class="doc-when">{{ subline(r) }}</span>
</button>
<span class="doc-actions">
<button v-if="r.canUpload" class="mini" title="In dein Konto hochladen (danach teilbar)" @click="upload(r)">
Hochladen
</button>
<button v-if="r.canRename" class="mini" @click="startRename(r)">Umbenennen</button>
<button v-if="r.canDuplicate" class="mini" @click="library.duplicate(r.ref)">Duplizieren</button>
<button class="mini danger" @click="remove(r)">{{ r.removeLabel }}</button>
</span>
</li>
</ul>
</section>
<p v-if="!session.signedIn.value" class="hint">
Dokumente werden nur in diesem Browser gespeichert.
<button class="linkish" @click="emit('login')">Anmelden</button>, um sie auf allen Geräten zu haben und zu
teilen.
</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button class="primary" :disabled="!library.state.available && !session.signedIn.value" @click="createNew">
+ Neues Dokument
</button>
</template>
</BaseModal>
</template>
<style scoped>
.group + .group {
margin-top: 16px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.empty {
margin: 0;
font-size: 12.5px;
color: var(--text-dim);
}
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 14px 0 0;
}
.linkish {
border: 0;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.doc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(40vh, 360px);
overflow-y: auto;
}
.doc-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 4px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid transparent;
}
.doc-row.current {
border-color: var(--accent);
}
.doc-open {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
border: 0;
background: transparent;
padding: 4px 6px;
border-radius: 5px;
font: inherit;
color: var(--text);
text-align: left;
cursor: pointer;
}
.doc-open:hover .doc-name {
color: var(--accent);
}
.doc-name {
font-size: 13px;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-when {
font-size: 11.5px;
color: var(--text-dim);
}
.rename {
flex: 1;
min-width: 0;
border: 1px solid var(--accent);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface);
color: var(--text);
}
.doc-actions {
display: flex;
gap: 6px;
flex: none;
}
@media (max-width: 560px) {
.doc-row {
flex-wrap: wrap;
}
.doc-actions {
width: 100%;
justify-content: flex-end;
}
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document'; import { useDocument } from '../store/document';
import { cycleReferenceLocks } from '../engine'; import { cycleReferenceLocks, completeFormula } from '../engine';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete'; import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode'; import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
@@ -66,7 +66,7 @@ function onNameInput() {
* in this app. */ * in this app. */
function jumpTo(text: string) { function jumpTo(text: string) {
if (store.jumpToNameInput(text)) { if (store.jumpToNameInput(text)) {
(document.activeElement as HTMLElement)?.blur(); backToGrid();
} else { } else {
toast.error(`„${text}" ist kein gültiger Bezug oder Name.`); toast.error(`„${text}" ist kein gültiger Bezug oder Name.`);
nextTick(() => nameEl.value?.select()); nextTick(() => nameEl.value?.select());
@@ -102,8 +102,21 @@ function onNameKeydown(e: KeyboardEvent) {
} }
} }
/** Leave the bar and hand the keyboard back to the grid of the selected
* table, so the arrow keys move the active cell again (just blurring
* would leave nothing focused). */
function backToGrid() {
(document.activeElement as HTMLElement)?.blur();
const tableId = store.doc.selection?.tableId;
if (tableId) window.dispatchEvent(new CustomEvent('numolus:focus-grid', { detail: tableId }));
}
function commit() { function commit() {
const sel = store.doc.selection; const sel = store.doc.selection;
// a forgotten closing ")" is completed rather than stored as an error; in
// the draft too, since the sync watch above skips changes made while the
// field still has focus (Enter commits before it blurs)
draft.value = completeFormula(draft.value);
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 /** Lets the store insert a reference into this field when the user clicks a
@@ -134,7 +147,7 @@ function onFocus() {
} }
function onEnter() { function onEnter() {
commit(); commit();
(document.activeElement as HTMLElement)?.blur(); backToGrid();
} }
function onBlur() { function onBlur() {
focused.value = false; focused.value = false;
@@ -144,7 +157,7 @@ function onBlur() {
} }
function onEscape() { function onEscape() {
draft.value = store.activeCellInput.value; draft.value = store.activeCellInput.value;
(document.activeElement as HTMLElement)?.blur(); backToGrid();
} }
/** Actual typing (not a point-mode click inserting a reference programmatically) /** Actual typing (not a point-mode click inserting a reference programmatically)
* ends the "next click replaces the last reference" window. */ * ends the "next click replaces the last reference" window. */
@@ -272,7 +285,8 @@ function onKeydown(e: KeyboardEvent) {
class="formula-input" class="formula-input"
spellcheck="false" spellcheck="false"
rows="1" rows="1"
placeholder="Wert oder =Formel" :placeholder="store.ui.readOnly ? 'Nur Lesen' : 'Wert oder =Formel'"
:readonly="!!store.ui.readOnly"
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen" title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
+410
View File
@@ -0,0 +1,410 @@
<script setup lang="ts">
/**
* In-app help: topics and keyboard shortcuts from data/help.ts, plus the
* formula functions and operators from data/codeReference.ts (the same
* entries the macro/function editors' reference shows, so the list can't
* drift from the engine). One search box runs over all of it. Opened from
* the toolbar's "?" button, Datei › Hilfe or F1 (see AppToolbar.vue).
*/
import { computed, nextTick, ref, watch } from 'vue';
import BaseModal from './BaseModal.vue';
import { HELP_TOPICS, SHORTCUT_GROUPS, shortcutKeys, type KeyPlatform, type Shortcut } from '../data/help';
import { matchesQuery, referenceFor } from '../data/codeReference';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
type Page = string; // a topic id, 'shortcuts' or 'functions'
const page = ref<Page>(HELP_TOPICS[0].id);
const query = ref('');
const searchEl = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
(o) => {
if (!o) return;
query.value = '';
nextTick(() => searchEl.value?.focus());
},
);
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
/** This device's platform: its column in the shortcut tables is highlighted,
* and the topics' running text names its keys. */
const platform: KeyPlatform = isMac ? 'mac' : 'pc';
const keys = (k: string) => shortcutKeys(k, platform);
/** Both columns are always shown -- the help is also read to explain the
* app to someone on the other system. */
const COLUMNS: { platform: KeyPlatform; label: string }[] = [
{ platform: 'pc', label: 'Windows / Linux' },
{ platform: 'mac', label: 'Mac' },
];
/** `**fett**` and `` `Code` `` -> HTML; everything else escaped first, so
* nothing in the data can inject markup. */
function rich(text: string): string {
const esc = keys(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return esc.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
const FORMULA_REF = referenceFor('expression').filter(
(e) => e.category === 'Formelfunktionen' || e.category === 'Operatoren',
);
const topic = computed(() => HELP_TOPICS.find((t) => t.id === page.value) ?? null);
const plain = (s: string) => s.replace(/[`*]/g, '').toLowerCase();
const hit = (s: string, words: string[]) => words.every((w) => plain(keys(s)).includes(w));
/** Search across topics, shortcuts and functions -- every word must occur. */
const results = computed(() => {
const words = query.value.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return null;
const items = HELP_TOPICS.flatMap((t) =>
t.sections.flatMap((s) =>
s.items
.filter((i) => hit(`${t.title} ${s.heading ?? ''} ${i}`, words))
.map((text) => ({ where: s.heading ? `${t.title} › ${s.heading}` : t.title, text })),
),
);
const shortcuts = SHORTCUT_GROUPS.flatMap((g) =>
g.shortcuts.filter((s: Shortcut) =>
hit(`${g.title} ${shortcutKeys(s, 'pc')} ${shortcutKeys(s, 'mac')} ${s.description}`, words),
),
);
const functions = FORMULA_REF.filter((e) => matchesQuery(e, query.value));
return { items, shortcuts, functions, empty: !items.length && !shortcuts.length && !functions.length };
});
/** Escape in the search box first clears a search, only then closes (an
* empty box would otherwise leave it to BaseModal's own Escape handling). */
function onSearchEscape() {
if (query.value) query.value = '';
else emit('close');
}
function go(p: Page) {
page.value = p;
query.value = '';
}
</script>
<template>
<BaseModal :open="open" title="Hilfe" wide @close="emit('close')">
<div class="help-page">
<input
ref="searchEl"
v-model="query"
class="search"
type="search"
placeholder="Hilfe durchsuchen, z. B. „Bezug“, „fett“, „SUM“ …"
aria-label="Hilfe durchsuchen"
@keydown.esc.prevent.stop="onSearchEscape"
/>
<div class="cols">
<nav class="nav">
<button
v-for="t in HELP_TOPICS"
:key="t.id"
:class="{ on: !results && page === t.id }"
@click="go(t.id)"
>
{{ t.title }}
</button>
<span class="sep" />
<button :class="{ on: !results && page === 'shortcuts' }" @click="go('shortcuts')">Tastenkürzel</button>
<button :class="{ on: !results && page === 'functions' }" @click="go('functions')">Funktionen</button>
</nav>
<div class="content">
<!-- search results -->
<template v-if="results">
<p v-if="results.empty" class="dim">Nichts gefunden.</p>
<section v-if="results.items.length">
<h3>Themen</h3>
<ul class="items">
<li v-for="(r, i) in results.items" :key="i">
<span class="where">{{ r.where }}</span>
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<span v-html="rich(r.text)" />
</li>
</ul>
</section>
<section v-if="results.shortcuts.length">
<h3>Tastenkürzel</h3>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in results.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
<section v-if="results.functions.length">
<h3>Funktionen</h3>
<dl class="fns">
<template v-for="f in results.functions" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>{{ f.description }}</dd>
</template>
</dl>
</section>
</template>
<!-- a topic -->
<template v-else-if="topic">
<h3 class="title">{{ topic.title }}</h3>
<section v-for="(s, i) in topic.sections" :key="i">
<h4 v-if="s.heading">{{ s.heading }}</h4>
<ul class="items">
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<li v-for="(item, j) in s.items" :key="j" v-html="rich(item)" />
</ul>
</section>
</template>
<template v-else-if="page === 'shortcuts'">
<h3 class="title">Tastenkürzel</h3>
<p class="dim">
Windows und Linux verwenden dieselben Tasten. Auf dem Mac steht ⌘ für Strg und ⌥ für Alt. Viele
Mac-Tastaturen haben keine Tasten Pos1, Ende, Bild auf/ab und Entf, dort hilft fn. Die
Funktionstasten F1 bis F4 brauchen je nach Einstellung ebenfalls fn.
</p>
<section v-for="g in SHORTCUT_GROUPS" :key="g.title">
<h4>{{ g.title }}</h4>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in g.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
</template>
<template v-else-if="page === 'functions'">
<h3 class="title">Funktionen</h3>
<p class="dim">
Eingebaute Funktionen und Rechenzeichen für Formeln. Argumente werden mit <code>;</code> getrennt,
ein <code>?</code> kennzeichnet ein optionales Argument.
</p>
<dl class="fns">
<template v-for="f in FORMULA_REF" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>
{{ f.description }}
<span v-if="f.example" class="example">Beispiel: <code>{{ f.example }}</code></span>
</dd>
</template>
</dl>
</template>
</div>
</div>
</div>
<template #footer>
<button class="primary" @click="emit('close')">Schließen</button>
</template>
</BaseModal>
</template>
<style scoped>
.help-page {
display: flex;
flex-direction: column;
gap: 12px;
/* fixed height: switching pages or searching must not resize (and so
re-centre) the dialog; the content column scrolls instead */
height: min(600px, calc(100dvh - 190px));
}
.search {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
}
.cols {
flex: 1;
min-height: 0;
display: flex;
gap: 18px;
}
.nav {
flex: none;
width: 170px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav button {
text-align: left;
border: 0;
background: transparent;
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius-sm);
font: inherit;
cursor: pointer;
}
.nav button:hover {
background: var(--surface-2);
}
.nav button.on {
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
}
.nav .sep {
height: 1px;
background: var(--border);
margin: 6px 0;
}
.content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding-right: 4px;
line-height: 1.5;
}
.title {
margin: 0 0 8px;
font-size: 15px;
}
h3 {
font-size: 13px;
margin: 0 0 6px;
}
h4 {
font-size: 12.5px;
margin: 14px 0 4px;
color: var(--text-dim);
}
section + section h3 {
margin-top: 16px;
}
.items {
margin: 0;
padding-left: 18px;
}
.items li + li {
margin-top: 4px;
}
.where {
display: block;
font-size: 11px;
color: var(--text-dim);
}
.keys {
border-collapse: collapse;
width: 100%;
}
.keys th,
.keys td {
padding: 4px 8px 4px 0;
vertical-align: top;
text-align: left;
border-bottom: 1px solid var(--border);
}
.keys th {
font-size: 11.5px;
font-weight: 600;
color: var(--text-dim);
}
.keys th.here {
color: var(--accent);
}
.keys td:first-child {
width: 36%;
}
.keys td.here kbd {
border-color: var(--accent);
}
kbd {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
.fns {
margin: 0;
}
.fns dt {
margin-top: 10px;
}
.fns dd {
margin: 2px 0 0 0;
color: var(--text-dim);
}
.example {
display: block;
}
code {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border-radius: 3px;
padding: 0 3px;
}
.dim {
color: var(--text-dim);
}
/* phones: navigation above the content */
@media (max-width: 640px) {
.cols {
flex-direction: column;
}
.nav {
flex: none;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.nav .sep {
display: none;
}
/* no room for three columns: each shortcut becomes a small block, the
action on top, the two key columns below it with their label */
.keys thead {
display: none;
}
.keys tr {
display: block;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.keys td {
display: block;
width: auto !important;
padding: 1px 0;
border: 0;
}
.keys td[data-label]::before {
content: attr(data-label) ': ';
font-size: 11px;
color: var(--text-dim);
}
kbd {
overflow-wrap: anywhere;
}
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
* code. Clicking the link signs in (in whatever browser opens it); typing
* the code here signs in this browser. Both create the account on first use.
*/
import { nextTick, ref, watch } from 'vue';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean; reason?: string }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const session = useSession();
const step = ref<'email' | 'code'>('email');
const email = ref('');
const code = ref('');
const busy = ref(false);
const error = ref<string | null>(null);
const emailInput = ref<HTMLInputElement | null>(null);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
async (o) => {
if (!o) return;
step.value = 'email';
code.value = '';
error.value = null;
await nextTick();
emailInput.value?.focus();
},
);
async function send() {
if (!email.value.trim()) return;
busy.value = true;
error.value = null;
try {
await session.requestLink(email.value.trim());
step.value = 'code';
await nextTick();
codeInput.value?.focus();
} catch (err) {
error.value = (err as Error).message.includes('422')
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
: (err as Error).message;
} finally {
busy.value = false;
}
}
async function verify() {
busy.value = true;
error.value = null;
try {
await session.verifyCode(email.value.trim(), code.value);
} catch {
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
<template v-if="step === 'email'">
<p v-if="reason" class="reason">{{ reason }}</p>
<p class="lead">
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
</p>
<form @submit.prevent="send">
<label class="row">
E-Mail
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
</label>
</form>
</template>
<template v-else>
<p class="lead">
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
den 6-stelligen Code ein.
</p>
<form @submit.prevent="verify">
<label class="row">
Code
<input
ref="codeInput"
v-model="code"
class="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="7"
placeholder="123456"
/>
</label>
</form>
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
</template>
<p v-if="error" class="error">{{ error }}</p>
<template #footer>
<button @click="emit('close')">Abbrechen</button>
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
Link senden
</button>
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
Anmelden
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead,
.reason {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.5;
}
.reason {
font-weight: 600;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font: inherit;
font-size: 14px;
background: var(--surface-2);
color: var(--text);
}
.code {
letter-spacing: 0.25em;
font-variant-numeric: tabular-nums;
}
.linkish {
margin-top: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 12.5px;
color: var(--accent);
cursor: pointer;
}
.error {
margin: 10px 0 0;
font-size: 12.5px;
color: #dc2626;
}
</style>
@@ -208,8 +208,9 @@ function resetDoc() {
</label> </label>
<label class="f row-inline"> <label class="f row-inline">
<button class="tgl" :class="{ on: selFmt.bold }" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button> <button class="tgl" :class="{ on: selFmt.bold }" title="Fett (Strg+Umschalt+B)" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button> <button class="tgl" :class="{ on: selFmt.italic }" title="Kursiv (Strg+Umschalt+I)" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
<button class="tgl" :class="{ on: selFmt.underline }" title="Unterstrichen (Strg+Umschalt+U)" @click="applyFmt({ underline: !selFmt.underline })"><u>U</u></button>
</label> </label>
<label class="f"> <label class="f">
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
/**
* "Teilen" for the open document. Sharing needs the document on the server,
* which needs an account: a local document is uploaded first (after
* signing in). The owner then manages links ("anyone with the link can
* view / edit") and people (invited by e-mail, with a role).
*/
import { computed, ref, watch } from 'vue';
import { api, type ShareInfo } from '../io/api';
import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
const library = useLibrary();
const session = useSession();
const toasts = useToasts();
const info = ref<ShareInfo | null>(null);
const busy = ref(false);
const inviteEmail = ref('');
const inviteRole = ref<'viewer' | 'editor'>('editor');
const current = computed(() => library.state.current);
const isServer = computed(() => current.value?.source === 'server');
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
const docPath = computed(() => `/documents/${current.value?.id}`);
async function load() {
info.value = null;
if (!isOwner.value) return;
try {
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
} catch (err) {
toasts.error((err as Error).message);
}
}
watch(
() => [props.open, current.value?.id, library.currentRole.value] as const,
([o]) => {
if (o) void load();
},
);
async function call(method: string, path: string, body?: unknown) {
busy.value = true;
try {
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
return true;
} catch (err) {
toasts.error((err as Error).message);
return false;
} finally {
busy.value = false;
}
}
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
async function copy(token: string) {
try {
await navigator.clipboard.writeText(urlFor(token));
toasts.success('Link kopiert.');
} catch {
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
}
}
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
if (await call('POST', '/links', { role })) {
const l = linkFor(role);
if (l) await copy(l.token);
}
}
async function revoke(id: number) {
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
await call('DELETE', `/links/${id}`);
}
}
async function invite() {
const email = inviteEmail.value.trim();
if (!email) return;
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
toasts.success(`Einladung an ${email} verschickt.`);
inviteEmail.value = '';
}
}
async function setRole(userId: number, role: string) {
await call('PATCH', `/members/${userId}`, { role });
}
async function removeMember(userId: number, name: string) {
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
}
async function uploadAndShare() {
const id = current.value?.id;
if (!id) return;
busy.value = true;
try {
await library.upload([id]);
} finally {
busy.value = false;
}
}
const LINK_ROWS = [
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
] as const;
</script>
<template>
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
<!-- local document -->
<template v-if="!isServer">
<p v-if="!session.signedIn.value" class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
Konto hochgeladen.
</p>
<p v-else class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
dem Browser entfernt).
</p>
</template>
<!-- server document, not the owner -->
<p v-else-if="!isOwner" class="lead">
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
</p>
<!-- owner -->
<template v-else-if="info">
<h3>Link</h3>
<ul class="rows">
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
<span class="label">{{ r.label }}</span>
<template v-if="linkFor(r.role)">
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
<span class="actions">
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
</span>
</template>
<span v-else class="actions">
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
</span>
</li>
</ul>
<p class="hint">Links funktionieren auch ohne Konto.</p>
<h3>Personen</h3>
<form class="invite" @submit.prevent="invite">
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
<select v-model="inviteRole">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
</form>
<ul class="rows">
<li class="person">
<span class="who">
<strong>{{ session.state.user?.name }}</strong>
<span class="dim">{{ session.state.user?.email }}</span>
</span>
<span class="dim">Besitzer</span>
</li>
<li v-for="m in info.members" :key="m.user_id" class="person">
<span class="who">
<strong>{{ m.name }}</strong>
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
</span>
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
</li>
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
<span class="who">
<strong>{{ i.email }}</strong>
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
</span>
</li>
</ul>
</template>
<p v-else class="lead">Lade …</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
Hochladen und teilen
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
h3:not(:first-child) {
margin-top: 18px;
}
.hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-dim);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.link-row,
.person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 13px;
}
.label {
flex: 1 1 200px;
}
.url {
flex: 1 1 220px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
font-size: 12px;
background: var(--surface);
color: var(--text-dim);
}
.actions {
display: flex;
gap: 6px;
margin-left: auto;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.invite {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.invite input {
flex: 1 1 200px;
}
.invite input,
select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface-2);
color: var(--text);
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
</style>
@@ -90,7 +90,8 @@ function onDrop(e: DragEvent) {
function selectObj(id: string) { function selectObj(id: string) {
store.selectObject(id); store.selectObject(id);
store.bringToFront(id); // z-order is document content: read-only viewers just select
if (!store.ui.readOnly) store.bringToFront(id);
} }
</script> </script>
@@ -10,9 +10,10 @@
* Also handles cell selection (click / shift / drag), keyboard navigation, * Also handles cell selection (click / shift / drag), keyboard navigation,
* in-cell editing and per-column resize. * in-cell editing and per-column resize.
*/ */
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine'; import { numberToCol, cycleReferenceLocks, completeFormula } from '../engine';
import { useDocument } from '../store/document'; import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types'; import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete'; import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode'; import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
@@ -22,6 +23,7 @@ import FormulaSuggest from './FormulaSuggest.vue';
const props = defineProps<{ tableId: string; selected: boolean }>(); const props = defineProps<{ tableId: string; selected: boolean }>();
const store = useDocument(); const store = useDocument();
const library = useLibrary();
const table = computed(() => store.tableById(props.tableId)); const table = computed(() => store.tableById(props.tableId));
const DEFAULT_W = 104; const DEFAULT_W = 104;
@@ -114,6 +116,7 @@ function formatStyle(c: number, r: number): Record<string, string> {
if (f.bg) s.background = f.bg; if (f.bg) s.background = f.bg;
if (f.bold) s.fontWeight = '600'; if (f.bold) s.fontWeight = '600';
if (f.italic) s.fontStyle = 'italic'; if (f.italic) s.fontStyle = 'italic';
if (f.underline) s.textDecoration = 'underline';
return s; return s;
} }
@@ -270,6 +273,28 @@ const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1)
// ---- clipboard source marker (one dashed rectangle, Excel-style "marching // ---- clipboard source marker (one dashed rectangle, Excel-style "marching
// ants" -- overlaid once over the whole range, not per cell). -------------- // ants" -- overlaid once over the whole range, not per cell). --------------
const clipRect = computed(() => store.clipboardRect.value); const clipRect = computed(() => store.clipboardRect.value);
/** Where the others (live collaboration) have their selection in this table. */
const peerRects = computed(() =>
library.peers.value
.filter((p) => p.rect && p.tableId === props.tableId)
.map((p) => {
const k = p.rect!;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
id: p.clientId,
name: p.name,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(Math.min(k.c1, (table.value?.cols ?? 1) - 1) + 1) - left + 'px',
height: rowTop(Math.min(k.r1, (table.value?.rows ?? 1) - 1) + 1) - top + 'px',
borderColor: p.color,
'--peer': p.color,
},
};
}),
);
const clipMarqueeStyle = computed(() => { const clipMarqueeStyle = computed(() => {
const k = clipRect.value; const k = clipRect.value;
if (!k || k.tableId !== props.tableId) return null; if (!k || k.tableId !== props.tableId) return null;
@@ -282,6 +307,21 @@ const clipMarqueeStyle = computed(() => {
height: rowTop(k.r1 + 1) - top + 'px', height: rowTop(k.r1 + 1) - top + 'px',
}; };
}); });
/** The cell/range just picked for the formula being edited (point mode, by
* mouse or arrow keys) -- the grid selection itself stays on the edited
* cell meanwhile, so without this the pick would be invisible. */
const pointRectStyle = computed(() => {
const k = store.pointRect.value;
if (!k || k.tableId !== props.tableId) return null;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(k.c1 + 1) - left + 'px',
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The copy/cut selection's *own* first cell, called out inside the marquee /** The copy/cut selection's *own* first cell, called out inside the marquee
* even once the live selection has moved on to pick a paste destination * even once the live selection has moved on to pick a paste destination
* (which is when `is-active` no longer sits there). */ * (which is when `is-active` no longer sits there). */
@@ -619,6 +659,81 @@ function onPointDragUp() {
window.removeEventListener('blur', onPointDragUp); window.removeEventListener('blur', onPointDragUp);
} }
// ---- references of the formula being edited (coloured, draggable) -------
/** The edited formula's references that point into this table, outlined in
* their colour. Dragging an outline's edge moves the reference, dragging a
* corner resizes it -- the formula text follows live (store.moveFormulaRef),
* same as Excel. */
const refHighlights = computed(() =>
store.formulaRefs.value
.filter((h) => h.tableId === props.tableId)
.map((h) => {
const left = colLeft(h.c0);
const top = rowTop(h.r0);
return {
...h,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(h.c1 + 1) - left + 'px',
height: rowTop(h.r1 + 1) - top + 'px',
'--ref': h.color,
},
};
}),
);
type RefCorner = 'nw' | 'ne' | 'sw' | 'se';
let refDrag: {
index: number;
orig: { c0: number; r0: number; c1: number; r1: number };
/** null = move by the edge, else the corner being dragged */
corner: RefCorner | null;
start: { c: number; r: number };
} | null = null;
function onRefHandleDown(e: MouseEvent, h: { index: number; c0: number; r0: number; c1: number; r1: number }, corner: RefCorner | null) {
if (e.button !== 0) return;
e.preventDefault(); // keep focus (and the caret) in the formula editor
e.stopPropagation(); // no cell/canvas-object mousedown underneath
const start = cellFromPoint(e.clientX, e.clientY);
if (!start) return;
refDrag = { index: h.index, orig: { c0: h.c0, r0: h.r0, c1: h.c1, r1: h.r1 }, corner, start };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onRefDragMove, true);
window.addEventListener('mouseup', onRefDragUp, true);
window.addEventListener('blur', onRefDragUp);
}
function onRefDragMove(e: MouseEvent) {
const d = refDrag;
const t = table.value;
if (!d || !t) return;
if (e.buttons === 0) {
onRefDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const o = d.orig;
if (!d.corner) {
// shift the whole rectangle, but never past the table's edges
const dc = Math.max(-o.c0, Math.min(t.cols - 1 - o.c1, hit.c - d.start.c));
const dr = Math.max(-o.r0, Math.min(t.rows - 1 - o.r1, hit.r - d.start.r));
store.moveFormulaRef(d.index, o.c0 + dc, o.r0 + dr, o.c1 + dc, o.r1 + dr);
return;
}
// the opposite corner stays put; the dragged one follows the pointer
const fixedC = d.corner === 'nw' || d.corner === 'sw' ? o.c1 : o.c0;
const fixedR = d.corner === 'nw' || d.corner === 'ne' ? o.r1 : o.r0;
store.moveFormulaRef(d.index, fixedC, fixedR, hit.c, hit.r);
}
function onRefDragUp() {
if (!refDrag) return;
refDrag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onRefDragMove, true);
window.removeEventListener('mouseup', onRefDragUp, true);
window.removeEventListener('blur', onRefDragUp);
}
// ---- 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
@@ -854,12 +969,22 @@ let committing = false;
const editStyle = computed(() => { const editStyle = computed(() => {
const s = editing.value; const s = editing.value;
if (!s) return {}; if (!s) return {};
return { // look like the cell being edited: its own text/fill colour (else the
// table's), bold/italic/underline -- a white-on-dark cell must not turn into dark
// text on a dark fill while editing
const f = store.cellFormat(props.tableId, s.col, s.row);
const style: Record<string, string> = {
left: colLeft(s.col) + 'px', left: colLeft(s.col) + 'px',
top: rowTop(s.row) + 'px', top: rowTop(s.row) + 'px',
minWidth: colWidth(s.col) + 'px', minWidth: colWidth(s.col) + 'px',
minHeight: rowH(s.row) + 'px', minHeight: rowH(s.row) + 'px',
}; };
if (f.color) style['--text'] = f.color;
if (f.bg) style['--surface'] = f.bg;
if (f.bold) style.fontWeight = '600';
if (f.italic) style.fontStyle = 'italic';
if (f.underline) style.textDecoration = 'underline';
return style;
}); });
const editMirrorStyle = computed(() => { const editMirrorStyle = computed(() => {
const s = editing.value; const s = editing.value;
@@ -911,6 +1036,10 @@ function formulaEditorHandle() {
} }
function beginEdit(c: number, r: number, initial?: string) { function beginEdit(c: number, r: number, initial?: string) {
if (store.ui.readOnly) {
store.setCellInput(props.tableId, c, r, ''); // no-op, only tells the user why
return;
}
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);
keyboardPoint.reset(); keyboardPoint.reset();
@@ -928,7 +1057,8 @@ function commitEdit() {
if (!editing.value) return; if (!editing.value) return;
committing = true; committing = true;
const { col, row } = editing.value; const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value); // a forgotten closing ")" is completed rather than stored as an error
store.setCellInput(props.tableId, col, row, completeFormula(editValue.value));
editing.value = null; editing.value = null;
store.registerFormulaEditor(null); store.registerFormulaEditor(null);
autocomplete.close(); autocomplete.close();
@@ -1089,6 +1219,17 @@ function onKeydown(e: KeyboardEvent) {
if (mod) { if (mod) {
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
// Ctrl/Cmd+Shift+B (or F, "fett") / I / U: toggle bold / italic /
// underline for the whole selection -- on when not every selected cell
// has it yet, else off, same as the toggle buttons in the side panel.
// With Shift, so plain Cmd+B/I/U stay free for the browser/OS.
const toggles: Record<string, 'bold' | 'italic' | 'underline'> = { b: 'bold', f: 'bold', i: 'italic', u: 'underline' };
const toggle = e.shiftKey ? toggles[k] : undefined;
if (toggle) {
e.preventDefault();
e.stopPropagation();
return store.applySelectionFormat({ [toggle]: !store.selectionFormat.value[toggle] });
}
if (k === 'a') { if (k === 'a') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -1304,9 +1445,31 @@ const totalWidth = computed(() => {
return w; return w;
}); });
// the formula bar / Name Box hand the keyboard back after Enter/Escape
function onFocusGridRequest(e: Event) {
if ((e as CustomEvent<string>).detail === props.tableId) root.value?.focus();
}
window.addEventListener('numolus:focus-grid', onFocusGridRequest);
// After a reload (or opening a document / switching sheets) the restored
// selection is visible but nothing had the keyboard, so arrow keys and typing
// went nowhere until the user clicked. Take it whenever the selection sits in
// this table and the keyboard is otherwise unclaimed -- never away from an
// input, the formula bar or another table someone is working in.
function claimIdleKeyboard() {
if (!mine.value || !root.value) return;
const active = document.activeElement;
if (active && active !== document.body) return;
root.value.focus({ preventScroll: true });
}
onMounted(claimIdleKeyboard);
watch(() => mine.value?.tableId, claimIdleKeyboard, { flush: 'post' });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('numolus:focus-grid', onFocusGridRequest);
onDragUp(); onDragUp();
onPointDragUp(); onPointDragUp();
onRefDragUp();
onFillDragUp(); onFillDragUp();
onResizeUp(); onResizeUp();
onRowResizeUp(); onRowResizeUp();
@@ -1407,8 +1570,30 @@ defineExpose({
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-for="p in peerRects" :key="p.id" class="peer-sel" :style="p.style">
<span class="peer-name">{{ p.name }}</span>
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" /> <div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" /> <div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-for="h in refHighlights" :key="'ref' + h.index" class="ref-hl" :style="h.style">
<template v-if="!h.whole">
<span
v-for="edge in ['top', 'right', 'bottom', 'left']"
:key="edge"
class="ref-edge"
:class="'ref-edge-' + edge"
@mousedown="onRefHandleDown($event, h, null)"
/>
<span
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
:key="corner"
class="ref-corner"
:class="'ref-corner-' + corner"
@mousedown="onRefHandleDown($event, h, corner)"
/>
</template>
</div>
<div v-if="pointRectStyle" class="point-rect" :style="pointRectStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" /> <div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div <div
v-if="fillHandleStyle" v-if="fillHandleStyle"
@@ -1574,6 +1759,26 @@ defineExpose({
.cell.is-active { .cell.is-active {
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
} }
/* someone else's selection (live collaboration) */
.peer-sel {
position: absolute;
pointer-events: none;
border: 2px solid;
z-index: 7;
box-sizing: border-box;
}
.peer-name {
position: absolute;
top: -17px;
left: -2px;
padding: 1px 5px;
border-radius: 3px 3px 3px 0;
background: var(--peer);
color: #fff;
font-size: 10.5px;
line-height: 15px;
white-space: nowrap;
}
/* one dashed rectangle around the whole copied range, not per cell */ /* one dashed rectangle around the whole copied range, not per cell */
.clip-marquee { .clip-marquee {
position: absolute; position: absolute;
@@ -1599,6 +1804,84 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8; z-index: 8;
} }
/* the reference being picked for a formula (point mode) */
.point-rect {
position: absolute;
pointer-events: none;
border: 2px dashed var(--accent);
background: var(--accent-weak);
box-sizing: border-box;
z-index: 8;
}
/* a reference of the formula being edited, in its colour (Excel-style) */
.ref-hl {
position: absolute;
pointer-events: none; /* the inside stays clickable for picking cells */
border: 2px solid var(--ref);
background: color-mix(in srgb, var(--ref) 10%, transparent);
box-sizing: border-box;
z-index: 9;
}
/* the edges move the reference, the corners resize it */
.ref-edge {
position: absolute;
pointer-events: auto;
cursor: move;
}
.ref-edge-top,
.ref-edge-bottom {
left: 0;
right: 0;
height: 6px;
}
.ref-edge-top {
top: -4px;
}
.ref-edge-bottom {
bottom: -4px;
}
.ref-edge-left,
.ref-edge-right {
top: 0;
bottom: 0;
width: 6px;
}
.ref-edge-left {
left: -4px;
}
.ref-edge-right {
right: -4px;
}
.ref-corner {
position: absolute;
pointer-events: auto;
width: 7px;
height: 7px;
background: var(--ref);
border: 1px solid var(--surface);
border-radius: 1px;
z-index: 1;
}
.ref-corner-nw {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.ref-corner-ne {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.ref-corner-sw {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.ref-corner-se {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
/* small square at the selection's bottom-right corner -- drag to fill/copy */ /* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle { .fill-handle {
position: absolute; position: absolute;
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* Shown once after signing in when this browser still has local documents:
* which of them should move into the account (server)? Uploaded ones are
* removed from the browser. "Später" leaves everything as is -- uploading
* stays possible from the document list.
*/
import { ref, watch } from 'vue';
import { useLibrary } from '../store/library';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const library = useLibrary();
const selected = ref<Set<string>>(new Set());
const busy = ref(false);
watch(
() => props.open,
(o) => {
if (o) selected.value = new Set(library.state.docs.map((d) => d.id));
},
);
function toggle(id: string) {
const s = new Set(selected.value);
if (s.has(id)) s.delete(id);
else s.add(id);
selected.value = s;
}
async function run() {
busy.value = true;
try {
await library.upload([...selected.value]);
emit('close');
} finally {
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Dokumente in dein Konto übernehmen?" @close="emit('close')">
<p class="lead">
In diesem Browser liegen noch Dokumente. Ausgewählte werden in dein Konto hochgeladen – dann sind sie
auf allen Geräten da und lassen sich teilen – und hier aus dem Browser entfernt.
</p>
<ul class="list">
<li v-for="d in library.state.docs" :key="d.id">
<label>
<input type="checkbox" :checked="selected.has(d.id)" @change="toggle(d.id)" />
{{ d.title }}
</label>
</li>
</ul>
<template #footer>
<button @click="emit('close')">Später</button>
<button class="primary" :disabled="busy || !selected.size" @click="run">
{{ busy ? 'Lade hoch …' : `${selected.size} hochladen` }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
}
.list {
list-style: none;
margin: 0;
padding: 0;
max-height: 40vh;
overflow-y: auto;
}
.list li + li {
margin-top: 4px;
}
.list label {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 13px;
cursor: pointer;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { HELP_TOPICS, SHORTCUT_GROUPS, shortcutKeys } from '../help';
describe('in-app help content', () => {
it('topics have unique ids that never clash with the fixed pages', () => {
const ids = HELP_TOPICS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).not.toContain('shortcuts');
expect(ids).not.toContain('functions');
});
it('no empty topics, sections, items or shortcuts', () => {
for (const t of HELP_TOPICS) {
expect(t.title.trim()).not.toBe('');
expect(t.sections.length).toBeGreaterThan(0);
for (const s of t.sections) {
expect(s.items.length).toBeGreaterThan(0);
for (const i of s.items) expect(i.trim()).not.toBe('');
}
}
for (const g of SHORTCUT_GROUPS) {
expect(g.shortcuts.length).toBeGreaterThan(0);
for (const s of g.shortcuts) {
expect(s.keys.trim()).not.toBe('');
expect(s.description.trim()).not.toBe('');
}
}
});
it('inline markup is balanced, so nothing renders half-bold', () => {
const all = HELP_TOPICS.flatMap((t) => t.sections.flatMap((s) => s.items));
for (const text of all) {
expect((text.match(/`/g) ?? []).length % 2, text).toBe(0);
expect((text.match(/\*\*/g) ?? []).length % 2, text).toBe(0);
}
});
it('names the keys per platform: Strg on Windows/Linux, ⌘/⌥ and the Mac overrides on a Mac', () => {
expect(shortcutKeys({ keys: 'Mod+Umschalt+U' }, 'pc')).toBe('Strg+Umschalt+U');
expect(shortcutKeys({ keys: 'Mod+Umschalt+U' }, 'mac')).toBe('⌘+Umschalt+U');
expect(shortcutKeys({ keys: 'Alt+Enter oder Mod+Enter' }, 'mac')).toBe('⌥+Enter oder ⌘+Enter');
expect(shortcutKeys({ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→' }, 'pc')).toBe('Pos1 / Ende');
expect(shortcutKeys({ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→' }, 'mac')).toBe('fn+← / fn+→');
// running text: only whole words are key names
expect(shortcutKeys('Alter mit Mod', 'mac')).toBe('Alter mit ⌘');
});
it('no Windows/Linux key text is left with a Mac-only symbol, and vice versa', () => {
for (const g of SHORTCUT_GROUPS) {
for (const s of g.shortcuts) {
expect(shortcutKeys(s, 'pc'), s.keys).not.toMatch(/[⌘⌥⌫]|\bfn\b/);
expect(shortcutKeys(s, 'mac'), s.keys).not.toMatch(/\b(Strg|Mod|Alt)\b/);
}
}
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* Content of the in-app help (components/HelpModal.vue): user-facing topics
* and the keyboard shortcuts. Kept as plain data so the dialog's search can
* run over it; the formula functions are not listed here but taken from
* data/codeReference.ts, so a new built-in shows up in the help by itself.
*
* Text may use `**fett**` and `` `Code` `` -- see HelpModal's `rich()`.
* Hand-maintained: whoever adds or changes a shortcut or a visible feature
* updates this file too (the shortcut test only checks its shape).
*/
export interface HelpSection {
heading?: string;
items: string[];
}
export interface HelpTopic {
id: string;
title: string;
sections: HelpSection[];
}
export interface Shortcut {
/** As on Windows and Linux, with `Mod` for the platform's command key --
* see {@link shortcutKeys}. */
keys: string;
/** Mac keys where they differ by more than ⌘/⌥ (no Pos1/Bild/Entf keys on
* most Mac keyboards, F-keys behind fn, trackpad gestures). */
mac?: string;
description: string;
}
export type KeyPlatform = 'pc' | 'mac';
/** The keys of a shortcut as written on the given platform: `Mod` is Strg on
* Windows/Linux and ⌘ on a Mac, `Alt` is ⌥ on a Mac. Text that is not a key
* name (e.g. "Mod" in a help topic) is converted the same way. */
export function shortcutKeys(s: Pick<Shortcut, 'keys' | 'mac'> | string, platform: KeyPlatform): string {
const text = typeof s === 'string' ? s : platform === 'mac' ? (s.mac ?? s.keys) : s.keys;
if (platform === 'pc') return text.replace(/\bMod\b/g, 'Strg');
return text.replace(/\bMod\b/g, '⌘').replace(/\bAlt\b/g, '⌥');
}
export interface ShortcutGroup {
title: string;
shortcuts: Shortcut[];
}
export const HELP_TOPICS: HelpTopic[] = [
{
id: 'start',
title: 'Erste Schritte',
sections: [
{
items: [
'Ein **Blatt** ist eine freie Fläche, auf der **Objekte** liegen: Tabellen, Diagramme, Textfelder und Buttons. Sie werden über die Symbole in der Werkzeugleiste eingefügt.',
'Objekte an ihrer Kopfleiste verschieben, an der Ecke unten rechts in der Größe ändern. Ein Doppelklick auf den Namen benennt sie um, das `×` in der Kopfleiste löscht sie.',
'Mehrere Blätter über die Reiter unten: hinzufügen mit `+`, umbenennen per Doppelklick, Reihenfolge durch Ziehen.',
'Das Zahnrad neben dem Titel öffnet die **Einstellungen** für das gewählte Objekt, das Blatt und das Dokument.',
'Zoomen mit `Mod` + Mausrad oder über die Zoom-Leiste unten rechts.',
],
},
],
},
{
id: 'tables',
title: 'Tabellen & Auswahl',
sections: [
{
heading: 'Auswählen',
items: [
'Klick wählt eine Zelle, **Umschalt+Klick** oder Ziehen einen Bereich.',
'Klick auf einen Spalten- oder Zeilenkopf wählt die ganze Spalte oder Zeile, die Ecke oben links die ganze Tabelle.',
'**Mod+Klick** fügt eine weitere Zelle hinzu, **Mod+Ziehen** einen weiteren Bereich.',
'**Mod+A** wählt den zusammenhängenden Bereich um die aktive Zelle, ein zweites Mal alle befüllten Zellen.',
'Die Statusleiste zeigt Summe, Mittelwert und Anzahl der Auswahl.',
],
},
{
heading: 'Eingeben',
items: [
'Einfach lostippen oder **F2** bzw. Doppelklick, um den Inhalt zu bearbeiten. **Enter** übernimmt, **Escape** bricht ab.',
'Zeilenumbruch in einer Zelle mit **Alt+Enter** oder **Mod+Enter**.',
'Der Inhalt der aktiven Zelle steht auch in der **Formelleiste** oben und kann dort bearbeitet werden. Links daneben zeigt das Namensfeld, wo man sich befindet. Dort kann auch ein Ziel eingegeben werden (`B5`, `Umsatz::A1:C4`, ein Tabellen- oder Blattname).',
],
},
{
heading: 'Zeilen & Spalten',
items: [
'Rechtsklick auf einen Zeilen- oder Spaltenkopf: einfügen oder löschen.',
'Rechtsklick in eine Zelle: kopieren, einfügen, Zeile oder Spalte am Ende hinzufügen, Abhängigkeiten anzeigen.',
'Spaltenbreite und Zeilenhöhe durch Ziehen am Rand des Kopfs.',
'In den Einstellungen kann eine Tabelle eine **feste Größe** mit eigenem Scrollbereich und fixierten Kopfzeilen und Spalten bekommen.',
],
},
],
},
{
id: 'formulas',
title: 'Formeln',
sections: [
{
heading: 'Grundlagen',
items: [
'Eine Formel beginnt mit `=`, z. B. `=SUM(B2:B6)*1.1` oder `=B2+C2`. Argumente werden mit `;` getrennt.',
'Bezüge: `A1` eine Zelle, `A1:C4` ein Bereich, `B:B` eine ganze Spalte, `2:2` eine ganze Zeile.',
'Andere Tabelle: `Umsatz!B2` oder `Umsatz::B2`. Gibt es den Tabellennamen auf mehreren Blättern: `Analyse!Umsatz::B2`. Namen mit Leerzeichen in einfachen Anführungszeichen: `\'Kosten Q3\'!A1`.',
'Text in doppelten Anführungszeichen: `="Summe: " & B7`.',
'Fehlt beim Übernehmen mit Enter eine schließende Klammer, wird sie ergänzt.',
],
},
{
heading: 'Bezüge zeigen statt tippen',
items: [
'Während der Eingabe einer Formel fügt ein Klick in eine Zelle ihren Bezug ein, Ziehen einen Bereich. Das geht auch in anderen Tabellen.',
'Mit der Tastatur: Direkt nach `=`, `(`, `;` oder einem Rechenzeichen wählen die **Pfeiltasten** eine Zelle, mit **Umschalt** einen Bereich.',
'Alle Bezüge der Formel werden farbig umrahmt. **Am Rand ziehen** verschiebt einen Bezug, **an einer Ecke ziehen** ändert seine Größe. Die Formel passt sich sofort an.',
'**F4** schaltet den Bezug am Cursor durch: `A1` → `$A$1` → `A$1` → `$A1`. Ein `$` hält Spalte oder Zeile beim Kopieren fest.',
],
},
{
heading: 'Funktionen',
items: [
'Beim Tippen eines Funktionsnamens erscheint eine Vorschlagsliste. **Pfeil auf/ab** wählt, **Tab** oder **Enter** übernimmt.',
'Alle eingebauten Funktionen stehen unter **Funktionen** in dieser Hilfe.',
'**Eigene Funktionen** legt man über `fx` in der Werkzeugleiste an. Sie werden danach wie eingebaute aufgerufen.',
'**Benannte Bereiche** (Etikett-Symbol) geben einem Bereich einen Namen, z. B. `=SUM(Umsatz_Q1)`.',
'Rechtsklick in eine Zelle › **Abhängigkeiten anzeigen** zeigt, woraus eine Formel rechnet und wer sie verwendet.',
],
},
],
},
{
id: 'format',
title: 'Formatierung',
sections: [
{
items: [
'Zellen auswählen, dann in den Einstellungen unter **Auswahl**: Ausrichtung, Zahlenformat, **F**ett, **K**ursiv, **U**nterstrichen, Text- und Füllfarbe.',
'Mit der Tastatur: **Mod+Umschalt+B** (oder **F**) fett, **Mod+Umschalt+I** kursiv, **Mod+Umschalt+U** unterstrichen. Erneut drücken schaltet wieder aus.',
'Die Formatierung gilt für die ganze Auswahl, auch für mehrere Bereiche.',
'Wie Zahlen dargestellt werden (Dezimal- und Tausendertrennzeichen), richtet sich nach der Ländereinstellung von Objekt, Blatt oder Dokument.',
],
},
],
},
{
id: 'macros',
title: 'Makros & Buttons',
sections: [
{
items: [
'**Makros** (Symbol in der Werkzeugleiste) ändern per Skript Zellen, Zeilen, Spalten, Tabellen und Blätter, auch dokumentweit.',
'Ein Makro läuft nur, wenn man es startet: mit **Ausführen** im Makro-Dialog oder per Klick auf ein **Button**-Objekt auf der Fläche.',
'Welches Makro ein Button startet, wird in den Einstellungen oder per Rechtsklick auf den Button gewählt.',
'Neben dem Editor steht eine durchsuchbare Befehlsreferenz. Ein Klick fügt den Befehl ein.',
],
},
],
},
{
id: 'files',
title: 'Speichern & Teilen',
sections: [
{
heading: 'Speichern',
items: [
'Ohne Anmeldung liegen Dokumente **in diesem Browser** und werden automatisch gespeichert. In einem anderen Browser oder nach dem Löschen der Browserdaten sind sie dort nicht vorhanden.',
'Mit **Anmelden** (Link oder Code per E-Mail) liegen Dokumente im Konto und sind überall verfügbar.',
'**Datei › Dokumente …** listet alle Dokumente: öffnen, umbenennen, duplizieren, löschen, ins Konto hochladen.',
'**Datei** öffnet und importiert `.num`, `.json`, `.csv` und `.xlsx` und exportiert in diese Formate.',
],
},
{
heading: 'Teilen & gemeinsam bearbeiten',
items: [
'**Teilen** erstellt einen Link zum Ansehen oder Bearbeiten oder lädt per E-Mail ein. Ein lokales Dokument wird dafür zuerst hochgeladen.',
'Änderungen der anderen erscheinen sofort. Ihre Auswahl ist als farbiger Rahmen mit Namen zu sehen, oben rechts stehen ihre Avatare.',
'Rückgängig nimmt nur die eigenen Änderungen zurück.',
'Wer nur ansehen darf, sieht „Nur Lesen“ und kann nichts ändern.',
],
},
],
},
];
export const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'In der Tabelle',
shortcuts: [
{ keys: 'Pfeiltasten', description: 'Zelle wechseln' },
{ keys: 'Umschalt+Pfeiltasten', description: 'Auswahl erweitern' },
{ keys: 'Mod+Pfeiltasten', description: 'An den Rand des Datenblocks springen (mit Umschalt: Auswahl bis dorthin)' },
{ keys: 'Tab / Umschalt+Tab', description: 'Nach rechts / links, innerhalb einer Auswahl der Reihe nach' },
{ keys: 'Enter / Umschalt+Enter', description: 'Nach unten / oben, innerhalb einer Auswahl der Reihe nach' },
{ keys: 'Pos1 / Ende', mac: 'fn+← / fn+→', description: 'Anfang / Ende der Zeile' },
{ keys: 'Mod+Pos1 / Mod+Ende', mac: 'fn+⌘+← / fn+⌘+→', description: 'Erste / letzte Zelle der Tabelle' },
{ keys: 'Bild auf / Bild ab', mac: 'fn+↑ / fn+↓', description: 'Seitenweise blättern' },
{ keys: 'Mod+A', description: 'Zusammenhängenden Bereich wählen, zweimal: alle befüllten Zellen' },
{ keys: 'F2', mac: 'F2 (ggf. fn+F2)', description: 'Aktive Zelle bearbeiten (oder einfach lostippen)' },
{ keys: 'Entf / Rücktaste', mac: '⌫ / fn+⌫', description: 'Inhalt der Auswahl leeren' },
{ keys: 'Mod+C / Mod+X / Mod+V', description: 'Kopieren / Ausschneiden / Einfügen' },
{ keys: 'Escape', description: 'Markierung der kopierten Zellen aufheben' },
{ keys: 'Mod+Umschalt+B oder F', description: 'Fett ein/aus' },
{ keys: 'Mod+Umschalt+I', description: 'Kursiv ein/aus' },
{ keys: 'Mod+Umschalt+U', description: 'Unterstrichen ein/aus' },
],
},
{
title: 'Beim Bearbeiten einer Zelle',
shortcuts: [
{ keys: 'Enter / Tab', description: 'Übernehmen und nach unten / rechts' },
{ keys: 'Umschalt+Enter / Umschalt+Tab', description: 'Übernehmen und nach oben / links' },
{ keys: 'Escape', description: 'Abbrechen' },
{ keys: 'Alt+Enter oder Mod+Enter', description: 'Zeilenumbruch in der Zelle' },
{ keys: 'Pfeiltasten nach = ( ; oder Rechenzeichen', description: 'Zellbezug wählen, mit Umschalt einen Bereich' },
{ keys: 'F4', mac: 'F4 (ggf. fn+F4)', description: 'Bezug sperren: A1 → $A$1 → A$1 → $A1' },
{ keys: 'Pfeil auf/ab, Tab oder Enter', description: 'In der Vorschlagsliste wählen und übernehmen' },
],
},
{
title: 'Allgemein',
shortcuts: [
{ keys: 'Mod+Z', description: 'Rückgängig' },
{ keys: 'Mod+Y oder Mod+Umschalt+Z', description: 'Wiederholen' },
{ keys: 'Mod+Mausrad', mac: '⌘+Mausrad oder Trackpad mit zwei Fingern auseinander/zusammen', description: 'Zoomen' },
{ keys: 'Entf / Rücktaste', mac: '⌫', description: 'Gewähltes Diagramm löschen' },
{ keys: 'F1', mac: 'F1 (ggf. fn+F1)', description: 'Diese Hilfe öffnen' },
],
},
];
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
parseFormula, parseFormula,
completeFormula,
parseFunctionBlock, parseFunctionBlock,
parseMacroBlock, parseMacroBlock,
collectRefs, collectRefs,
@@ -489,3 +490,21 @@ describe('collectRefsInBlock -- reference walk used by the macro save-time quali
expect(refs).toHaveLength(2); expect(refs).toHaveLength(2);
}); });
}); });
describe('completeFormula -- closing what Enter left open', () => {
it('appends missing closing parentheses', () => {
expect(completeFormula('=SUMME(A1:A5')).toBe('=SUMME(A1:A5)');
expect(completeFormula('=RUNDEN(SUMME(A1;B1)*(2+3')).toBe('=RUNDEN(SUMME(A1;B1)*(2+3))');
expect(completeFormula('=SUMME(A1 ')).toBe('=SUMME(A1 )');
});
it('closes an open text literal before the parentheses', () => {
expect(completeFormula('=WENN(A1>0;"ja')).toBe('=WENN(A1>0;"ja")');
});
it('leaves valid formulas, plain text and unfixable input alone', () => {
expect(completeFormula('=SUMME(A1:A5)')).toBe('=SUMME(A1:A5)');
expect(completeFormula('Hallo (Welt')).toBe('Hallo (Welt');
expect(completeFormula('=SUMME(A1+')).toBe('=SUMME(A1+');
expect(completeFormula('=A1)')).toBe('=A1)');
expect(completeFormula('=(')).toBe('=(');
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs'; import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret, formulaRefSpans } from '../refs';
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => { describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
it('parseA1 reports which axes are $-locked', () => { it('parseA1 reports which axes are $-locked', () => {
@@ -121,3 +121,30 @@ describe('currentIdentifierAtCaret -- function-name autocomplete detection', ()
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull(); expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
}); });
}); });
describe('formulaRefSpans -- references with their position in the text', () => {
const texts = (f: string) => formulaRefSpans(f).map((s) => f.slice(s.start, s.end));
it('finds cells, ranges and whole lines in text order', () => {
expect(texts('=SUMME(A1:B3;$C$2)*D4+B:B')).toEqual(['A1:B3', '$C$2', 'D4', 'B:B']);
const [r] = formulaRefSpans('=A1:$B$3');
expect(r.a).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
expect(r.b).toEqual({ col: 1, row: 2, colAbs: true, rowAbs: true });
expect(formulaRefSpans('=2:5')[0]).toMatchObject({ whole: 'row', a: { row: 1 }, b: { row: 4 } });
});
it('includes the qualifier in the span and reports it separately', () => {
const f = "='Kosten Q3'!A1+Analyse!Umsatz::B2:C4";
expect(texts(f)).toEqual(["'Kosten Q3'!A1", 'Analyse!Umsatz::B2:C4']);
const [q, s] = formulaRefSpans(f);
expect(q).toMatchObject({ qualifier: 'Kosten Q3', refStart: f.indexOf('A1') });
expect(s).toMatchObject({ qualifier: 'Umsatz', sheetQualifier: 'Analyse' });
});
it('skips function names, strings and named ranges; tolerates unfinished input', () => {
expect(texts('=LOG10(A1)&"B2"&Umsatz_Q1')).toEqual(['A1']);
expect(texts('=SUMME(A1:A5;B')).toEqual(['A1:A5']);
expect(texts('=WENN(A1;"offen')).toEqual([]);
expect(texts('A1')).toEqual([]);
});
});
@@ -8,6 +8,7 @@
export { tokenize, LexError, type Token, type TokenType } from './lexer'; export { tokenize, LexError, type Token, type TokenType } from './lexer';
export { export {
parseFormula, parseFormula,
completeFormula,
parseFunctionBlock, parseFunctionBlock,
parseMacroBlock, parseMacroBlock,
collectRefs, collectRefs,
@@ -37,6 +38,8 @@ export {
WHOLE_LINE_END, WHOLE_LINE_END,
cycleReferenceLocks, cycleReferenceLocks,
currentIdentifierAtCaret, currentIdentifierAtCaret,
formulaRefSpans,
type RefSpan,
} from './refs'; } from './refs';
export { export {
Workbook, Workbook,
@@ -763,6 +763,53 @@ export function parseFormula(src: string, paramNames?: readonly string[]): Node
return new Parser(toks, paramNames, paramNames === undefined).parse(); return new Parser(toks, paramNames, paramNames === undefined).parse();
} }
/**
* Repair the one mistake that is almost always just forgotten typing: a
* formula committed with Enter while parentheses (or a "..." text literal)
* are still open, e.g. `=SUMME(A1:A5` or `=WENN(A1>0;"ja`. Same as Excel and
* Numbers, the missing closers are appended at the end.
*
* Deliberately conservative: anything that already parses, isn't a formula,
* or still doesn't parse after closing (`=SUMME(A1;`, a stray `)` too many)
* comes back unchanged, so the cell shows its usual parse error instead of a
* "repair" that guessed something the user never meant.
*/
export function completeFormula(input: string): string {
if (!input.startsWith('=')) return input;
const parses = (src: string) => {
try {
parseFormula(src);
return true;
} catch {
return false;
}
};
if (parses(input)) return input;
let fixed = input;
let toks: Token[];
try {
toks = tokenize(fixed.slice(1));
} catch (e) {
// an unterminated "..." literal swallows everything up to the end, so
// closing it first is the only way to count the parentheses before it
if (!(e instanceof LexError) || !e.message.startsWith('Unterminated string')) return input;
fixed += '"';
try {
toks = tokenize(fixed.slice(1));
} catch {
return input;
}
}
let depth = 0;
for (const t of toks) {
if (t.type === 'LPAREN') depth++;
else if (t.type === 'RPAREN' && --depth < 0) return input;
}
fixed += ')'.repeat(depth);
return fixed !== input && parses(fixed) ? fixed : input;
}
/** /**
* Parse a "block" function body -- the statement grammar (assignments, * Parse a "block" function body -- the statement grammar (assignments,
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a * `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
@@ -174,6 +174,102 @@ export function currentIdentifierAtCaret(
return { text: last.value, start: last.pos + 1 }; return { text: last.value, start: last.pos + 1 };
} }
/** One cell/range reference found in a formula's text -- see {@link formulaRefSpans}. */
export interface RefSpan {
/** offsets into the formula (including its leading "="): `start` is where
* the qualifier (if any) begins, `refStart` where the A1 part begins */
start: number;
refStart: number;
end: number;
qualifier: string | null;
sheetQualifier?: string;
/** both corners as written (`end` equals `start` for a single cell) */
a: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
b: { col: number; row: number; colAbs: boolean; rowAbs: boolean };
/** a whole-column/whole-row range (`B:B`, `2:5`); its open axis runs to
* {@link WHOLE_LINE_END} */
whole?: LineAxis;
}
/**
* Every cell/range reference in a formula, in text order, with where it sits
* in the text -- what the grid colours while a formula is edited (and lets
* the user drag to change it, Excel-style). Same token-level reading of the
* `reference` grammar rule the parser applies (optional `Blatt!Tabelle::` /
* `Tabelle!` qualifier, `A1`, `A1:B2`, `B:B`), but tolerant: an incomplete
* formula still yields the references written so far. Named ranges are left
* out (they have no position of their own to drag); a formula that doesn't
* even tokenize (open "..." literal) yields nothing.
*/
export function formulaRefSpans(formula: string): RefSpan[] {
if (!formula.startsWith('=')) return [];
let toks: Token[];
try {
toks = tokenize(formula.slice(1));
} catch {
return [];
}
const isName = (t: Token | undefined) => !!t && (t.type === 'IDENT' || t.type === 'QUOTED');
const out: RefSpan[] = [];
let i = 0;
while (i < toks.length) {
const first = toks[i];
let j = i;
let qualifier: string | null = null;
let sheetQualifier: string | undefined;
if (isName(toks[j]) && toks[j + 1]?.type === 'BANG' && isName(toks[j + 2]) && toks[j + 3]?.type === 'DBLCOLON') {
sheetQualifier = toks[j].value;
qualifier = toks[j + 2].value;
j += 4;
} else if (isName(toks[j]) && (toks[j + 1]?.type === 'BANG' || toks[j + 1]?.type === 'DBLCOLON')) {
qualifier = toks[j].value;
j += 2;
}
const line = wholeLineAt(toks, j);
const cell = toks[j];
if (line) {
const max = WHOLE_LINE_END;
const isCol = line.axis === 'col';
out.push({
start: first.pos + 1,
refStart: line.a.pos + 1,
end: line.b.pos + line.b.value.length + 1,
qualifier,
sheetQualifier,
a: { col: isCol ? line.start : 0, row: isCol ? 0 : line.start, colAbs: isCol && line.startAbs, rowAbs: !isCol && line.startAbs },
b: { col: isCol ? line.end : max, row: isCol ? max : line.end, colAbs: isCol && line.endAbs, rowAbs: !isCol && line.endAbs },
whole: line.axis,
});
i = j + 3;
continue;
}
if (cell?.type === 'IDENT' && looksLikeCell(cell.value) && toks[j + 1]?.type !== 'LPAREN') {
const a = parseA1(cell.value);
let last = cell;
let b = a;
const other = toks[j + 2];
if (toks[j + 1]?.type === 'COLON' && other?.type === 'IDENT' && looksLikeCell(other.value)) {
b = parseA1(other.value);
last = other;
j += 2;
}
out.push({
start: first.pos + 1,
refStart: cell.pos + 1,
end: last.pos + last.value.length + 1,
qualifier,
sheetQualifier,
a,
b,
});
i = j + 1;
continue;
}
i++;
}
return out;
}
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ---- // ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
/** Stand-in for "the last row/column" in a whole-column/whole-row range's /** Stand-in for "the last row/column" in a whole-column/whole-row range's
@@ -1,7 +1,7 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from '../App.vue';
import { useLibrary } from './store/library'; import { useLibrary } from '../store/library';
import './styles/main.css'; import '../styles/main.css';
// open the document (from IndexedDB) before the first render, so the app // open the document (from IndexedDB) before the first render, so the app
// never flashes an empty document first // never flashes an empty document first
View File
+99
View File
@@ -0,0 +1,99 @@
/**
* The numolus server API (Rails, /api). JSON in and out, the session cookie
* for the signed-in user, the CSRF token from the page's meta tag, and
* X-Share-Token (a share link's token, for access via link).
*/
export class ApiError extends Error {
constructor(
public status: number,
public body: Record<string, unknown>,
) {
super(ApiError.describe(status, body));
}
static describe(status: number, body: Record<string, unknown>): string {
if (status === 0) return 'Keine Verbindung zum Server.';
if (status === 401) return 'Nicht angemeldet.';
if (status === 404) return 'Nicht gefunden oder kein Zugriff.';
if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.';
const msgs = body.messages;
if (Array.isArray(msgs) && msgs.length) return msgs.join(', ');
return `Serverfehler (${status}).`;
}
}
export type Role = 'owner' | 'editor' | 'viewer';
export interface User {
id: number;
email: string;
name: string;
display_name: string | null;
color: string;
}
export interface ServerDocMeta {
id: string;
title: string;
role: Role;
owner_name: string;
updated_at: string;
updated_by_name: string | null;
}
export interface ServerDoc extends ServerDocMeta {
/** JSON snapshot (the live content comes through DocumentChannel) */
content?: string;
}
export interface ShareInfo {
links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[];
members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[];
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
}
function csrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
}
interface RequestOpts {
body?: unknown;
shareToken?: string | null;
/** survives the page being closed (small bodies only, < 64 KB) */
keepalive?: boolean;
}
export async function api<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
'X-CSRF-Token': csrfToken(),
};
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
let res: Response;
try {
res = await fetch(`/api${path}`, {
method,
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
credentials: 'same-origin',
keepalive: opts.keepalive,
});
} catch {
throw new ApiError(0, {});
}
if (res.status === 204) return {} as T;
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) throw new ApiError(res.status, body);
return body as T;
}
/** Data the server put into the page (see app/views/spa/show.html.erb). */
export function bootData(): { user: User | null } {
try {
const el = document.getElementById('numolus-boot');
return el ? JSON.parse(el.textContent || '{}') : { user: null };
} catch {
return { user: null };
}
}
@@ -171,6 +171,7 @@ function normaliseCellFormat(x: unknown): CellFormat | undefined {
if (colorOrUndef(f.bg)) out.bg = f.bg; if (colorOrUndef(f.bg)) out.bg = f.bg;
if (f.bold === true) out.bold = true; if (f.bold === true) out.bold = true;
if (f.italic === true) out.italic = true; if (f.italic === true) out.italic = true;
if (f.underline === true) out.underline = true;
return Object.keys(out).length ? out : undefined; return Object.keys(out).length ? out : undefined;
} }
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined { function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Some files were not shown because too many files have changed in this diff Show More