Compare commits
9
Commits
main
...
with-server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db1562a38 | ||
|
|
59b99e5715 | ||
|
|
0fca61eb65 | ||
|
|
0368e96e0c | ||
|
|
f22963a666 | ||
|
|
ed099cfbda | ||
|
|
5871524421 | ||
|
|
7fce972fdc | ||
|
|
e14bada114 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ruby-3.4.9
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
web: bin/rails server -p 3000
|
||||||
|
vite: bin/vite dev
|
||||||
@@ -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, …
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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>
|
||||||
@@ -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">
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user