From 7fce972fdcd73621efcae502380dd6fd52e4e7b0 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Mon, 28 Sep 2026 20:41:02 +0200 Subject: [PATCH] Live collaboration with Yjs over ActionCable All editors now work on a server document at the same time instead of taking turns behind an edit lock. - The document's content is a sequence of Yjs updates (document_updates); DocumentChannel stores and relays them immediately, checks access on subscribe and again whenever links or memberships change. - app/frontend/collab/: the store stays position-based; a binding maps it to a Yjs form with stable row/column ids, so a row inserted by one person doesn't misplace what another is typing. Remote changes are applied only after pending local ones went into Yjs. - Per-person undo (Y.UndoManager), presence via awareness: the others' selections as coloured boxes with names, avatars in the toolbar. - Existing JSON documents convert on first open, deterministically, so two people opening one at once end up with the same Yjs state. - Editors compact the stored updates after 200 and keep a JSON snapshot current for export and duplicating. Edit lock and polling are gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 12 + app/channels/document_channel.rb | 114 +++++ app/controllers/api/base_controller.rb | 7 - .../api/document_locks_controller.rb | 29 -- app/controllers/api/documents_controller.rb | 31 +- app/controllers/api/shares_controller.rb | 4 + app/frontend/collab/__tests__/ydoc.spec.ts | 178 ++++++++ app/frontend/collab/session.ts | 401 ++++++++++++++++ app/frontend/collab/ydoc.ts | 432 ++++++++++++++++++ app/frontend/components/AppToolbar.vue | 45 +- app/frontend/components/TableGrid.vue | 47 ++ app/frontend/io/api.ts | 23 +- app/frontend/store/document.ts | 86 +++- app/frontend/store/library.ts | 325 ++++--------- app/models/document.rb | 41 +- app/models/document_update.rb | 13 + config/routes.rb | 1 - .../20260929090000_live_collaboration.rb | 18 + db/schema.rb | 17 +- docs/konzept-teilen-und-zusammenarbeit.md | 10 +- package-lock.json | 88 +++- package.json | 7 +- test/channels/document_channel_test.rb | 94 ++++ test/integration/documents_api_test.rb | 51 +-- test/models/document_test.rb | 11 - 27 files changed, 1685 insertions(+), 413 deletions(-) create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/document_channel.rb delete mode 100644 app/controllers/api/document_locks_controller.rb create mode 100644 app/frontend/collab/__tests__/ydoc.spec.ts create mode 100644 app/frontend/collab/session.ts create mode 100644 app/frontend/collab/ydoc.ts create mode 100644 app/models/document_update.rb create mode 100644 db/migrate/20260929090000_live_collaboration.rb create mode 100644 test/channels/document_channel_test.rb diff --git a/README.md b/README.md index ad2214a..1ef1fa6 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,11 @@ Die App ist eine **Rails-8-Anwendung** mit dem Vue-Frontend unter [`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“. Solange -es keine Live-Zusammenarbeit gibt, bearbeitet immer nur ein Browser-Tab ein -Server-Dokument; alle anderen sehen es schreibgeschützt und folgen den -Änderungen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md). +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: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md). Mails landen in der Entwicklung nicht im Postfach, sondern unter . Produktion braucht `SMTP_ADDRESS`, diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..3e674fd --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -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 diff --git a/app/channels/document_channel.rb b/app/channels/document_channel.rb new file mode 100644 index 0000000..7a431b9 --- /dev/null +++ b/app/channels/document_channel.rb @@ -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 diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index cf8b088..ed2f852 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -17,12 +17,5 @@ module Api # a share link token, for visitors (with or without account) who got the link def share_token = request.headers["X-Share-Token"].presence - - # identifies the lock holder: one browser tab - def client_id = request.headers["X-Client-Id"].to_s[0, 64].presence || "anon" - - def lock_holder = "#{Current.user&.id || 'anon'}:#{client_id}" - - def holder_name = Current.user&.name || "Jemand mit dem Link" end end diff --git a/app/controllers/api/document_locks_controller.rb b/app/controllers/api/document_locks_controller.rb deleted file mode 100644 index cad98e1..0000000 --- a/app/controllers/api/document_locks_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Api - # The edit lock of a server document: one browser tab edits at a time, - # everyone else sees the document read-only until the lock is released or - # expires (Document::LOCK_TTL without a heartbeat). - class DocumentLocksController < BaseController - before_action :load_document - - # POST /api/documents/:id/lock -- take or refresh (heartbeat) - def create - return render(json: { lock: { mine: false, holder_name: nil } }) unless Document.role_at_least?(@role, :editor) - mine = @doc.acquire_lock!(lock_holder, holder_name) - render json: { lock: { mine:, holder_name: (@doc.lock_holder_name unless mine) } } - end - - # DELETE /api/documents/:id/lock - def destroy - @doc.release_lock!(lock_holder) - head :no_content - end - - private - - def load_document - @doc = Document.find(params[:document_id]) - @role = @doc.role_for(user: Current.user, token: share_token) - raise ActiveRecord::RecordNotFound unless @role - end - end -end diff --git a/app/controllers/api/documents_controller.rb b/app/controllers/api/documents_controller.rb index ebf5ade..ced913c 100644 --- a/app/controllers/api/documents_controller.rb +++ b/app/controllers/api/documents_controller.rb @@ -23,32 +23,21 @@ module Api render json: { document: doc.meta_json(role: :owner) }, status: :created end - # GET /api/documents/:id[?known_version=n] - # With known_version: just the metadata when nothing changed since. + # 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).merge(lock: @doc.lock_info(lock_holder)) - if params[:known_version].present? && params[:known_version].to_i == @doc.version - render json: { document: meta, unchanged: true } - else - render json: { document: meta.merge(content: @doc.content) } - end + meta = @doc.meta_json(role: @role) + meta[:content] = @doc.content unless params[:content] == "0" + render json: { document: meta } end - # PATCH /api/documents/:id { title } and/or { content, base_version } + # PATCH /api/documents/:id { title } def update return forbidden unless Document.role_at_least?(@role, :editor) - if params.key?(:content) - return render(json: { error: "locked", holder_name: @doc.lock_holder_name }, status: :locked) if @doc.locked_by_other?(lock_holder) - @doc.with_lock do - if params.require(:base_version).to_i != @doc.version - return render(json: { error: "conflict", version: @doc.version }, status: :conflict) - end - @doc.update!(content: content_param, version: @doc.version + 1, - content_updated_at: Time.current, updated_by: Current.user) - end - end - @doc.update!(title: params[:title]) if params[:title].present? + @doc.update!(title: params.require(:title)) + @doc.broadcast({ type: "meta", title: @doc.title }) render json: { document: @doc.meta_json(role: @role) } end @@ -56,8 +45,10 @@ module Api 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 diff --git a/app/controllers/api/shares_controller.rb b/app/controllers/api/shares_controller.rb index 8eda64a..7340f8c 100644 --- a/app/controllers/api/shares_controller.rb +++ b/app/controllers/api/shares_controller.rb @@ -18,12 +18,14 @@ module Api @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 @@ -44,12 +46,14 @@ module Api 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 diff --git a/app/frontend/collab/__tests__/ydoc.spec.ts b/app/frontend/collab/__tests__/ydoc.spec.ts new file mode 100644 index 0000000..2807e87 --- /dev/null +++ b/app/frontend/collab/__tests__/ydoc.spec.ts @@ -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, 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 = (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); + }); +}); diff --git a/app/frontend/collab/session.ts b/app/frontend/collab/session.ts new file mode 100644 index 0000000..13bf18c --- /dev/null +++ b/app/frontend/collab/session.ts @@ -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([]); + 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 | undefined; + private snapshotTimer: ReturnType | undefined; + private compactTimer: ReturnType | undefined; + private queue: Msg[] = []; + private queued = false; + private knownIds = new Set(); + private axes = new Map(); + 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, 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 { + const done = new Promise((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); + } +} diff --git a/app/frontend/collab/ydoc.ts b/app/frontend/collab/ydoc.ts new file mode 100644 index 0000000..f4e9888 --- /dev/null +++ b/app/frontend/collab/ydoc.ts @@ -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 sheets: Y.Map + * sheet: name, locale, background, ... plain values (zoom stays local) + * objectOrder: Y.Array objects: Y.Map + * object: kind, name, x, y, ... plain values + * table additionally: + * rowOrder / colOrder: Y.Array + * cells: Y.Map<"colId|rowId", raw input> + * formats: Y.Map<"colId|rowId", CellFormat> + * colWidths: Y.Map rowHeights: Y.Map + * 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; +type YArr = Y.Array; + +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) { + 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(); + rowOrder.push(rowIds); + const colOrder = new Y.Array(); + colOrder.push(colIds); + m.set('rowOrder', rowOrder); + m.set('colOrder', colOrder); + const cells = new Y.Map(); + const formats = new Y.Map(); + const colWidths = new Y.Map(); + const rowHeights = new Y.Map(); + 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(); + 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(); + setPlain(m, s, SHEET_STRUCT); + m.set('id', s.id); + const order = new Y.Array(); + order.push(s.objects.map((o) => o.id)); + const objects = new Y.Map(); + 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(); + order.push(d.sheets.map((s) => s.id)); + const sheets = new Y.Map(); + 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(); + o.push(list.map((x) => x.id)); + const items = new Y.Map(); + 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(order: YArr | undefined, items: Y.Map | undefined): T[] { + if (!order || !items) return []; + const seen = new Set(); + 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 = {}; + 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 = {}; + for (const [k, v] of (m.get('cells') as Y.Map).entries()) { + const p = pos(k); + if (p) cells[p] = v; + } + const formats: Record = {}; + for (const [k, v] of (m.get('formats') as Y.Map).entries()) { + const p = pos(k); + if (p) formats[p] = v; + } + const colWidths: Record = {}; + for (const [k, v] of (m.get('colWidths') as Y.Map).entries()) { + const c = ci.get(k); + if (c !== undefined) colWidths[c] = v; + } + const rowHeights: Record = {}; + for (const [k, v] of (m.get('rowHeights') as Y.Map).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 = {}; + for (const [k, v] of m.entries()) { + if (!(v instanceof Y.AbstractType)) s[k] = v; + } + s.objects = orderedItems(m.get('objectOrder') as YArr, m.get('objects') as Y.Map).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(root.get('sheetOrder') as YArr, root.get('sheets') as Y.Map).map(readSheet); + const list = (o: string, i: string) => orderedItems(root.get(o) as YArr, root.get(i) as Y.Map); + 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('fnOrder', 'fns'), + namedRanges: list('nameOrder', 'names'), + macros: list('macroOrder', 'macros'), + }; +} + +/** Current row/column ids of every table (for mapping positions to ids). */ +export function allAxes(y: Y.Doc): Map { + const out = new Map(); + const sheets = y.getMap('numolus').get('sheets') as Y.Map | undefined; + if (!sheets) return out; + for (const s of sheets.values() as IterableIterator) { + for (const [id, o] of (s.get('objects') as Y.Map).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, keepKeys: Set) { + const entries = obj as Record; + 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(ymap: Y.Map, want: Map) { + 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(); + 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, cells); + const formats = new Map(); + 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, formats); + const widths = new Map(); + for (const [c, w] of Object.entries(t.colWidths ?? {})) if (cols[+c]) widths.set(cols[+c], w); + syncKeyedMap(m.get('colWidths') as Y.Map, widths); + const heights = new Map(); + for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rows[+r]) heights.set(rows[+r], h); + syncKeyedMap(m.get('rowHeights') as Y.Map, heights); +} + +function syncObjects(sheet: YMap, s: Sheet, events: StructureEvent[]) { + const objects = sheet.get('objects') as Y.Map; + 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; + 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, new Map(list.map((x) => [x.id, x]))); + syncOrder(root.get(orderKey) as YArr, list.map((x) => x.id)); + } + }, LOCAL); + } +} diff --git a/app/frontend/components/AppToolbar.vue b/app/frontend/components/AppToolbar.vue index 5945b9f..6232dea 100644 --- a/app/frontend/components/AppToolbar.vue +++ b/app/frontend/components/AppToolbar.vue @@ -143,11 +143,11 @@ onMounted(() => { uploadOpen.value = true; }); -const statusNote = computed(() => { - if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert'; - if (library.state.syncState === 'saving') return 'Speichert …'; - return ''; -}); +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) ------------------------------------ const titleDraft = ref(library.currentTitle.value); @@ -439,10 +439,17 @@ async function onSample() { - - {{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }} + Nur Lesen + Offline + + {{ p.name.replace(/^Anonym \(/, '').slice(0, 1).toUpperCase() }} + +{{ library.peers.value.length - 5 }} - {{ statusNote }}
@@ -748,6 +755,28 @@ async function onSample() { 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 { diff --git a/app/frontend/components/TableGrid.vue b/app/frontend/components/TableGrid.vue index ef22255..c5eff88 100644 --- a/app/frontend/components/TableGrid.vue +++ b/app/frontend/components/TableGrid.vue @@ -13,6 +13,7 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'; import { numberToCol, cycleReferenceLocks } from '../engine'; import { useDocument } from '../store/document'; +import { useLibrary } from '../store/library'; import { DEFAULT_ROW_H, type SelectionState } from '../store/types'; import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete'; import { useKeyboardPointMode } from '../composables/useKeyboardPointMode'; @@ -22,6 +23,7 @@ import FormulaSuggest from './FormulaSuggest.vue'; const props = defineProps<{ tableId: string; selected: boolean }>(); const store = useDocument(); +const library = useLibrary(); const table = computed(() => store.tableById(props.tableId)); const DEFAULT_W = 104; @@ -270,6 +272,28 @@ const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1) // ---- clipboard source marker (one dashed rectangle, Excel-style "marching // ants" -- overlaid once over the whole range, not per cell). -------------- 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 k = clipRect.value; if (!k || k.tableId !== props.tableId) return null; @@ -1411,6 +1435,9 @@ defineExpose({ +
+ {{ p.name }} +
@@ -1578,6 +1605,26 @@ defineExpose({ .cell.is-active { 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 */ .clip-marquee { position: absolute; diff --git a/app/frontend/io/api.ts b/app/frontend/io/api.ts index ba0b81d..bea04c2 100644 --- a/app/frontend/io/api.ts +++ b/app/frontend/io/api.ts @@ -1,8 +1,7 @@ /** * 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 two - * per-request extras: X-Client-Id (this browser tab, the edit-lock holder) - * and X-Share-Token (a share link's token, for access via link). + * 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 { @@ -36,7 +35,6 @@ export interface User { export interface ServerDocMeta { id: string; title: string; - version: number; role: Role; owner_name: string; updated_at: string; @@ -44,8 +42,8 @@ export interface ServerDocMeta { } export interface ServerDoc extends ServerDocMeta { + /** JSON snapshot (the live content comes through DocumentChannel) */ content?: string; - lock?: { locked_by_other: boolean; holder_name: string | null }; } export interface ShareInfo { @@ -54,20 +52,6 @@ export interface ShareInfo { invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[]; } -/** One id per browser tab, kept across reloads of that tab. */ -export const clientId: string = (() => { - const key = 'numolus:client-id'; - try { - const existing = sessionStorage.getItem(key); - if (existing) return existing; - const id = Math.random().toString(36).slice(2) + Date.now().toString(36); - sessionStorage.setItem(key, id); - return id; - } catch { - return Math.random().toString(36).slice(2); - } -})(); - function csrfToken(): string { return document.querySelector('meta[name="csrf-token"]')?.content ?? ''; } @@ -83,7 +67,6 @@ export async function api>(method: string, path: str const headers: Record = { Accept: 'application/json', 'X-CSRF-Token': csrfToken(), - 'X-Client-Id': clientId, }; if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken; diff --git a/app/frontend/store/document.ts b/app/frontend/store/document.ts index 47fd701..b672afe 100644 --- a/app/frontend/store/document.ts +++ b/app/frontend/store/document.ts @@ -17,7 +17,7 @@ * qualifier, so `Umsatz!B2` resolves the same from any sheet. */ -import { computed, reactive, ref } from 'vue'; +import { computed, reactive, ref, shallowRef, type Ref } from 'vue'; import { Workbook, toA1, @@ -91,6 +91,29 @@ export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) { persistHandler = fn; } +/** Live collaboration hooks (src/collab/binding.ts), unset otherwise: + * `onChange` fires synchronously on every content or view change (the + * binding debounces), `onStructure` right when a row/column is inserted or + * deleted mid-table -- the binding needs to know that a whole row moved, + * not just that many cells changed. */ +export interface CollabHooks { + onChange(): void; + onStructure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1): void; +} +let collabHooks: CollabHooks | null = null; +export function setCollabHooks(h: CollabHooks | null) { + collabHooks = h; +} + +/** Replaces the store's own snapshot undo while a document is edited + * together (per-person undo, see src/collab/session.ts). */ +export interface UndoProvider { + undo(): void; + redo(): void; + canUndo: Ref; + canRedo: Ref; +} + /** The part of a document that is *content* -- everything but the per-view * state (selection, active sheet/object). Used by undo history to tell an * edit from a mere cursor move, and by the library for the same distinction @@ -669,7 +692,10 @@ function createStore() { function scheduleSave() { clearTimeout(persistTimer); persistTimer = setTimeout(flushSave, 250); + if (!applyingExternal) collabHooks?.onChange(); } + /** true while a collaborator's change is being applied -- not a local edit */ + let applyingExternal = false; /** Write a pending save right now (page hidden, switching documents). */ function flushSave() { if (persistTimer === undefined) return; @@ -766,6 +792,7 @@ function createStore() { */ function markDirty() { scheduleSave(); + if (undoProvider.value) return; // shared document: undo lives in Yjs if (!historyPending.value) { redoStack.length = 0; historyTick.value++; @@ -789,10 +816,22 @@ function createStore() { historyTick.value++; } - const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value)); - const canRedo = computed(() => (historyTick.value, redoStack.length > 0)); + const undoProvider = shallowRef(null); + function setUndoProvider(p: UndoProvider | null) { + undoProvider.value = p; + resetHistory(); + } + const canUndo = computed(() => + undoProvider.value + ? undoProvider.value.canUndo.value + : (historyTick.value, undoStack.length > 0 || historyPending.value), + ); + const canRedo = computed(() => + undoProvider.value ? undoProvider.value.canRedo.value : (historyTick.value, redoStack.length > 0), + ); function undo() { + if (undoProvider.value) return undoProvider.value.undo(); commitHistoryStep(); // flush a pending (not yet coalesced) change first const prev = undoStack.pop(); if (!prev) return; @@ -806,6 +845,7 @@ function createStore() { historyTick.value++; } function redo() { + if (undoProvider.value) return undoProvider.value.redo(); clearTimeout(historyTimer); const next = redoStack.pop(); if (!next) return; @@ -820,7 +860,8 @@ function createStore() { // ---- id / name helpers ------------------------------------------ let seq = 0; - const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`; + // random part: ids must not collide between people editing the same document + const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}${Math.random().toString(36).slice(2, 6)}`; function uniqueName(base: string, taken: Set): string { const b = base.trim() || 'Element'; @@ -2233,6 +2274,7 @@ function createStore() { const t = tableById(tableId); if (!t) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.rows)); + collabHooks?.onStructure(tableId, 'row', clamped, 1); t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1); if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1); @@ -2247,6 +2289,7 @@ function createStore() { const t = tableById(tableId); if (!t || t.rows <= 1) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1)); + collabHooks?.onStructure(tableId, 'row', clamped, -1); t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1); if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1); @@ -2263,6 +2306,7 @@ function createStore() { const t = tableById(tableId); if (!t) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.cols)); + collabHooks?.onStructure(tableId, 'col', clamped, 1); t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1); t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1); @@ -2277,6 +2321,7 @@ function createStore() { const t = tableById(tableId); if (!t || t.cols <= 1) return; const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1)); + collabHooks?.onStructure(tableId, 'col', clamped, -1); t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1); if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1); t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1); @@ -2946,6 +2991,37 @@ function createStore() { // as a new edit. Persistence, unlike undo history, has no such carve-out. scheduleSave(); } + /** + * A collaborator's change: take the new content, keep what is this + * person's own view -- the open sheet, the selected object, the selection + * and each sheet's zoom. History is untouched (undo is per person, in + * Yjs) and it doesn't count as a local change. + */ + function applyExternalState(next: DocumentState) { + const zoom = new Map(doc.sheets.map((s) => [s.id, s.zoom])); + const keepSheet = next.sheets.some((s) => s.id === doc.activeSheetId); + const view = { + activeSheetId: keepSheet ? doc.activeSheetId : next.sheets[0]?.id, + activeObjectId: doc.activeObjectId, + selection: doc.selection, + }; + for (const s of next.sheets) { + if (zoom.has(s.id)) s.zoom = zoom.get(s.id); + } + applyingExternal = true; + try { + applyDocumentState({ ...next, ...view } as DocumentState); + if (doc.activeObjectId && !doc.sheets.some((s) => s.objects.some((o) => o.id === doc.activeObjectId))) { + doc.activeObjectId = null; + } + if (doc.selection && !tableById(doc.selection.tableId)) doc.selection = null; + if (doc.selection) clampSelection(doc.selection.tableId); + } finally { + applyingExternal = false; + } + cancelSave(); + } + /** Loading a different document outright (JSON import "replace", "reset to * seed") -- unlike undo/redo, this starts a fresh undo history rather * than leaving the previous document's edits undoable into the new one. */ @@ -3159,6 +3235,8 @@ function createStore() { flushSave, hasPendingSave, cancelSave, + applyExternalState, + setUndoProvider, engine: () => wb, }; diff --git a/app/frontend/store/library.ts b/app/frontend/store/library.ts index 3870401..067616a 100644 --- a/app/frontend/store/library.ts +++ b/app/frontend/store/library.ts @@ -10,16 +10,16 @@ * (`/d/#k=`) also opens a server document without an account -- * the token is kept in this browser (localStorage) and sent along. * - * Persistence: the store's debounced save lands in `persist()` and goes to - * IndexedDB or to the server. Server documents have a single-editor lock - * (until live collaboration exists): whoever holds it edits, everyone else - * sees the document read-only and follows its changes by polling. + * Local documents are saved by the store's debounced save (`persist()`). + * Server documents are edited live, together with everyone who has them + * open: a CollabSession (src/collab/session.ts) keeps the store and the + * shared Yjs document in step over ActionCable. * * Other tabs: a BroadcastChannel keeps lists current and lets a local * document open in two tabs follow the other tab's saves. */ -import { computed, reactive } from 'vue'; +import { computed, reactive, shallowRef } from 'vue'; import { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document'; import type { DocumentState } from './types'; import { DocumentLibrary, newDocId, type DocMeta } from '../io/library'; @@ -28,6 +28,7 @@ import { validateDocument } from '../io/json'; import { createBlankDocument, createSeedDocument } from '../data/seed'; import { useToasts } from '../composables/useToasts'; import { useSession } from './session'; +import { CollabSession } from '../collab/session'; /** The single-document localStorage keys used before the library existed -- * read once and moved into the library as its first document. */ @@ -37,9 +38,8 @@ const LINKS_KEY = 'numolus:links'; const CHANNEL = 'numolus:library'; const DEFAULT_TITLE = 'Unbenannt'; const APP_TITLE = 'numolus'; -const UPLOAD_DELAY = 800; // ms after the last change -const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s) -const POLL = 10_000; // read-only: look for someone else's changes +const ANON_COLORS = ['#e67e22', '#2980b9', '#27ae60', '#8e44ad', '#c0392b', '#16a085']; +const ANON_NAMES = ['Fuchs', 'Dachs', 'Luchs', 'Otter', 'Eule', 'Biber', 'Reh', 'Igel']; export type DocSource = 'local' | 'server'; export interface DocRef { @@ -61,8 +61,8 @@ type Msg = | { type: 'here'; id: string }; function metaOf(d: ServerDoc): ServerDocMeta { - const { id, title, version, role, owner_name, updated_at, updated_by_name } = d; - return { id, title, version, role, owner_name, updated_at, updated_by_name }; + const { id, title, role, owner_name, updated_at, updated_by_name } = d; + return { id, title, role, owner_name, updated_at, updated_by_name }; } function readLinks(): LinkDoc[] { @@ -80,6 +80,13 @@ function writeLinks(links: LinkDoc[]) { } } +/** Name + colour shown to the others: the account, or a random animal. */ +function presenceIdentity(user: { name: string; color: string } | null) { + if (user) return { name: user.name, color: user.color }; + const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]; + return { name: `Anonym (${pick(ANON_NAMES)})`, color: pick(ANON_COLORS) }; +} + function createLibrary() { const store = useDocument(); const toasts = useToasts(); @@ -96,13 +103,12 @@ function createLibrary() { /** documents opened via share link without an account */ linkDocs: readLinks(), current: null as DocRef | null, - /** the open server document's metadata (title, role, version, ...) */ + /** the open server document's metadata (title, role, ...) */ server: null as ServerDocMeta | null, - /** someone else holds the edit lock on the open server document */ - lockedBy: null as string | null, - /** server saves waiting or failing */ - syncState: 'idle' as 'idle' | 'saving' | 'offline', }); + /** the live session of the open server document */ + const collab = shallowRef(null); + const identity = presenceIdentity(session.state.user); const currentTitle = computed(() => { const c = state.current; @@ -114,11 +120,14 @@ function createLibrary() { const currentRole = computed(() => state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner', ); - const canEdit = computed(() => currentRole.value !== 'viewer' && !state.lockedBy); + const canEdit = computed(() => currentRole.value !== 'viewer'); + /** others on the open document right now */ + const peers = computed(() => collab.value?.peers ?? []); + const online = computed(() => collab.value?.online.value ?? true); let lib: DocumentLibrary | null = null; let channel: BroadcastChannel | null = null; - /** content fingerprint of what's saved for the open document */ + /** content fingerprint of what's saved for the open local document */ let savedContent = ''; /** `updatedAt` of the open local document as this tab last wrote or read it */ let knownUpdatedAt = 0; @@ -126,15 +135,6 @@ function createLibrary() { let writeQueue: Promise = Promise.resolve(); const warnedShared = new Set(); - // server document state - let uploadTimer: ReturnType | undefined; - let uploading: Promise | null = null; - let pendingJson: string | null = null; - let pendingFp = ''; - let holdsLock = false; - let heartbeatTimer: ReturnType | undefined; - let pollTimer: ReturnType | undefined; - const post = (m: Msg) => channel?.postMessage(m); const enqueue = (job: () => Promise) => { writeQueue = writeQueue.then(job).catch((err) => { @@ -188,19 +188,15 @@ function createLibrary() { function applyReadOnly() { store.ui.readOnly = - state.current?.source !== 'server' - ? null - : currentRole.value === 'viewer' - ? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.' - : state.lockedBy - ? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.` - : null; + state.current?.source === 'server' && currentRole.value === 'viewer' + ? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.' + : null; } /** Put `next` into the store as the open document. */ function show(ref: DocRef, next: DocumentState, keepView = false) { if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) { - // someone else's save: take the content, keep this tab's cursor/sheet + // another tab's save: take the content, keep this tab's cursor/sheet next.activeSheetId = store.doc.activeSheetId; next.activeObjectId = store.doc.activeObjectId; next.selection = store.doc.selection; @@ -213,12 +209,10 @@ function createLibrary() { updateWindowTitle(); } - // ---- persistence ----------------------------------------------------- + // ---- persistence (local documents) --------------------------------------- function persist(doc: DocumentState) { const c = state.current; - if (!c) return; - if (c.source === 'server') return persistServer(doc); - if (!lib) return; + if (!c || c.source !== 'local' || !lib) return; // server documents: CollabSession const id = c.id; const json = JSON.stringify(doc); const fp = contentFingerprint(doc); @@ -246,181 +240,14 @@ function createLibrary() { }); } - function persistServer(doc: DocumentState) { - if (!canEdit.value) return; - const fp = contentFingerprint(doc); - if (fp === savedContent && pendingJson === null) return; // cursor move only - pendingJson = JSON.stringify(doc); - pendingFp = fp; - clearTimeout(uploadTimer); - uploadTimer = setTimeout(() => void upload(), UPLOAD_DELAY); - } - - /** Send the pending change of the open server document (one at a time). */ - async function upload(): Promise { - clearTimeout(uploadTimer); - uploadTimer = undefined; - if (uploading) { - await uploading; - if (pendingJson !== null) return upload(); - return; - } - const c = state.current; - const meta = state.server; - if (!c || c.source !== 'server' || !meta || pendingJson === null) return; - const json = pendingJson; - const fp = pendingFp; - pendingJson = null; - state.syncState = 'saving'; - uploading = (async () => { - try { - const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${c.id}`, { - body: { content: json, base_version: meta.version }, - shareToken: shareToken(c.id), - }); - if (isCurrent(c)) { - state.server = { ...meta, ...r.document, role: meta.role }; - savedContent = fp; - } - state.syncState = 'idle'; - } catch (err) { - await uploadFailed(c, err, json, fp); - } finally { - uploading = null; - } - })(); - await uploading; - if (pendingJson !== null && isCurrent(c)) return upload(); - } - - async function uploadFailed(c: DocRef, err: unknown, json: string, fp: string) { - if (!(err instanceof ApiError) || err.status === 0 || err.status >= 500) { - // offline / server trouble: keep it and try again - state.syncState = 'offline'; - if (pendingJson === null) { - pendingJson = json; - pendingFp = fp; - } - clearTimeout(uploadTimer); - uploadTimer = setTimeout(() => void upload(), 5000); - return; - } - state.syncState = 'idle'; - // the server's version wins; edits made meanwhile would be based on the - // rejected state, so they go too - pendingJson = null; - clearTimeout(uploadTimer); - if (err.status === 423) { - holdsLock = false; - state.lockedBy = (err.body.holder_name as string) || 'Jemand anderes'; - toasts.error(`${state.lockedBy} bearbeitet dieses Dokument inzwischen – deine letzte Änderung wurde nicht gespeichert.`); - } else if (err.status === 409) { - toasts.error('Das Dokument wurde inzwischen anderswo geändert – deine letzte Änderung wurde nicht gespeichert.'); - } else { - toasts.error(`Speichern fehlgeschlagen: ${err.message}`); - } - if (isCurrent(c)) await reloadServer(c.id, true); - startServerTimers(); - } - - /** Before leaving the open server document: send what's pending. */ - async function flushServer() { - store.flushSave(); - if (pendingJson !== null || uploading) await upload(); - } - - // ---- edit lock + polling --------------------------------------------- - function stopServerTimers() { - clearInterval(heartbeatTimer); - clearInterval(pollTimer); - heartbeatTimer = pollTimer = undefined; - } - - function startServerTimers() { - stopServerTimers(); - if (state.current?.source !== 'server') return; - if (holdsLock) heartbeatTimer = setInterval(() => void heartbeat(), HEARTBEAT); - else pollTimer = setInterval(() => void poll(), POLL); - } - - /** Try to take the edit lock of the open server document. */ - async function takeLock(id: string): Promise { - if (currentRole.value === 'viewer') return false; - try { - const r = await api<{ lock: { mine: boolean; holder_name: string | null } }>('POST', `/documents/${id}/lock`, { - shareToken: shareToken(id), - }); - holdsLock = r.lock.mine; - state.lockedBy = r.lock.mine ? null : r.lock.holder_name || 'Jemand anderes'; - } catch { - holdsLock = false; - } - applyReadOnly(); - return holdsLock; - } - - async function heartbeat() { - const c = state.current; - if (c?.source !== 'server') return; - const had = holdsLock; - await takeLock(c.id); - if (had && !holdsLock) { - toasts.error(`${state.lockedBy ?? 'Jemand anderes'} bearbeitet dieses Dokument jetzt.`); - startServerTimers(); - } - } - - async function poll() { - const c = state.current; - if (c?.source !== 'server' || document.visibilityState === 'hidden') return; - if (currentRole.value !== 'viewer' && (await takeLock(c.id))) { - await reloadServer(c.id, true); - toasts.info('Du kannst das Dokument jetzt bearbeiten.'); - startServerTimers(); - return; - } - await reloadServer(c.id, true); - } - - function releaseLock(id: string) { - if (!holdsLock) return; - holdsLock = false; - void api('DELETE', `/documents/${id}/lock`, { shareToken: shareToken(id), keepalive: true }).catch(() => {}); - } - - /** Fetch the server document again; with `keepView` only when it changed. */ - async function reloadServer(id: string, keepView: boolean) { - const known = keepView ? state.server?.version : undefined; - try { - const q = known ? `?known_version=${known}` : ''; - const r = await api<{ document: ServerDoc; unchanged?: boolean }>('GET', `/documents/${id}${q}`, { - shareToken: shareToken(id), - }); - if (!isCurrent({ source: 'server', id })) return; - const { content } = r.document; - state.server = metaOf(r.document); - if (!r.unchanged && content !== undefined) { - show({ source: 'server', id }, validateDocument(JSON.parse(content)), keepView); - } - updateWindowTitle(); - applyReadOnly(); - } catch (err) { - if (err instanceof ApiError && err.status === 404) { - toasts.error('Kein Zugriff mehr auf dieses Dokument.'); - await openFallback(); - } - } - } - // ---- leaving / opening ----------------------------------------------- async function leaveCurrent() { const c = state.current; if (!c) return; if (c.source === 'server') { - await flushServer(); - stopServerTimers(); - releaseLock(c.id); - state.lockedBy = null; + collab.value?.destroy(); + collab.value = null; + store.setUndoProvider(null); state.server = null; } else { store.flushSave(); @@ -451,9 +278,10 @@ function createLibrary() { async function openServer(id: string, opts: { push?: boolean } = {}): Promise { if (isCurrent({ source: 'server', id })) return true; - let r: { document: ServerDoc }; + let meta: ServerDocMeta; try { - r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) }); + const r = await api<{ document: ServerDoc }>('GET', `/documents/${id}?content=0`, { shareToken: shareToken(id) }); + meta = metaOf(r.document); } catch (err) { toasts.error( err instanceof ApiError && err.status === 404 @@ -462,20 +290,47 @@ function createLibrary() { ); return false; } - const { content } = r.document; - const meta = metaOf(r.document); + const ref: DocRef = { source: 'server', id }; + const s = new CollabSession(id, shareToken(id), identity, { + onTitle: (title) => { + if (!isCurrent(ref) || !state.server) return; + state.server = { ...state.server, title }; + updateWindowTitle(); + void refreshServerList(); + }, + onRole: (role) => { + if (!isCurrent(ref) || !state.server || state.server.role === role) return; + state.server = { ...state.server, role }; + applyReadOnly(); + toasts.info(role === 'viewer' ? 'Du kannst dieses Dokument jetzt nur noch ansehen.' : 'Du kannst dieses Dokument jetzt bearbeiten.'); + }, + onRevoked: () => { + if (!isCurrent(ref)) return; + toasts.error('Du hast keinen Zugriff mehr auf dieses Dokument.'); + state.linkDocs = state.linkDocs.filter((l) => l.id !== id); + writeLinks(state.linkDocs); + void refreshServerList().then(() => { + state.current = null; + collab.value?.destroy(); + collab.value = null; + store.setUndoProvider(null); + return openFallback(); + }); + }, + }); let next: DocumentState; try { - next = validateDocument(JSON.parse(content ?? '')); + next = await s.start(); } catch (err) { - toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`); + s.destroy(); + toasts.error(`Dokument konnte nicht geöffnet werden: ${(err as Error).message}`); return false; } await leaveCurrent(); - state.server = meta; - state.lockedBy = null; - holdsLock = false; - show({ source: 'server', id }, next); + state.server = { ...meta, role: s.role }; + collab.value = s; + show(ref, next); + store.setUndoProvider(s.attach()); // remember the title for link-only documents const link = state.linkDocs.find((l) => l.id === id); if (link && (link.title !== meta.title || link.role !== meta.role)) { @@ -483,9 +338,7 @@ function createLibrary() { writeLinks(state.linkDocs); } applyReadOnly(); - if (meta.role !== 'viewer') await takeLock(id); - startServerTimers(); - setUrl({ source: 'server', id }, opts.push ?? true); + setUrl(ref, opts.push ?? true); return true; } @@ -555,11 +408,10 @@ function createLibrary() { async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> { if (isCurrent(ref)) { - if (ref.source === 'server') await flushServer(); - else { - store.flushSave(); - await writeQueue; - } + // the open document: straight from the store, always current + store.flushSave(); + await writeQueue; + return { title: currentTitle.value, json: JSON.stringify(store.doc) }; } if (ref.source === 'server') { const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) }); @@ -637,7 +489,7 @@ function createLibrary() { * this browser once it's safely on the server; an open one is reopened * from there. Returns the server id of each uploaded document. */ - async function upload_(localIds: string[]): Promise> { + async function upload(localIds: string[]): Promise> { const done = new Map(); if (!lib || !session.signedIn.value) return done; for (const id of localIds) { @@ -812,16 +664,17 @@ function createLibrary() { // don't lose the last moments of typing when the tab goes away window.addEventListener('pagehide', () => { store.flushSave(); - if (state.current?.source === 'server') { - if (pendingJson !== null) void upload(); - releaseLock(state.current.id); - } + collab.value?.destroy(); }); - window.addEventListener('beforeunload', (e) => { - if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes + // back from the browser's page cache: the live session is gone + window.addEventListener('pageshow', (e) => { + if (e.persisted && state.current?.source === 'server') location.reload(); }); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') store.flushSave(); + if (document.visibilityState === 'hidden') { + store.flushSave(); + collab.value?.flushLocal(); + } }); state.ready = true; } @@ -831,6 +684,8 @@ function createLibrary() { currentTitle, currentRole, canEdit, + peers, + online, init, open, create, @@ -839,7 +694,7 @@ function createLibrary() { rename, duplicate, remove, - upload: upload_, + upload, refreshServerList, }; } diff --git a/app/models/document.rb b/app/models/document.rb index 8e1ac01..18df99e 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -1,6 +1,5 @@ class Document < ApplicationRecord ROLES = %w[viewer editor owner].freeze - LOCK_TTL = 90.seconds MAX_CONTENT_BYTES = 20.megabytes belongs_to :owner, class_name: "User" @@ -8,6 +7,7 @@ class Document < ApplicationRecord has_many :document_memberships, dependent: :delete_all has_many :members, through: :document_memberships, source: :user has_many :share_links, dependent: :delete_all + has_many :updates, class_name: "DocumentUpdate", dependent: :delete_all validates :title, presence: true, length: { maximum: 200 } validates :content, presence: true @@ -36,39 +36,28 @@ class Document < ApplicationRecord role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s) end - # ---- edit lock -------------------------------------------------------- - def locked_by_other?(holder) - lock_holder.present? && lock_holder != holder && lock_expires_at&.future? - end - - # Take or refresh the lock for `holder`. Returns true when `holder` has it. - def acquire_lock!(holder, name) - with_lock do - return false if locked_by_other?(holder) - update_columns(lock_holder: holder, lock_holder_name: name, lock_expires_at: LOCK_TTL.from_now) - true - end - end - - def release_lock!(holder) - with_lock do - update_columns(lock_holder: nil, lock_holder_name: nil, lock_expires_at: nil) if lock_holder == holder - end - end - - def lock_info(holder) - { locked_by_other: locked_by_other?(holder), holder_name: (lock_holder_name if locked_by_other?(holder)) } - end - def meta_json(role:) { - id:, title:, version:, role:, + id:, title:, role:, owner_name: owner.name, updated_at: content_updated_at.iso8601(3), updated_by_name: updated_by&.name } end + # Tell everyone connected to this document (see DocumentChannel). + def broadcast(message) = DocumentChannel.broadcast_to(self, message) + + # Access changed: every connection checks its role again. + def revalidate_access! = broadcast({ type: "revalidate" }) + + def self.valid_snapshot?(json) + parsed = JSON.parse(json) + parsed.is_a?(Hash) && parsed["sheets"].is_a?(Array) + rescue JSON::ParserError, TypeError + false + end + private def content_is_document_json diff --git a/app/models/document_update.rb b/app/models/document_update.rb new file mode 100644 index 0000000..c558506 --- /dev/null +++ b/app/models/document_update.rb @@ -0,0 +1,13 @@ +# One Yjs update of a document -- an edit by someone, or a compacted state +# that replaces many earlier updates. Order doesn't matter to Yjs. +class DocumentUpdate < ApplicationRecord + MAX_BYTES = 10.megabytes + + belongs_to :document + belongs_to :user, optional: true + + validates :data, presence: true + validate { errors.add(:data, "ist zu groß") if data && data.bytesize > MAX_BYTES } + + def as_message = { id:, data: Base64.strict_encode64(data) } +end diff --git a/config/routes.rb b/config/routes.rb index f95a40c..90f7349 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,7 +14,6 @@ Rails.application.routes.draw do patch "me" => "me#update" resources :documents, only: %i[index create show update destroy] do - resource :lock, only: %i[create destroy], controller: "document_locks" resource :join, only: :create get "share" => "shares#show" post "share/links" => "shares#create_link" diff --git a/db/migrate/20260929090000_live_collaboration.rb b/db/migrate/20260929090000_live_collaboration.rb new file mode 100644 index 0000000..01ec327 --- /dev/null +++ b/db/migrate/20260929090000_live_collaboration.rb @@ -0,0 +1,18 @@ +# Live collaboration (Yjs): a document's content is the sum of its Yjs +# updates. `documents.content` stays as a JSON snapshot (duplicate, export, +# the first conversion to Yjs). The edit lock and version counter go. +class LiveCollaboration < ActiveRecord::Migration[8.1] + def change + create_table :document_updates do |t| + t.references :document, type: :uuid, null: false, foreign_key: { on_delete: :cascade } + t.references :user, foreign_key: { on_delete: :nullify } + t.binary :data, null: false + t.datetime :created_at, null: false + end + + remove_column :documents, :version, :integer, null: false, default: 1 + remove_column :documents, :lock_holder, :string + remove_column :documents, :lock_holder_name, :string + remove_column :documents, :lock_expires_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index ad25620..4b46757 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_28_120300) do +ActiveRecord::Schema[8.1].define(version: 2026_09_29_090000) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -31,16 +31,21 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_28_120300) do t.index ["user_id"], name: "index_document_memberships_on_user_id" end + create_table "document_updates", force: :cascade do |t| + t.uuid "document_id", null: false + t.bigint "user_id" + t.binary "data", null: false + t.datetime "created_at", null: false + t.index ["document_id"], name: "index_document_updates_on_document_id" + t.index ["user_id"], name: "index_document_updates_on_user_id" + end + create_table "documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.bigint "owner_id", null: false t.string "title", null: false t.text "content", null: false - t.integer "version", default: 1, null: false t.bigint "updated_by_id" t.datetime "content_updated_at", null: false - t.string "lock_holder" - t.string "lock_holder_name" - t.datetime "lock_expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["owner_id"], name: "index_documents_on_owner_id" @@ -273,6 +278,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_28_120300) do add_foreign_key "document_memberships", "documents", on_delete: :cascade add_foreign_key "document_memberships", "share_links", on_delete: :cascade add_foreign_key "document_memberships", "users", on_delete: :cascade + add_foreign_key "document_updates", "documents", on_delete: :cascade + add_foreign_key "document_updates", "users", on_delete: :nullify add_foreign_key "documents", "users", column: "owner_id", on_delete: :cascade add_foreign_key "documents", "users", column: "updated_by_id", on_delete: :nullify add_foreign_key "magic_links", "documents", on_delete: :cascade diff --git a/docs/konzept-teilen-und-zusammenarbeit.md b/docs/konzept-teilen-und-zusammenarbeit.md index 57f1b76..5c75a5d 100644 --- a/docs/konzept-teilen-und-zusammenarbeit.md +++ b/docs/konzept-teilen-und-zusammenarbeit.md @@ -621,17 +621,19 @@ wie jede andere Änderung verteilt. Viewer können keine Makros starten. --- -## Umsetzungsstand (Branch `with-server`, 2026-09-28) +## Umsetzungsstand (Branch `with-server`, 2026-09-29) -Umgesetzt sind Phasen 1–3 in angepasster Form; Live-Sync (Phase 4) fehlt noch. +Umgesetzt sind Phasen 1–4 in angepasster Form, einschließlich Live-Bearbeitung mit Yjs. | Thema | Umsetzung | Abweichung vom Konzept | | --- | --- | --- | | Anonym | Dokumente bleiben lokal in IndexedDB (`/?doc=`) | kein Gast-Konto auf dem Server | | Konto | Magic Link + 6-stelliger Code, Konto entsteht bei der ersten Anmeldung | – | | Übernahme | nach der ersten Anmeldung Rückfrage, welche lokalen Dokumente hochgeladen werden; hochgeladene werden lokal entfernt | statt automatischer Übernahme | -| Server-Dokumente | JSON-Inhalt mit Versionsnummer, `/d/`; mit Konto werden neue Dokumente auf dem Server angelegt | Inhalt noch nicht als Yjs-Stand | -| Gleichzeitiges Bearbeiten | Sperre pro Browser-Tab (90 s ohne Heartbeat), andere sehen „Nur Lesen“ und folgen per Abfrage alle 10 s | Übergang bis Phase 4 | +| Server-Dokumente | Inhalt = Yjs-Updates (`document_updates`), `documents.content` als JSON-Snapshot (Export, Duplizieren, erste Umwandlung); `/d/` | – | +| Gleichzeitiges Bearbeiten | live über ActionCable (`DocumentChannel`), ~150 ms; feste Zeilen-/Spalten-IDs; Undo pro Person (`Y.UndoManager`); Auswahl der anderen als farbiger Rahmen | Store bleibt positionsbasiert, eine Binding-Schicht übersetzt (`app/frontend/collab/`) | +| Erste Umwandlung | JSON → Yjs deterministisch im Browser (feste Client-ID, IDs `r0…`/`c0…`), gleichzeitige Umwandlungen verschmelzen | statt serverseitig | +| Zusammenfassen | ab 200 Updates schickt ein Editor den Gesamtstand, der Server ersetzt die gelisteten Updates | clientseitig, kein `y-rb` nötig | | Teilen per Link | ohne Konto nutzbar; Token im Browser gespeichert (localStorage), nach Anmeldung dem Konto zugeordnet | Link-Token im Klartext gespeichert, damit der Besitzer den Link erneut kopieren kann | | Einladungen | Mail mit Link (7 Tage gültig), meldet gleichzeitig an | – | | Sessions | signierte Session-ID im Cookie (Rails-8-Standard) | statt Token-Digest | diff --git a/package-lock.json b/package-lock.json index f67ddf4..202cc02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,17 @@ "name": "numolus", "version": "1.0.0", "dependencies": { + "@rails/actioncable": "^8.1.400", "chart.js": "^4.4.6", "fflate": "^0.8.3", + "lib0": "^0.2.119", "vue": "^3.5.13", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "y-protocols": "^1.0.7", + "yjs": "^13.6.33" }, "devDependencies": { + "@types/rails__actioncable": "^8.0.3", "@vitejs/plugin-vue": "^5.2.0", "@vue/eslint-config-prettier": "^10.1.0", "@vue/eslint-config-typescript": "^14.1.3", @@ -934,6 +939,12 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@rails/actioncable": { + "version": "8.1.400", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.400.tgz", + "integrity": "sha512-Qffc3/I9xcpeaal12hmi9EPRV3hz9FvakUHBR7kL0jpZviWwO9dyaG8F1ltcjkHIdFxQ5QqzRyequAWHMq17xQ==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", @@ -1298,6 +1309,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/rails__actioncable": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@types/rails__actioncable/-/rails__actioncable-8.0.3.tgz", + "integrity": "sha512-y46MOTYorVQVwlHUyaZYbrh3nIkXsRYNuPna32lb3RngLVBlndNbIPvAUywFfhivftNhYg+vW5sZKWYCVIX2lA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -3349,6 +3367,16 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -3468,6 +3496,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -5146,6 +5195,43 @@ "dev": true, "license": "MIT" }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5bca01a..b7f6eb6 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,17 @@ "format": "prettier --write \"app/frontend/**/*.{ts,vue,css}\"" }, "dependencies": { + "@rails/actioncable": "^8.1.400", "chart.js": "^4.4.6", "fflate": "^0.8.3", + "lib0": "^0.2.119", "vue": "^3.5.13", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "y-protocols": "^1.0.7", + "yjs": "^13.6.33" }, "devDependencies": { + "@types/rails__actioncable": "^8.0.3", "@vitejs/plugin-vue": "^5.2.0", "@vue/eslint-config-prettier": "^10.1.0", "@vue/eslint-config-typescript": "^14.1.3", diff --git a/test/channels/document_channel_test.rb b/test/channels/document_channel_test.rb new file mode 100644 index 0000000..2cbc0f1 --- /dev/null +++ b/test/channels/document_channel_test.rb @@ -0,0 +1,94 @@ +require "test_helper" + +class DocumentChannelTest < ActionCable::Channel::TestCase + setup do + @anna = create_user("anna@example.com") + @bob = create_user("bob@example.com") + @doc = create_document(@anna) + end + + def b64(s) = Base64.strict_encode64(s) + def last = transmissions.last + + test "rejects without access" do + stub_connection current_user: @bob + subscribe id: @doc.id + assert subscription.rejected? + end + + test "init sends the JSON while there are no updates yet, then the updates" do + stub_connection current_user: @anna + subscribe id: @doc.id, key: "a" + assert subscription.confirmed? + assert_equal "owner", last["role"].to_s + assert_equal DOC_JSON, last["content"] + assert_empty last["updates"] + + @doc.updates.create!(data: "\x01\x02") + subscribe id: @doc.id, key: "a" + assert_nil last["content"] + assert_equal [ b64("\x01\x02") ], last["updates"].map { _1["data"] } + end + + test "an editor's update is stored and passed on to everyone" do + stub_connection current_user: @anna + subscribe id: @doc.id, key: "a" + assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do + perform :update, data: b64("abc") + end + assert_equal [ "abc" ], @doc.updates.pluck(:data) + end + + test "a viewer (link) can't change anything but may send awareness" do + link = @doc.share_links.create!(role: :viewer, created_by: @anna) + stub_connection current_user: nil + subscribe id: @doc.id, token: link.token, key: "v" + assert subscription.confirmed? + perform :update, data: b64("x") + perform :compact, state: b64("x"), ids: [], snapshot: DOC_JSON + assert_equal 0, @doc.updates.count + assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do + perform :awareness, data: b64("cursor") + end + end + + test "compact replaces the listed updates by one state and stores the snapshot" do + a = @doc.updates.create!(data: "1") + b = @doc.updates.create!(data: "2") + keep = @doc.updates.create!(data: "3") + stub_connection current_user: @anna + subscribe id: @doc.id, key: "a" + snapshot = { version: 2, sheets: [ { id: "s9", name: "Neu", objects: [] } ] }.to_json + perform :compact, state: b64("12"), ids: [ a.id, b.id ], snapshot: snapshot + assert_equal %w[3 12], @doc.updates.order(:id).pluck(:data) + assert keep.reload + assert_equal snapshot, @doc.reload.content + end + + test "invalid snapshots are ignored" do + stub_connection current_user: @anna + subscribe id: @doc.id, key: "a" + perform :snapshot, content: "[1]" + assert_equal DOC_JSON, @doc.reload.content + end + + test "losing access mid-session" do + DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite) + stub_connection current_user: @bob + subscribe id: @doc.id, key: "b" + @doc.document_memberships.delete_all + subscription.send(:revalidate) + assert_equal "revoked", last["type"] + perform :update, data: b64("late") + assert_equal 0, @doc.updates.count + end + + test "role change is announced" do + DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite) + stub_connection current_user: @bob + subscribe id: @doc.id, key: "b" + @doc.document_memberships.update_all(role: DocumentMembership.roles[:viewer]) + subscription.send(:revalidate) + assert_equal({ "type" => "role", "role" => "viewer" }, last.slice("type", "role").transform_values(&:to_s)) + end +end diff --git a/test/integration/documents_api_test.rb b/test/integration/documents_api_test.rb index 67ef6ac..aa0af29 100644 --- a/test/integration/documents_api_test.rb +++ b/test/integration/documents_api_test.rb @@ -1,14 +1,16 @@ require "test_helper" class DocumentsApiTest < ActionDispatch::IntegrationTest + include ActionCable::TestHelper + setup do @anna = create_user("anna@example.com") @bob = create_user("bob@example.com") @doc = create_document(@anna) end - def put_content(doc, base_version, headers: {}) - patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json + def rename(doc, title, headers: {}) + patch "/api/documents/#{doc.id}", params: { title: }, headers:, as: :json end test "listing and creating need an account" do @@ -38,39 +40,6 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest assert_response :not_found end - test "save with version check; stale base_version is a conflict" do - sign_in @anna - put_content(@doc, 1) - assert_response :success - assert_equal 2, json.dig("document", "version") - - put_content(@doc, 1) - assert_response :conflict - assert_equal 2, json["version"] - end - - test "known_version returns metadata only when unchanged" do - sign_in @anna - get "/api/documents/#{@doc.id}", params: { known_version: 1 } - assert json["unchanged"] - assert_nil json.dig("document", "content") - end - - test "edit lock blocks saves from another tab" do - sign_in @anna - post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" } - assert json.dig("lock", "mine") - - post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" } - assert_not json.dig("lock", "mine") - put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" }) - assert_response :locked - - delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" } - put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" }) - assert_response :success - end - test "share link without account: read with token; viewer cannot write" do link = @doc.share_links.create!(role: :viewer, created_by: @anna) headers = { "X-Share-Token" => link.token } @@ -79,7 +48,7 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest assert_equal "viewer", json.dig("document", "role") get "/api/documents/#{@doc.id}", headers: headers assert_response :success - put_content(@doc, 1, headers:) + rename(@doc, "Neu", headers:) assert_response :forbidden get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" } @@ -92,10 +61,18 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json get "/api/documents" assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] } - put_content(@doc, 1) + rename(@doc, "Von Bob") assert_response :success end + test "renaming tells everyone connected" do + sign_in @anna + assert_broadcast_on(DocumentChannel.broadcasting_for(@doc), { type: "meta", title: "Neu" }) do + rename(@doc, "Neu") + end + assert_equal "Neu", @doc.reload.title + end + test "owner deletes; a member only leaves" do DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite) sign_in @bob diff --git a/test/models/document_test.rb b/test/models/document_test.rb index c403ab7..db6a8bc 100644 --- a/test/models/document_test.rb +++ b/test/models/document_test.rb @@ -49,15 +49,4 @@ class DocumentTest < ActiveSupport::TestCase DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link) assert_equal :editor, @doc.role_for(user: bob) end - - test "edit lock: one holder at a time, expires without heartbeat" do - assert @doc.acquire_lock!("1:tab-a", "Anna") - assert @doc.acquire_lock!("1:tab-a", "Anna"), "refreshing own lock" - assert_not @doc.acquire_lock!("2:tab-b", "Bob") - assert @doc.reload.locked_by_other?("2:tab-b") - - travel Document::LOCK_TTL + 1.second do - assert @doc.acquire_lock!("2:tab-b", "Bob") - end - end end