Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of taking turns behind an edit lock. - The document's content is a sequence of Yjs updates (document_updates); DocumentChannel stores and relays them immediately, checks access on subscribe and again whenever links or memberships change. - app/frontend/collab/: the store stays position-based; a binding maps it to a Yjs form with stable row/column ids, so a row inserted by one person doesn't misplace what another is typing. Remote changes are applied only after pending local ones went into Yjs. - Per-person undo (Y.UndoManager), presence via awareness: the others' selections as coloured boxes with names, avatars in the toolbar. - Existing JSON documents convert on first open, deterministically, so two people opening one at once end up with the same Yjs state. - Editors compact the stored updates after 200 and keep a JSON snapshot current for export and duplicating. Edit lock and polling are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
[`app/frontend/`](app/frontend/) (eingebunden über `vite_rails`). Ohne Konto
|
||||||
bleibt alles **im Browser** (IndexedDB). Mit Konto (Anmeldung per Magic Link,
|
bleibt alles **im Browser** (IndexedDB). Mit Konto (Anmeldung per Magic Link,
|
||||||
kein Passwort) liegen Dokumente auf dem Server und lassen sich teilen – per
|
kein Passwort) liegen Dokumente auf dem Server und lassen sich teilen – per
|
||||||
Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Solange
|
Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Alle mit
|
||||||
es keine Live-Zusammenarbeit gibt, bearbeitet immer nur ein Browser-Tab ein
|
Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen
|
||||||
Server-Dokument; alle anderen sehen es schreibgeschützt und folgen den
|
kommen über ActionCable sofort bei den anderen an (Yjs, siehe
|
||||||
Änderungen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md).
|
[`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
|
Mails landen in der Entwicklung nicht im Postfach, sondern unter
|
||||||
<http://localhost:3000/letter_opener>. Produktion braucht `SMTP_ADDRESS`,
|
<http://localhost:3000/letter_opener>. Produktion braucht `SMTP_ADDRESS`,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
# The WebSocket knows the signed-in user from the same session cookie as
|
||||||
|
# the rest of the app. Anonymous connections are allowed: a share link
|
||||||
|
# token (a subscription parameter) can grant access on its own.
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
identified_by :current_user
|
||||||
|
|
||||||
|
def connect
|
||||||
|
self.current_user = Session.find_by(id: cookies.signed[:session_id])&.user if cookies.signed[:session_id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Live collaboration on one document (Yjs). Every edit is a Yjs update: the
|
||||||
|
# server stores it and passes it on to everyone connected; it never needs
|
||||||
|
# to understand what's inside. Binary data travels Base64-encoded.
|
||||||
|
#
|
||||||
|
# From the browser (perform):
|
||||||
|
# update { data } an edit (editors only)
|
||||||
|
# awareness { data } cursor/selection/name, not stored
|
||||||
|
# compact { state, ids, snapshot } replace the listed updates by one
|
||||||
|
# state; snapshot = JSON of the document
|
||||||
|
# snapshot { content } JSON of the document, for export etc.
|
||||||
|
#
|
||||||
|
# To the browser:
|
||||||
|
# init { role, title, updates: [{ id, data }], content? }
|
||||||
|
# content (JSON) only while no update exists yet: the browser
|
||||||
|
# then builds the Yjs form and sends it as the first update
|
||||||
|
# update { id, data, from } from = the sending subscription
|
||||||
|
# awareness { data, from }
|
||||||
|
# compacted { removed: [ids], id }
|
||||||
|
# meta { title }
|
||||||
|
# role { role } access changed
|
||||||
|
# revoked access gone (or document deleted)
|
||||||
|
class DocumentChannel < ApplicationCable::Channel
|
||||||
|
def subscribed
|
||||||
|
@document = Document.find_by(id: params[:id])
|
||||||
|
@token = params[:token].presence
|
||||||
|
@key = params[:key].to_s[0, 64]
|
||||||
|
@role = @document&.role_for(user: current_user, token: @token)
|
||||||
|
return reject unless @role
|
||||||
|
|
||||||
|
stream_for @document, coder: ActiveSupport::JSON do |message|
|
||||||
|
relay(message)
|
||||||
|
end
|
||||||
|
transmit init_message
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(data)
|
||||||
|
return unless editor?
|
||||||
|
bytes = decode(data["data"]) or return
|
||||||
|
row = @document.updates.create!(data: bytes, user: current_user)
|
||||||
|
@document.update_columns(content_updated_at: Time.current, updated_by_id: current_user&.id)
|
||||||
|
broadcast({ type: "update", id: row.id, data: data["data"], from: @key })
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
transmit({ type: "error", error: "too_large" })
|
||||||
|
end
|
||||||
|
|
||||||
|
def awareness(data)
|
||||||
|
return unless data["data"].is_a?(String) && data["data"].bytesize < 64.kilobytes
|
||||||
|
broadcast({ type: "awareness", data: data["data"], from: @key })
|
||||||
|
end
|
||||||
|
|
||||||
|
def compact(data)
|
||||||
|
return unless editor?
|
||||||
|
state = decode(data["state"]) or return
|
||||||
|
ids = Array(data["ids"]).map(&:to_i)
|
||||||
|
row = nil
|
||||||
|
@document.with_lock do
|
||||||
|
@document.updates.where(id: ids).delete_all
|
||||||
|
row = @document.updates.create!(data: state, user: current_user)
|
||||||
|
store_snapshot(data["snapshot"])
|
||||||
|
end
|
||||||
|
broadcast({ type: "compacted", removed: ids, id: row.id })
|
||||||
|
end
|
||||||
|
|
||||||
|
def snapshot(data)
|
||||||
|
return unless editor?
|
||||||
|
store_snapshot(data["content"])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def editor? = Document.role_at_least?(@role, :editor)
|
||||||
|
|
||||||
|
def broadcast(message) = DocumentChannel.broadcast_to(@document, message)
|
||||||
|
|
||||||
|
def relay(message)
|
||||||
|
case message["type"]
|
||||||
|
when "revalidate" then revalidate
|
||||||
|
when "awareness" then transmit(message) unless message["from"] == @key
|
||||||
|
else transmit(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Membership or link changed: is this connection still allowed in?
|
||||||
|
def revalidate
|
||||||
|
role = Document.find_by(id: @document.id)&.role_for(user: current_user, token: @token)
|
||||||
|
if role.nil?
|
||||||
|
@role = nil
|
||||||
|
transmit({ type: "revoked" })
|
||||||
|
stop_all_streams
|
||||||
|
elsif role != @role
|
||||||
|
@role = role
|
||||||
|
transmit({ type: "role", role: })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def init_message
|
||||||
|
updates = @document.updates.order(:id).map(&:as_message)
|
||||||
|
message = { type: "init", role: @role, title: @document.title, updates: }
|
||||||
|
message[:content] = @document.content if updates.empty?
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(b64)
|
||||||
|
return unless b64.is_a?(String)
|
||||||
|
Base64.strict_decode64(b64)
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def store_snapshot(json)
|
||||||
|
return unless json.is_a?(String) && json.bytesize <= Document::MAX_CONTENT_BYTES && Document.valid_snapshot?(json)
|
||||||
|
@document.update_columns(content: json)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,12 +17,5 @@ module Api
|
|||||||
|
|
||||||
# a share link token, for visitors (with or without account) who got the link
|
# a share link token, for visitors (with or without account) who got the link
|
||||||
def share_token = request.headers["X-Share-Token"].presence
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -23,32 +23,21 @@ module Api
|
|||||||
render json: { document: doc.meta_json(role: :owner) }, status: :created
|
render json: { document: doc.meta_json(role: :owner) }, status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /api/documents/:id[?known_version=n]
|
# GET /api/documents/:id[?content=0]
|
||||||
# With known_version: just the metadata when nothing changed since.
|
# The content is the JSON snapshot the editors keep current (the live
|
||||||
|
# content comes through DocumentChannel).
|
||||||
def show
|
def show
|
||||||
touch_membership
|
touch_membership
|
||||||
meta = @doc.meta_json(role: @role).merge(lock: @doc.lock_info(lock_holder))
|
meta = @doc.meta_json(role: @role)
|
||||||
if params[:known_version].present? && params[:known_version].to_i == @doc.version
|
meta[:content] = @doc.content unless params[:content] == "0"
|
||||||
render json: { document: meta, unchanged: true }
|
render json: { document: meta }
|
||||||
else
|
|
||||||
render json: { document: meta.merge(content: @doc.content) }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH /api/documents/:id { title } and/or { content, base_version }
|
# PATCH /api/documents/:id { title }
|
||||||
def update
|
def update
|
||||||
return forbidden unless Document.role_at_least?(@role, :editor)
|
return forbidden unless Document.role_at_least?(@role, :editor)
|
||||||
if params.key?(:content)
|
@doc.update!(title: params.require(:title))
|
||||||
return render(json: { error: "locked", holder_name: @doc.lock_holder_name }, status: :locked) if @doc.locked_by_other?(lock_holder)
|
@doc.broadcast({ type: "meta", title: @doc.title })
|
||||||
@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?
|
|
||||||
render json: { document: @doc.meta_json(role: @role) }
|
render json: { document: @doc.meta_json(role: @role) }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -56,8 +45,10 @@ module Api
|
|||||||
def destroy
|
def destroy
|
||||||
if @role == :owner
|
if @role == :owner
|
||||||
@doc.destroy!
|
@doc.destroy!
|
||||||
|
@doc.broadcast({ type: "revoked" })
|
||||||
elsif Current.user
|
elsif Current.user
|
||||||
@doc.document_memberships.where(user: Current.user).delete_all
|
@doc.document_memberships.where(user: Current.user).delete_all
|
||||||
|
@doc.revalidate_access!
|
||||||
else
|
else
|
||||||
return forbidden
|
return forbidden
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ module Api
|
|||||||
@doc.share_links.active.where(role:).find_each(&:revoke!)
|
@doc.share_links.active.where(role:).find_each(&:revoke!)
|
||||||
@doc.share_links.create!(role:, created_by: Current.user)
|
@doc.share_links.create!(role:, created_by: Current.user)
|
||||||
end
|
end
|
||||||
|
@doc.revalidate_access!
|
||||||
render json: share_json
|
render json: share_json
|
||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /api/documents/:document_id/share/links/:id
|
# DELETE /api/documents/:document_id/share/links/:id
|
||||||
def revoke_link
|
def revoke_link
|
||||||
@doc.share_links.active.find(params[:id]).revoke!
|
@doc.share_links.active.find(params[:id]).revoke!
|
||||||
|
@doc.revalidate_access!
|
||||||
render json: share_json
|
render json: share_json
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,12 +46,14 @@ module Api
|
|||||||
def update_member
|
def update_member
|
||||||
m = @doc.document_memberships.find_by!(user_id: params[:user_id])
|
m = @doc.document_memberships.find_by!(user_id: params[:user_id])
|
||||||
m.update!(role: params.require(:role))
|
m.update!(role: params.require(:role))
|
||||||
|
@doc.revalidate_access!
|
||||||
render json: share_json
|
render json: share_json
|
||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /api/documents/:document_id/share/members/:user_id
|
# DELETE /api/documents/:document_id/share/members/:user_id
|
||||||
def remove_member
|
def remove_member
|
||||||
@doc.document_memberships.where(user_id: params[:user_id]).delete_all
|
@doc.document_memberships.where(user_id: params[:user_id]).delete_all
|
||||||
|
@doc.revalidate_access!
|
||||||
render json: share_json
|
render json: share_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Binding, initialUpdate, readDocument, REMOTE } from '../ydoc';
|
||||||
|
import type { DocumentState, TableObject } from '../../store/types';
|
||||||
|
import { createSeedDocument } from '../../data/seed';
|
||||||
|
|
||||||
|
function docWithTable(cells: Record<string, string>, rows = 5, cols = 3): DocumentState {
|
||||||
|
const t: TableObject = {
|
||||||
|
kind: 'table',
|
||||||
|
id: 't1',
|
||||||
|
name: 'Tabelle 1',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
colWidths: { 1: 120 },
|
||||||
|
rowHeights: { 2: 40 },
|
||||||
|
cells,
|
||||||
|
formats: { '0,0': { bold: true } },
|
||||||
|
view: { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
sheets: [{ id: 's1', name: 'Blatt 1', objects: [t] }],
|
||||||
|
activeSheetId: 's1',
|
||||||
|
activeObjectId: null,
|
||||||
|
selection: null,
|
||||||
|
locale: 'de-DE',
|
||||||
|
csvDelimiter: ',',
|
||||||
|
customFunctions: [],
|
||||||
|
namedRanges: [],
|
||||||
|
macros: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = (d: DocumentState) => d.sheets[0].objects[0] as TableObject;
|
||||||
|
const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
|
||||||
|
|
||||||
|
/** two people on the same document, updates exchanged explicitly */
|
||||||
|
function pair(start: DocumentState) {
|
||||||
|
const a = new Y.Doc();
|
||||||
|
const b = new Y.Doc();
|
||||||
|
Y.applyUpdate(a, initialUpdate(start), REMOTE);
|
||||||
|
Y.applyUpdate(b, initialUpdate(start), REMOTE);
|
||||||
|
const exchange = () => {
|
||||||
|
Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)), REMOTE);
|
||||||
|
Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)), REMOTE);
|
||||||
|
};
|
||||||
|
return { a, b, ba: new Binding(a), bb: new Binding(b), exchange };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Yjs document form', () => {
|
||||||
|
it('round-trips the whole document (seed)', () => {
|
||||||
|
const seed = createSeedDocument();
|
||||||
|
const y = new Y.Doc();
|
||||||
|
Y.applyUpdate(y, initialUpdate(seed));
|
||||||
|
const back = readDocument(y);
|
||||||
|
const strip = (d: DocumentState) => ({
|
||||||
|
...d,
|
||||||
|
activeSheetId: '',
|
||||||
|
activeObjectId: null,
|
||||||
|
selection: null,
|
||||||
|
sheets: d.sheets.map((s) => ({ ...s, zoom: undefined })),
|
||||||
|
});
|
||||||
|
expect(JSON.parse(JSON.stringify(strip(back)))).toEqual(JSON.parse(JSON.stringify(strip(seed))));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initial update is deterministic, so two conversions merge into one', () => {
|
||||||
|
const d = docWithTable({ '0,0': 'x' });
|
||||||
|
expect(initialUpdate(d)).toEqual(initialUpdate(d));
|
||||||
|
const y = new Y.Doc();
|
||||||
|
Y.applyUpdate(y, initialUpdate(d));
|
||||||
|
Y.applyUpdate(y, initialUpdate(d));
|
||||||
|
expect(table(readDocument(y)).cells).toEqual({ '0,0': 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits to different cells by two people merge', () => {
|
||||||
|
const { a, b, ba, bb, exchange } = pair(docWithTable({}));
|
||||||
|
const da = readDocument(a);
|
||||||
|
table(da).cells['0,0'] = 'Anna';
|
||||||
|
ba.sync(da);
|
||||||
|
const db = readDocument(b);
|
||||||
|
table(db).cells['2,4'] = 'Bob';
|
||||||
|
bb.sync(db);
|
||||||
|
exchange();
|
||||||
|
expect(table(readDocument(a)).cells).toEqual({ '0,0': 'Anna', '2,4': 'Bob' });
|
||||||
|
expect(readDocument(b)).toEqual(readDocument(a));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a row inserted by one person does not misplace what the other types', () => {
|
||||||
|
const start = docWithTable({ '0,3': 'row 4 before' });
|
||||||
|
const { a, b, ba, bb, exchange } = pair(start);
|
||||||
|
|
||||||
|
// Anna inserts a row at index 1 (the store shifts cells, and reports it)
|
||||||
|
const da = readDocument(a);
|
||||||
|
const ta = table(da);
|
||||||
|
ta.cells = { '0,4': 'row 4 before' };
|
||||||
|
ta.rowHeights = { 3: 40 };
|
||||||
|
ta.rows += 1;
|
||||||
|
ba.structure('t1', 'row', 1, 1);
|
||||||
|
ba.sync(da);
|
||||||
|
|
||||||
|
// meanwhile Bob, not having seen that, types next to "row 4 before"
|
||||||
|
const db = readDocument(b);
|
||||||
|
table(db).cells['1,3'] = 'Bob';
|
||||||
|
bb.sync(db);
|
||||||
|
|
||||||
|
exchange();
|
||||||
|
const cells = table(readDocument(a)).cells;
|
||||||
|
expect(cells).toEqual({ '0,4': 'row 4 before', '1,4': 'Bob' });
|
||||||
|
expect(table(readDocument(a)).rows).toBe(6);
|
||||||
|
expect(table(readDocument(a)).rowHeights).toEqual({ 3: 40 });
|
||||||
|
expect(readDocument(b)).toEqual(readDocument(a));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a column removes its cells and widths', () => {
|
||||||
|
const { a, ba } = pair(docWithTable({ '0,0': 'a', '1,0': 'b', '2,0': 'c' }));
|
||||||
|
const d = readDocument(a);
|
||||||
|
const t = table(d);
|
||||||
|
t.cells = { '0,0': 'a', '1,0': 'c' };
|
||||||
|
t.colWidths = {};
|
||||||
|
t.cols = 2;
|
||||||
|
ba.structure('t1', 'col', 1, -1);
|
||||||
|
ba.sync(d);
|
||||||
|
const back = table(readDocument(a));
|
||||||
|
expect(back.cells).toEqual({ '0,0': 'a', '1,0': 'c' });
|
||||||
|
expect(back.colWidths).toEqual({});
|
||||||
|
expect(back.cols).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('growing and shrinking at the edge needs no structure event', () => {
|
||||||
|
const { a, ba } = pair(docWithTable({ '0,4': 'last' }));
|
||||||
|
const d = readDocument(a);
|
||||||
|
table(d).rows = 8;
|
||||||
|
table(d).cells['0,7'] = 'new last';
|
||||||
|
ba.sync(d);
|
||||||
|
expect(table(readDocument(a)).rows).toBe(8);
|
||||||
|
const d2 = readDocument(a);
|
||||||
|
table(d2).rows = 5;
|
||||||
|
delete table(d2).cells['0,7'];
|
||||||
|
ba.sync(d2);
|
||||||
|
expect(table(readDocument(a)).rows).toBe(5);
|
||||||
|
expect(table(readDocument(a)).cells).toEqual({ '0,4': 'last' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sheets, objects and lists: add, change, reorder, remove', () => {
|
||||||
|
const { a, b, ba, exchange } = pair(docWithTable({}));
|
||||||
|
const d = clone(readDocument(a));
|
||||||
|
d.sheets.push({ id: 's2', name: 'Zwei', objects: [] });
|
||||||
|
d.sheets[0].objects.push({ kind: 'text', id: 'x1', name: 'Text', x: 1, y: 2, width: 10, height: 10, text: 'Hi', fontSize: 14, align: 'left' });
|
||||||
|
d.customFunctions.push({ id: 'f1', name: 'DOPPELT', params: ['x'], body: 'x*2', kind: 'expression' });
|
||||||
|
ba.sync(d);
|
||||||
|
exchange();
|
||||||
|
const got = readDocument(b);
|
||||||
|
expect(got.sheets.map((s) => s.id)).toEqual(['s1', 's2']);
|
||||||
|
expect(got.sheets[0].objects.map((o) => o.id)).toEqual(['t1', 'x1']);
|
||||||
|
expect(got.customFunctions[0].name).toBe('DOPPELT');
|
||||||
|
|
||||||
|
const d2 = clone(readDocument(a));
|
||||||
|
d2.sheets.reverse();
|
||||||
|
d2.sheets[1].objects.reverse(); // z-order
|
||||||
|
d2.customFunctions = [];
|
||||||
|
ba.sync(d2);
|
||||||
|
exchange();
|
||||||
|
const got2 = readDocument(b);
|
||||||
|
expect(got2.sheets.map((s) => s.id)).toEqual(['s2', 's1']);
|
||||||
|
expect(got2.sheets[1].objects.map((o) => o.id)).toEqual(['x1', 't1']);
|
||||||
|
expect(got2.customFunctions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a sync without changes produces no update', () => {
|
||||||
|
const { a, ba } = pair(docWithTable({ '0,0': 'x' }));
|
||||||
|
let updates = 0;
|
||||||
|
a.on('update', () => updates++);
|
||||||
|
ba.sync(readDocument(a));
|
||||||
|
expect(updates).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* One live-editing session on a server document: the Yjs document, its
|
||||||
|
* connection to the server (ActionCable, see app/channels/document_channel.rb),
|
||||||
|
* per-person undo and the presence of the others (awareness: who is here,
|
||||||
|
* what they have selected).
|
||||||
|
*
|
||||||
|
* The store stays the single source for the UI. Local edits flow
|
||||||
|
* store → Binding.sync → Yjs → server; the others' edits flow
|
||||||
|
* server → Yjs → readDocument → store.applyExternalState. Before a remote
|
||||||
|
* change is applied, any not-yet-synced local change goes into Yjs first, so
|
||||||
|
* neither side can overwrite the other.
|
||||||
|
*/
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
|
||||||
|
import { fromBase64, toBase64 } from 'lib0/buffer';
|
||||||
|
import { createConsumer, type Consumer, type Subscription } from '@rails/actioncable';
|
||||||
|
import { reactive, ref, watch, type WatchStopHandle } from 'vue';
|
||||||
|
import { useDocument, setCollabHooks, contentFingerprint, type UndoProvider } from '../store/document';
|
||||||
|
import type { DocumentState } from '../store/types';
|
||||||
|
import type { Role } from '../io/api';
|
||||||
|
import { Binding, LOCAL, REMOTE, allAxes, initialUpdate, isInitialized, readDocument, type Axes } from './ydoc';
|
||||||
|
import { validateDocument } from '../io/json';
|
||||||
|
|
||||||
|
const SYNC_DELAY = 40; // ms: coalesce a burst of store changes into one Yjs transaction
|
||||||
|
const SNAPSHOT_EVERY = 30_000; // JSON snapshot for the server (export, duplicate)
|
||||||
|
const COMPACT_AFTER = 200; // stored updates before they're merged into one
|
||||||
|
|
||||||
|
let consumer: Consumer | null = null;
|
||||||
|
const cable = () => (consumer ??= createConsumer('/cable'));
|
||||||
|
|
||||||
|
export interface Peer {
|
||||||
|
clientId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
sheetId: string | null;
|
||||||
|
tableId: string | null;
|
||||||
|
/** selection rectangle in current positions, or null */
|
||||||
|
rect: { c0: number; r0: number; c1: number; r1: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionEvents {
|
||||||
|
onRole(role: Role): void;
|
||||||
|
onRevoked(): void;
|
||||||
|
onTitle(title: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitMsg {
|
||||||
|
type: 'init';
|
||||||
|
role: Role;
|
||||||
|
title: string;
|
||||||
|
updates: { id: number; data: string }[];
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
type Msg =
|
||||||
|
| InitMsg
|
||||||
|
| { type: 'update'; id: number; data: string; from: string }
|
||||||
|
| { type: 'awareness'; data: string; from: string }
|
||||||
|
| { type: 'compacted'; removed: number[]; id: number }
|
||||||
|
| { type: 'meta'; title: string }
|
||||||
|
| { type: 'role'; role: Role }
|
||||||
|
| { type: 'revoked' }
|
||||||
|
| { type: 'error'; error: string };
|
||||||
|
|
||||||
|
type SelIds = { tableId: string; a: [string, string]; f: [string, string] } | null;
|
||||||
|
|
||||||
|
export class CollabSession {
|
||||||
|
readonly ydoc = new Y.Doc();
|
||||||
|
readonly awareness = new Awareness(this.ydoc);
|
||||||
|
readonly peers = reactive<Peer[]>([]);
|
||||||
|
readonly online = ref(false);
|
||||||
|
role: Role = 'viewer';
|
||||||
|
|
||||||
|
private store = useDocument();
|
||||||
|
private binding = new Binding(this.ydoc);
|
||||||
|
private undoManager: Y.UndoManager | null = null;
|
||||||
|
private sub: Subscription | null = null;
|
||||||
|
private key = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
private syncTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private snapshotTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private compactTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private queue: Msg[] = [];
|
||||||
|
private queued = false;
|
||||||
|
private knownIds = new Set<number>();
|
||||||
|
private axes = new Map<string, Axes>();
|
||||||
|
private lastSnapshot = '';
|
||||||
|
private initialized = false;
|
||||||
|
private stopWatch: WatchStopHandle | null = null;
|
||||||
|
private attached = false;
|
||||||
|
private ready!: { resolve: (d: DocumentState) => void; reject: (e: Error) => void };
|
||||||
|
|
||||||
|
readonly canUndo = ref(false);
|
||||||
|
readonly canRedo = ref(false);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private docId: string,
|
||||||
|
private token: string | null,
|
||||||
|
private user: { name: string; color: string },
|
||||||
|
private events: SessionEvents,
|
||||||
|
) {
|
||||||
|
this.ydoc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||||
|
if (origin === REMOTE || !this.canEdit) return;
|
||||||
|
this.sub?.perform('update', { data: toBase64(update) });
|
||||||
|
});
|
||||||
|
this.awareness.on('update', ({ added, updated, removed }: Record<string, number[]>, origin: unknown) => {
|
||||||
|
if (origin !== 'local') return;
|
||||||
|
const mine = [...added, ...updated, ...removed].filter((c) => c === this.ydoc.clientID);
|
||||||
|
if (mine.length) this.sub?.perform('awareness', { data: toBase64(encodeAwarenessUpdate(this.awareness, mine)) });
|
||||||
|
});
|
||||||
|
this.awareness.on('change', () => this.updatePeers());
|
||||||
|
}
|
||||||
|
|
||||||
|
get canEdit() {
|
||||||
|
return this.role !== 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect; resolves with the document once the server sent it. */
|
||||||
|
start(): Promise<DocumentState> {
|
||||||
|
const done = new Promise<DocumentState>((resolve, reject) => (this.ready = { resolve, reject }));
|
||||||
|
this.sub = cable().subscriptions.create(
|
||||||
|
{ channel: 'DocumentChannel', id: this.docId, token: this.token, key: this.key },
|
||||||
|
{
|
||||||
|
received: (m: Msg) => this.receive(m),
|
||||||
|
connected: () => (this.online.value = true),
|
||||||
|
disconnected: () => (this.online.value = false),
|
||||||
|
rejected: () => this.ready.reject(new Error('Kein Zugriff auf dieses Dokument.')),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take over the store: sync hooks, per-person undo, own presence. */
|
||||||
|
attach(): UndoProvider {
|
||||||
|
this.attached = true;
|
||||||
|
this.axes = allAxes(this.ydoc);
|
||||||
|
setCollabHooks({
|
||||||
|
onChange: () => this.scheduleSync(),
|
||||||
|
onStructure: (tableId, axis, at, delta) => this.binding.structure(tableId, axis, at, delta),
|
||||||
|
});
|
||||||
|
const root = this.ydoc.getMap('numolus');
|
||||||
|
this.undoManager = new Y.UndoManager(root, { trackedOrigins: new Set([LOCAL]), captureTimeout: 400 });
|
||||||
|
const refresh = () => {
|
||||||
|
this.canUndo.value = this.undoManager!.canUndo();
|
||||||
|
this.canRedo.value = this.undoManager!.canRedo();
|
||||||
|
};
|
||||||
|
this.undoManager.on('stack-item-added', refresh);
|
||||||
|
this.undoManager.on('stack-item-popped', refresh);
|
||||||
|
this.undoManager.on('stack-cleared', refresh);
|
||||||
|
|
||||||
|
this.awareness.setLocalState({ user: this.user, sheetId: null, sel: null });
|
||||||
|
this.stopWatch = watch(
|
||||||
|
() => [this.store.doc.selection, this.store.doc.activeSheetId] as const,
|
||||||
|
() => this.publishSelection(),
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
);
|
||||||
|
this.snapshotTimer = setInterval(() => this.sendSnapshot(), SNAPSHOT_EVERY);
|
||||||
|
this.lastSnapshot = contentFingerprint(this.store.doc);
|
||||||
|
this.rebuild(); // whatever arrived since start() resolved
|
||||||
|
|
||||||
|
return {
|
||||||
|
undo: () => this.undoRedo('undo'),
|
||||||
|
redo: () => this.undoRedo('redo'),
|
||||||
|
canUndo: this.canUndo,
|
||||||
|
canRedo: this.canRedo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave: send what's pending, say goodbye to the others, disconnect. */
|
||||||
|
destroy() {
|
||||||
|
this.flushLocal();
|
||||||
|
this.sendSnapshot();
|
||||||
|
if (this.attached) setCollabHooks(null);
|
||||||
|
this.stopWatch?.();
|
||||||
|
clearInterval(this.snapshotTimer);
|
||||||
|
clearTimeout(this.compactTimer);
|
||||||
|
removeAwarenessStates(this.awareness, [this.ydoc.clientID], 'local');
|
||||||
|
this.awareness.destroy();
|
||||||
|
this.undoManager?.destroy();
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
this.sub = null;
|
||||||
|
this.ydoc.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- local → Yjs ---------------------------------------------------------
|
||||||
|
private scheduleSync() {
|
||||||
|
if (!this.canEdit) return;
|
||||||
|
clearTimeout(this.syncTimer);
|
||||||
|
this.syncTimer = setTimeout(() => this.flushLocal(), SYNC_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put not-yet-synced store changes into Yjs now. */
|
||||||
|
flushLocal() {
|
||||||
|
if (this.syncTimer === undefined && !this.binding.hasPending) return;
|
||||||
|
clearTimeout(this.syncTimer);
|
||||||
|
this.syncTimer = undefined;
|
||||||
|
if (!this.canEdit || !isInitialized(this.ydoc)) return;
|
||||||
|
this.binding.sync(this.store.doc);
|
||||||
|
this.axes = allAxes(this.ydoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private undoRedo(which: 'undo' | 'redo') {
|
||||||
|
this.flushLocal();
|
||||||
|
if (which === 'undo') this.undoManager?.undo();
|
||||||
|
else this.undoManager?.redo();
|
||||||
|
this.rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- server → Yjs ----------------------------------------------------------
|
||||||
|
private receive(m: Msg) {
|
||||||
|
// bursts are applied together, after the current task
|
||||||
|
this.queue.push(m);
|
||||||
|
if (this.queued) return;
|
||||||
|
this.queued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.queued = false;
|
||||||
|
const msgs = this.queue.splice(0);
|
||||||
|
this.process(msgs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private process(msgs: Msg[]) {
|
||||||
|
let changed = false;
|
||||||
|
this.flushLocal();
|
||||||
|
for (const m of msgs) {
|
||||||
|
switch (m.type) {
|
||||||
|
case 'init':
|
||||||
|
this.init(m);
|
||||||
|
changed = this.initialized;
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
this.knownIds.add(m.id);
|
||||||
|
if (m.from !== this.key) {
|
||||||
|
Y.applyUpdate(this.ydoc, fromBase64(m.data), REMOTE);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'awareness':
|
||||||
|
applyAwarenessUpdate(this.awareness, fromBase64(m.data), 'remote');
|
||||||
|
break;
|
||||||
|
case 'compacted':
|
||||||
|
for (const id of m.removed) this.knownIds.delete(id);
|
||||||
|
this.knownIds.add(m.id);
|
||||||
|
clearTimeout(this.compactTimer);
|
||||||
|
this.compactTimer = undefined;
|
||||||
|
break;
|
||||||
|
case 'meta':
|
||||||
|
this.events.onTitle(m.title);
|
||||||
|
break;
|
||||||
|
case 'role':
|
||||||
|
this.role = m.role;
|
||||||
|
this.events.onRole(m.role);
|
||||||
|
break;
|
||||||
|
case 'revoked':
|
||||||
|
this.events.onRevoked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed && this.attached) this.rebuild();
|
||||||
|
this.maybeCompact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(m: InitMsg) {
|
||||||
|
const first = !this.initialized;
|
||||||
|
this.role = m.role;
|
||||||
|
// what the server has, to find out what it's missing from us (reconnect)
|
||||||
|
const server = new Y.Doc();
|
||||||
|
for (const u of m.updates) {
|
||||||
|
this.knownIds.add(u.id);
|
||||||
|
const bytes = fromBase64(u.data);
|
||||||
|
Y.applyUpdate(server, bytes);
|
||||||
|
Y.applyUpdate(this.ydoc, bytes, REMOTE);
|
||||||
|
}
|
||||||
|
if (!m.updates.length && m.content && !isInitialized(this.ydoc)) {
|
||||||
|
// not yet converted: everyone converts the same way (see initialUpdate)
|
||||||
|
let json: DocumentState;
|
||||||
|
try {
|
||||||
|
json = validateDocument(JSON.parse(m.content));
|
||||||
|
} catch (err) {
|
||||||
|
this.ready.reject(err as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = initialUpdate(json);
|
||||||
|
Y.applyUpdate(this.ydoc, u, REMOTE);
|
||||||
|
if (this.canEdit) this.sub?.perform('update', { data: toBase64(u) });
|
||||||
|
} else if (!first && this.canEdit) {
|
||||||
|
const missing = Y.encodeStateAsUpdate(this.ydoc, Y.encodeStateVector(server));
|
||||||
|
if (missing.length > 2) this.sub?.perform('update', { data: toBase64(missing) });
|
||||||
|
}
|
||||||
|
server.destroy();
|
||||||
|
if (!isInitialized(this.ydoc)) {
|
||||||
|
this.ready.reject(new Error('Das Dokument ist leer.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
if (first) {
|
||||||
|
this.events.onTitle(m.title);
|
||||||
|
this.ready.resolve(readDocument(this.ydoc));
|
||||||
|
} else {
|
||||||
|
this.events.onRole(m.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the current Yjs state in the store, keeping this person's view. */
|
||||||
|
private rebuild() {
|
||||||
|
const selIds = this.selectionIds();
|
||||||
|
this.store.applyExternalState(readDocument(this.ydoc));
|
||||||
|
this.axes = allAxes(this.ydoc);
|
||||||
|
this.restoreSelection(selIds);
|
||||||
|
this.updatePeers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- selection by ids (survives rows inserted by others) --------------------
|
||||||
|
private idsAt(tableId: string, c: number, r: number): [string, string] | null {
|
||||||
|
const ax = this.axes.get(tableId);
|
||||||
|
return ax && ax.cols[c] !== undefined && ax.rows[r] !== undefined ? [ax.cols[c], ax.rows[r]] : null;
|
||||||
|
}
|
||||||
|
private posOf(tableId: string, ids: [string, string]): { col: number; row: number } | null {
|
||||||
|
const ax = this.axes.get(tableId);
|
||||||
|
if (!ax) return null;
|
||||||
|
const col = ax.cols.indexOf(ids[0]);
|
||||||
|
const row = ax.rows.indexOf(ids[1]);
|
||||||
|
return col < 0 || row < 0 ? null : { col, row };
|
||||||
|
}
|
||||||
|
private selectionIds(): SelIds {
|
||||||
|
const s = this.store.doc.selection;
|
||||||
|
if (!s) return null;
|
||||||
|
const a = this.idsAt(s.tableId, s.anchor.col, s.anchor.row);
|
||||||
|
const f = this.idsAt(s.tableId, s.focus.col, s.focus.row);
|
||||||
|
return a && f ? { tableId: s.tableId, a, f } : null;
|
||||||
|
}
|
||||||
|
private restoreSelection(ids: SelIds) {
|
||||||
|
const s = this.store.doc.selection;
|
||||||
|
if (!ids || !s || s.tableId !== ids.tableId) return;
|
||||||
|
const anchor = this.posOf(ids.tableId, ids.a);
|
||||||
|
const focus = this.posOf(ids.tableId, ids.f);
|
||||||
|
if (!anchor || !focus) return; // its row/column is gone: stay where it was (clamped)
|
||||||
|
if (anchor.col === s.anchor.col && anchor.row === s.anchor.row && focus.col === s.focus.col && focus.row === s.focus.row) return;
|
||||||
|
this.store.doc.selection = { tableId: ids.tableId, anchor, focus };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- presence ---------------------------------------------------------------
|
||||||
|
private publishSelection() {
|
||||||
|
const sel = this.selectionIds();
|
||||||
|
this.awareness.setLocalStateField('sheetId', this.store.doc.activeSheetId);
|
||||||
|
this.awareness.setLocalStateField('sel', sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePeers() {
|
||||||
|
const out: Peer[] = [];
|
||||||
|
for (const [clientId, st] of this.awareness.getStates()) {
|
||||||
|
if (clientId === this.ydoc.clientID || !st.user) continue;
|
||||||
|
const sel = st.sel as SelIds;
|
||||||
|
let rect: Peer['rect'] = null;
|
||||||
|
if (sel) {
|
||||||
|
const a = this.posOf(sel.tableId, sel.a);
|
||||||
|
const f = this.posOf(sel.tableId, sel.f);
|
||||||
|
if (a && f) {
|
||||||
|
rect = {
|
||||||
|
c0: Math.min(a.col, f.col),
|
||||||
|
c1: Math.max(a.col, f.col),
|
||||||
|
r0: Math.min(a.row, f.row),
|
||||||
|
r1: Math.max(a.row, f.row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
clientId,
|
||||||
|
name: String(st.user.name ?? '?'),
|
||||||
|
color: String(st.user.color ?? '#888'),
|
||||||
|
sheetId: (st.sheetId as string) ?? null,
|
||||||
|
tableId: sel?.tableId ?? null,
|
||||||
|
rect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.peers.splice(0, this.peers.length, ...out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- housekeeping ------------------------------------------------------------
|
||||||
|
/** JSON of the document for the server (export, duplicate, list), when changed. */
|
||||||
|
private sendSnapshot() {
|
||||||
|
if (!this.canEdit || !this.initialized || !this.attached) return;
|
||||||
|
const fp = contentFingerprint(this.store.doc);
|
||||||
|
if (fp === this.lastSnapshot) return;
|
||||||
|
this.lastSnapshot = fp;
|
||||||
|
this.sub?.perform('snapshot', { content: JSON.stringify(this.store.doc) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Many stored updates → one; whoever notices first does it. */
|
||||||
|
private maybeCompact() {
|
||||||
|
if (!this.canEdit || this.compactTimer || this.knownIds.size < COMPACT_AFTER) return;
|
||||||
|
this.compactTimer = setTimeout(() => {
|
||||||
|
this.flushLocal();
|
||||||
|
this.sub?.perform('compact', {
|
||||||
|
state: toBase64(Y.encodeStateAsUpdate(this.ydoc)),
|
||||||
|
ids: [...this.knownIds],
|
||||||
|
snapshot: JSON.stringify(this.store.doc),
|
||||||
|
});
|
||||||
|
// if no confirmation arrives, try again later
|
||||||
|
this.compactTimer = setTimeout(() => (this.compactTimer = undefined), 30_000);
|
||||||
|
}, 1000 + Math.random() * 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/**
|
||||||
|
* The shared (Yjs) form of a numolus document, and the two directions
|
||||||
|
* between it and the store's plain `DocumentState`:
|
||||||
|
*
|
||||||
|
* - `readDocument(ydoc)` Yjs → DocumentState (after someone else's change)
|
||||||
|
* - `Binding.sync(state)` DocumentState → Yjs, as the *smallest* set of
|
||||||
|
* changes (a cell, a property), so that edits by
|
||||||
|
* different people merge instead of overwriting
|
||||||
|
*
|
||||||
|
* Layout (root = ydoc.getMap('numolus')):
|
||||||
|
*
|
||||||
|
* locale, csvDelimiter plain values
|
||||||
|
* sheetOrder: Y.Array<id> sheets: Y.Map<id, Y.Map>
|
||||||
|
* sheet: name, locale, background, ... plain values (zoom stays local)
|
||||||
|
* objectOrder: Y.Array<id> objects: Y.Map<id, Y.Map>
|
||||||
|
* object: kind, name, x, y, ... plain values
|
||||||
|
* table additionally:
|
||||||
|
* rowOrder / colOrder: Y.Array<rowId | colId>
|
||||||
|
* cells: Y.Map<"colId|rowId", raw input>
|
||||||
|
* formats: Y.Map<"colId|rowId", CellFormat>
|
||||||
|
* colWidths: Y.Map<colId, px> rowHeights: Y.Map<rowId, px>
|
||||||
|
* fnOrder/fns, nameOrder/names, macroOrder/macros id-keyed lists
|
||||||
|
*
|
||||||
|
* Rows and columns have stable ids: the store addresses cells by position
|
||||||
|
* ("col,row"), Yjs by id. Inserting a row inserts one id into `rowOrder` --
|
||||||
|
* no cell moves -- so someone typing into row 7 while someone else inserts
|
||||||
|
* a row above still lands in the row they meant. The store reports such
|
||||||
|
* inserts/deletes explicitly (`Binding.structure`), because a diff of
|
||||||
|
* positional cells can't tell "row inserted" from "every cell below changed".
|
||||||
|
*/
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import type {
|
||||||
|
CustomFunction,
|
||||||
|
DocumentState,
|
||||||
|
Macro,
|
||||||
|
NamedRange,
|
||||||
|
Sheet,
|
||||||
|
SheetObject,
|
||||||
|
TableObject,
|
||||||
|
} from '../store/types';
|
||||||
|
|
||||||
|
/** transaction origin of this person's own edits (tracked by undo) */
|
||||||
|
export const LOCAL = Symbol('local');
|
||||||
|
/** transaction origin of changes received from the server */
|
||||||
|
export const REMOTE = Symbol('remote');
|
||||||
|
|
||||||
|
type YMap = Y.Map<unknown>;
|
||||||
|
type YArr = Y.Array<string>;
|
||||||
|
|
||||||
|
const TABLE_STRUCT = new Set(['rows', 'cols', 'cells', 'formats', 'colWidths', 'rowHeights']);
|
||||||
|
const SHEET_STRUCT = new Set(['id', 'objects', 'zoom']);
|
||||||
|
const LISTS = [
|
||||||
|
['fnOrder', 'fns', 'customFunctions'],
|
||||||
|
['nameOrder', 'names', 'namedRanges'],
|
||||||
|
['macroOrder', 'macros', 'macros'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
const randomId = (p: string) => p + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
||||||
|
|
||||||
|
// ---- building ------------------------------------------------------------
|
||||||
|
|
||||||
|
type IdGen = (axis: 'r' | 'c', index: number) => string;
|
||||||
|
const deterministicIds: IdGen = (axis, i) => `${axis}${i}`;
|
||||||
|
const randomIds: IdGen = (axis) => randomId(axis);
|
||||||
|
|
||||||
|
function setPlain(m: YMap, obj: object, skip: Set<string>) {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (skip.has(k) || v === undefined) continue;
|
||||||
|
m.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTable(m: YMap, t: TableObject, ids: IdGen) {
|
||||||
|
const rowIds = Array.from({ length: t.rows }, (_, i) => ids('r', i));
|
||||||
|
const colIds = Array.from({ length: t.cols }, (_, i) => ids('c', i));
|
||||||
|
const rowOrder = new Y.Array<string>();
|
||||||
|
rowOrder.push(rowIds);
|
||||||
|
const colOrder = new Y.Array<string>();
|
||||||
|
colOrder.push(colIds);
|
||||||
|
m.set('rowOrder', rowOrder);
|
||||||
|
m.set('colOrder', colOrder);
|
||||||
|
const cells = new Y.Map<string>();
|
||||||
|
const formats = new Y.Map<unknown>();
|
||||||
|
const colWidths = new Y.Map<number>();
|
||||||
|
const rowHeights = new Y.Map<number>();
|
||||||
|
const key = (k: string) => {
|
||||||
|
const [c, r] = k.split(',').map(Number);
|
||||||
|
return c < colIds.length && r < rowIds.length ? `${colIds[c]}|${rowIds[r]}` : null;
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(t.cells)) {
|
||||||
|
const id = key(k);
|
||||||
|
if (id) cells.set(id, v);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(t.formats ?? {})) {
|
||||||
|
const id = key(k);
|
||||||
|
if (id) formats.set(id, v);
|
||||||
|
}
|
||||||
|
for (const [c, w] of Object.entries(t.colWidths ?? {})) if (colIds[+c]) colWidths.set(colIds[+c], w);
|
||||||
|
for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rowIds[+r]) rowHeights.set(rowIds[+r], h);
|
||||||
|
m.set('cells', cells);
|
||||||
|
m.set('formats', formats);
|
||||||
|
m.set('colWidths', colWidths);
|
||||||
|
m.set('rowHeights', rowHeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildObject(o: SheetObject, ids: IdGen): YMap {
|
||||||
|
const m = new Y.Map<unknown>();
|
||||||
|
if (o.kind === 'table') {
|
||||||
|
setPlain(m, o, TABLE_STRUCT);
|
||||||
|
buildTable(m, o, ids);
|
||||||
|
} else {
|
||||||
|
setPlain(m, o, new Set());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSheet(s: Sheet, ids: IdGen): YMap {
|
||||||
|
const m = new Y.Map<unknown>();
|
||||||
|
setPlain(m, s, SHEET_STRUCT);
|
||||||
|
m.set('id', s.id);
|
||||||
|
const order = new Y.Array<string>();
|
||||||
|
order.push(s.objects.map((o) => o.id));
|
||||||
|
const objects = new Y.Map<unknown>();
|
||||||
|
for (const o of s.objects) objects.set(o.id, buildObject(o, ids));
|
||||||
|
m.set('objectOrder', order);
|
||||||
|
m.set('objects', objects);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(root: YMap, d: DocumentState, ids: IdGen) {
|
||||||
|
root.set('locale', d.locale);
|
||||||
|
root.set('csvDelimiter', d.csvDelimiter);
|
||||||
|
const order = new Y.Array<string>();
|
||||||
|
order.push(d.sheets.map((s) => s.id));
|
||||||
|
const sheets = new Y.Map<unknown>();
|
||||||
|
for (const s of d.sheets) sheets.set(s.id, buildSheet(s, ids));
|
||||||
|
root.set('sheetOrder', order);
|
||||||
|
root.set('sheets', sheets);
|
||||||
|
for (const [orderKey, itemsKey, field] of LISTS) {
|
||||||
|
const list = d[field] as { id: string }[];
|
||||||
|
const o = new Y.Array<string>();
|
||||||
|
o.push(list.map((x) => x.id));
|
||||||
|
const items = new Y.Map<unknown>();
|
||||||
|
for (const x of list) items.set(x.id, x);
|
||||||
|
root.set(orderKey, o);
|
||||||
|
root.set(itemsKey, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Yjs update that turns an empty document into `d`. Deterministic: the
|
||||||
|
* same `d` always gives byte-identical output (fixed client id, row/column
|
||||||
|
* ids `r0…`, `c0…`), so two people opening a not-yet-converted document at
|
||||||
|
* the same moment produce the same update, which Yjs merges into one.
|
||||||
|
*/
|
||||||
|
export function initialUpdate(d: DocumentState): Uint8Array {
|
||||||
|
const y = new Y.Doc();
|
||||||
|
y.clientID = 1;
|
||||||
|
y.transact(() => build(y.getMap('numolus'), d, deterministicIds));
|
||||||
|
return Y.encodeStateAsUpdate(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInitialized = (y: Y.Doc) => y.getMap('numolus').has('sheets');
|
||||||
|
|
||||||
|
// ---- reading ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function orderedItems<T>(order: YArr | undefined, items: Y.Map<unknown> | undefined): T[] {
|
||||||
|
if (!order || !items) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const id of order.toArray()) {
|
||||||
|
if (seen.has(id) || !items.has(id)) continue; // a concurrent double insert
|
||||||
|
seen.add(id);
|
||||||
|
out.push(items.get(id) as T);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Axes {
|
||||||
|
rows: string[];
|
||||||
|
cols: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row/column ids of a table (duplicates from concurrent moves dropped). */
|
||||||
|
export function axesOf(t: YMap): Axes {
|
||||||
|
const dedupe = (a: string[]) => [...new Set(a)];
|
||||||
|
return {
|
||||||
|
rows: dedupe((t.get('rowOrder') as YArr).toArray()),
|
||||||
|
cols: dedupe((t.get('colOrder') as YArr).toArray()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTable(m: YMap): TableObject {
|
||||||
|
const t: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of m.entries()) {
|
||||||
|
if (!(v instanceof Y.AbstractType)) t[k] = v;
|
||||||
|
}
|
||||||
|
const { rows, cols } = axesOf(m);
|
||||||
|
const ri = new Map(rows.map((id, i) => [id, i]));
|
||||||
|
const ci = new Map(cols.map((id, i) => [id, i]));
|
||||||
|
const pos = (key: string) => {
|
||||||
|
const [c, r] = key.split('|');
|
||||||
|
const cc = ci.get(c);
|
||||||
|
const rr = ri.get(r);
|
||||||
|
return cc === undefined || rr === undefined ? null : `${cc},${rr}`;
|
||||||
|
};
|
||||||
|
const cells: Record<string, string> = {};
|
||||||
|
for (const [k, v] of (m.get('cells') as Y.Map<string>).entries()) {
|
||||||
|
const p = pos(k);
|
||||||
|
if (p) cells[p] = v;
|
||||||
|
}
|
||||||
|
const formats: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of (m.get('formats') as Y.Map<unknown>).entries()) {
|
||||||
|
const p = pos(k);
|
||||||
|
if (p) formats[p] = v;
|
||||||
|
}
|
||||||
|
const colWidths: Record<number, number> = {};
|
||||||
|
for (const [k, v] of (m.get('colWidths') as Y.Map<number>).entries()) {
|
||||||
|
const c = ci.get(k);
|
||||||
|
if (c !== undefined) colWidths[c] = v;
|
||||||
|
}
|
||||||
|
const rowHeights: Record<number, number> = {};
|
||||||
|
for (const [k, v] of (m.get('rowHeights') as Y.Map<number>).entries()) {
|
||||||
|
const r = ri.get(k);
|
||||||
|
if (r !== undefined) rowHeights[r] = v;
|
||||||
|
}
|
||||||
|
t.rows = Math.max(1, rows.length);
|
||||||
|
t.cols = Math.max(1, cols.length);
|
||||||
|
t.cells = cells;
|
||||||
|
t.colWidths = colWidths;
|
||||||
|
if (Object.keys(formats).length) t.formats = formats;
|
||||||
|
if (Object.keys(rowHeights).length) t.rowHeights = rowHeights;
|
||||||
|
return t as unknown as TableObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readObject(m: YMap): SheetObject {
|
||||||
|
if (m.get('kind') === 'table') return readTable(m);
|
||||||
|
return Object.fromEntries(m.entries()) as unknown as SheetObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSheet(m: YMap): Sheet {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of m.entries()) {
|
||||||
|
if (!(v instanceof Y.AbstractType)) s[k] = v;
|
||||||
|
}
|
||||||
|
s.objects = orderedItems<YMap>(m.get('objectOrder') as YArr, m.get('objects') as Y.Map<unknown>).map(readObject);
|
||||||
|
return s as unknown as Sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole document as the store holds it (view fields left at defaults). */
|
||||||
|
export function readDocument(y: Y.Doc): DocumentState {
|
||||||
|
const root = y.getMap('numolus');
|
||||||
|
const sheets = orderedItems<YMap>(root.get('sheetOrder') as YArr, root.get('sheets') as Y.Map<unknown>).map(readSheet);
|
||||||
|
const list = <T>(o: string, i: string) => orderedItems<T>(root.get(o) as YArr, root.get(i) as Y.Map<unknown>);
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
sheets,
|
||||||
|
activeSheetId: sheets[0]?.id ?? '',
|
||||||
|
activeObjectId: null,
|
||||||
|
selection: null,
|
||||||
|
locale: (root.get('locale') as DocumentState['locale']) ?? 'de-DE',
|
||||||
|
csvDelimiter: (root.get('csvDelimiter') as ',' | ';') ?? ',',
|
||||||
|
customFunctions: list<CustomFunction>('fnOrder', 'fns'),
|
||||||
|
namedRanges: list<NamedRange>('nameOrder', 'names'),
|
||||||
|
macros: list<Macro>('macroOrder', 'macros'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current row/column ids of every table (for mapping positions to ids). */
|
||||||
|
export function allAxes(y: Y.Doc): Map<string, Axes> {
|
||||||
|
const out = new Map<string, Axes>();
|
||||||
|
const sheets = y.getMap('numolus').get('sheets') as Y.Map<unknown> | undefined;
|
||||||
|
if (!sheets) return out;
|
||||||
|
for (const s of sheets.values() as IterableIterator<YMap>) {
|
||||||
|
for (const [id, o] of (s.get('objects') as Y.Map<unknown>).entries() as IterableIterator<[string, YMap]>) {
|
||||||
|
if (o.get('kind') === 'table') out.set(id, axesOf(o));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writing: store state → Yjs, as small changes ------------------------------
|
||||||
|
|
||||||
|
interface StructureEvent {
|
||||||
|
tableId: string;
|
||||||
|
axis: 'row' | 'col';
|
||||||
|
at: number;
|
||||||
|
delta: 1 | -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPlain(m: YMap, obj: object, skip: Set<string>, keepKeys: Set<string>) {
|
||||||
|
const entries = obj as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(entries)) {
|
||||||
|
if (skip.has(k)) continue;
|
||||||
|
if (v === undefined) {
|
||||||
|
if (m.has(k)) m.delete(k);
|
||||||
|
} else if (!same(m.get(k), v)) {
|
||||||
|
m.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of [...m.keys()]) {
|
||||||
|
if (!keepKeys.has(k) && !skip.has(k) && !(k in entries)) m.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Make `order` equal `ids`, touching only what differs. */
|
||||||
|
function syncOrder(order: YArr, ids: string[]) {
|
||||||
|
const want = new Set(ids);
|
||||||
|
const cur = order.toArray();
|
||||||
|
for (let i = cur.length - 1; i >= 0; i--) {
|
||||||
|
if (!want.has(cur[i]) || cur.indexOf(cur[i]) !== i) order.delete(i, 1);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const now = order.toArray();
|
||||||
|
if (now[i] === ids[i]) continue;
|
||||||
|
const j = now.indexOf(ids[i]);
|
||||||
|
if (j >= 0) order.delete(j, 1); // moved
|
||||||
|
order.insert(i, [ids[i]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncKeyedMap<T>(ymap: Y.Map<T>, want: Map<string, T>) {
|
||||||
|
for (const [k, v] of want) if (!same(ymap.get(k), v)) ymap.set(k, v);
|
||||||
|
for (const k of [...ymap.keys()]) if (!want.has(k)) ymap.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTable(m: YMap, t: TableObject, events: StructureEvent[]) {
|
||||||
|
syncPlain(m, t, TABLE_STRUCT, new Set(['rowOrder', 'colOrder', 'cells', 'formats', 'colWidths', 'rowHeights']));
|
||||||
|
const rowOrder = m.get('rowOrder') as YArr;
|
||||||
|
const colOrder = m.get('colOrder') as YArr;
|
||||||
|
// mid-table inserts/deletes first, in the order they happened ...
|
||||||
|
for (const e of events) {
|
||||||
|
const order = e.axis === 'row' ? rowOrder : colOrder;
|
||||||
|
if (e.delta > 0) order.insert(Math.min(e.at, order.length), [randomId(e.axis === 'row' ? 'r' : 'c')]);
|
||||||
|
else if (e.at < order.length) order.delete(e.at, 1);
|
||||||
|
}
|
||||||
|
// ... then edge growth/shrink (addRows, setTableRows, ...)
|
||||||
|
const fit = (order: YArr, n: number, p: 'r' | 'c') => {
|
||||||
|
if (order.length < n) order.push(Array.from({ length: n - order.length }, () => randomId(p)));
|
||||||
|
else if (order.length > n) order.delete(n, order.length - n);
|
||||||
|
};
|
||||||
|
fit(rowOrder, t.rows, 'r');
|
||||||
|
fit(colOrder, t.cols, 'c');
|
||||||
|
|
||||||
|
const { rows, cols } = axesOf(m);
|
||||||
|
const key = (k: string) => {
|
||||||
|
const [c, r] = k.split(',').map(Number);
|
||||||
|
return cols[c] !== undefined && rows[r] !== undefined ? `${cols[c]}|${rows[r]}` : null;
|
||||||
|
};
|
||||||
|
const cells = new Map<string, string>();
|
||||||
|
for (const [k, v] of Object.entries(t.cells)) {
|
||||||
|
const id = key(k);
|
||||||
|
if (id) cells.set(id, v);
|
||||||
|
}
|
||||||
|
syncKeyedMap(m.get('cells') as Y.Map<string>, cells);
|
||||||
|
const formats = new Map<string, unknown>();
|
||||||
|
for (const [k, v] of Object.entries(t.formats ?? {})) {
|
||||||
|
const id = key(k);
|
||||||
|
if (id) formats.set(id, v);
|
||||||
|
}
|
||||||
|
syncKeyedMap(m.get('formats') as Y.Map<unknown>, formats);
|
||||||
|
const widths = new Map<string, number>();
|
||||||
|
for (const [c, w] of Object.entries(t.colWidths ?? {})) if (cols[+c]) widths.set(cols[+c], w);
|
||||||
|
syncKeyedMap(m.get('colWidths') as Y.Map<number>, widths);
|
||||||
|
const heights = new Map<string, number>();
|
||||||
|
for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rows[+r]) heights.set(rows[+r], h);
|
||||||
|
syncKeyedMap(m.get('rowHeights') as Y.Map<number>, heights);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncObjects(sheet: YMap, s: Sheet, events: StructureEvent[]) {
|
||||||
|
const objects = sheet.get('objects') as Y.Map<unknown>;
|
||||||
|
const want = new Set(s.objects.map((o) => o.id));
|
||||||
|
for (const id of [...objects.keys()]) if (!want.has(id)) objects.delete(id);
|
||||||
|
for (const o of s.objects) {
|
||||||
|
const m = objects.get(o.id) as YMap | undefined;
|
||||||
|
if (!m || m.get('kind') !== o.kind) {
|
||||||
|
objects.set(o.id, buildObject(o, randomIds));
|
||||||
|
} else if (o.kind === 'table') {
|
||||||
|
syncTable(m, o, events.filter((e) => e.tableId === o.id));
|
||||||
|
} else {
|
||||||
|
syncPlain(m, o, new Set(), new Set());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncOrder(sheet.get('objectOrder') as YArr, s.objects.map((o) => o.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Binding {
|
||||||
|
private events: StructureEvent[] = [];
|
||||||
|
|
||||||
|
constructor(private y: Y.Doc) {}
|
||||||
|
|
||||||
|
/** A row/column was inserted/deleted in the store (before the next sync). */
|
||||||
|
structure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1) {
|
||||||
|
this.events.push({ tableId, axis, at, delta });
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPending() {
|
||||||
|
return this.events.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bring Yjs in line with the store, as one local transaction. */
|
||||||
|
sync(d: DocumentState) {
|
||||||
|
const events = this.events;
|
||||||
|
this.events = [];
|
||||||
|
const root = this.y.getMap('numolus');
|
||||||
|
this.y.transact(() => {
|
||||||
|
if (!same(root.get('locale'), d.locale)) root.set('locale', d.locale);
|
||||||
|
if (!same(root.get('csvDelimiter'), d.csvDelimiter)) root.set('csvDelimiter', d.csvDelimiter);
|
||||||
|
|
||||||
|
const sheets = root.get('sheets') as Y.Map<unknown>;
|
||||||
|
const want = new Set(d.sheets.map((s) => s.id));
|
||||||
|
for (const id of [...sheets.keys()]) if (!want.has(id)) sheets.delete(id);
|
||||||
|
for (const s of d.sheets) {
|
||||||
|
const m = sheets.get(s.id) as YMap | undefined;
|
||||||
|
if (!m) {
|
||||||
|
sheets.set(s.id, buildSheet(s, randomIds));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
syncPlain(m, s, SHEET_STRUCT, new Set(['objectOrder', 'objects']));
|
||||||
|
syncObjects(m, s, events);
|
||||||
|
}
|
||||||
|
syncOrder(root.get('sheetOrder') as YArr, d.sheets.map((s) => s.id));
|
||||||
|
|
||||||
|
for (const [orderKey, itemsKey, field] of LISTS) {
|
||||||
|
const list = d[field] as { id: string }[];
|
||||||
|
syncKeyedMap(root.get(itemsKey) as Y.Map<unknown>, new Map(list.map((x) => [x.id, x])));
|
||||||
|
syncOrder(root.get(orderKey) as YArr, list.map((x) => x.id));
|
||||||
|
}
|
||||||
|
}, LOCAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,11 +143,11 @@ onMounted(() => {
|
|||||||
uploadOpen.value = true;
|
uploadOpen.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusNote = computed(() => {
|
const statusNote = computed(() =>
|
||||||
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
|
library.state.current?.source === 'server' && !library.online.value
|
||||||
if (library.state.syncState === 'saving') return 'Speichert …';
|
? 'Offline – Änderungen werden übertragen, sobald die Verbindung zurück ist'
|
||||||
return '';
|
: '',
|
||||||
});
|
);
|
||||||
|
|
||||||
// ---- document title (inline rename) ------------------------------------
|
// ---- document title (inline rename) ------------------------------------
|
||||||
const titleDraft = ref(library.currentTitle.value);
|
const titleDraft = ref(library.currentTitle.value);
|
||||||
@@ -439,10 +439,17 @@ async function onSample() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
|
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">Nur Lesen</span>
|
||||||
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
|
<span v-if="statusNote" class="status-note" :title="statusNote">Offline</span>
|
||||||
|
<span v-if="library.peers.value.length" class="peers" :title="library.peers.value.map((p) => p.name).join(', ')">
|
||||||
|
<span
|
||||||
|
v-for="p in library.peers.value.slice(0, 5)"
|
||||||
|
:key="p.clientId"
|
||||||
|
class="avatar peer"
|
||||||
|
:style="{ background: p.color }"
|
||||||
|
>{{ p.name.replace(/^Anonym \(/, '').slice(0, 1).toUpperCase() }}</span>
|
||||||
|
<span v-if="library.peers.value.length > 5" class="more">+{{ library.peers.value.length - 5 }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
|
|
||||||
|
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
|
|
||||||
@@ -748,6 +755,28 @@ async function onSample() {
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
.status-note {
|
.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);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.account {
|
.account {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||||
import { numberToCol, cycleReferenceLocks } from '../engine';
|
import { numberToCol, cycleReferenceLocks } from '../engine';
|
||||||
import { useDocument } from '../store/document';
|
import { useDocument } from '../store/document';
|
||||||
|
import { useLibrary } from '../store/library';
|
||||||
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
|
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
|
||||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||||
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
||||||
@@ -22,6 +23,7 @@ import FormulaSuggest from './FormulaSuggest.vue';
|
|||||||
const props = defineProps<{ tableId: string; selected: boolean }>();
|
const props = defineProps<{ tableId: string; selected: boolean }>();
|
||||||
|
|
||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
|
const library = useLibrary();
|
||||||
const table = computed(() => store.tableById(props.tableId));
|
const table = computed(() => store.tableById(props.tableId));
|
||||||
|
|
||||||
const DEFAULT_W = 104;
|
const DEFAULT_W = 104;
|
||||||
@@ -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
|
// ---- clipboard source marker (one dashed rectangle, Excel-style "marching
|
||||||
// ants" -- overlaid once over the whole range, not per cell). --------------
|
// ants" -- overlaid once over the whole range, not per cell). --------------
|
||||||
const clipRect = computed(() => store.clipboardRect.value);
|
const clipRect = computed(() => store.clipboardRect.value);
|
||||||
|
/** Where the others (live collaboration) have their selection in this table. */
|
||||||
|
const peerRects = computed(() =>
|
||||||
|
library.peers.value
|
||||||
|
.filter((p) => p.rect && p.tableId === props.tableId)
|
||||||
|
.map((p) => {
|
||||||
|
const k = p.rect!;
|
||||||
|
const left = colLeft(k.c0);
|
||||||
|
const top = rowTop(k.r0);
|
||||||
|
return {
|
||||||
|
id: p.clientId,
|
||||||
|
name: p.name,
|
||||||
|
style: {
|
||||||
|
left: left + 'px',
|
||||||
|
top: top + 'px',
|
||||||
|
width: colLeft(Math.min(k.c1, (table.value?.cols ?? 1) - 1) + 1) - left + 'px',
|
||||||
|
height: rowTop(Math.min(k.r1, (table.value?.rows ?? 1) - 1) + 1) - top + 'px',
|
||||||
|
borderColor: p.color,
|
||||||
|
'--peer': p.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
const clipMarqueeStyle = computed(() => {
|
const clipMarqueeStyle = computed(() => {
|
||||||
const k = clipRect.value;
|
const k = clipRect.value;
|
||||||
if (!k || k.tableId !== props.tableId) return null;
|
if (!k || k.tableId !== props.tableId) return null;
|
||||||
@@ -1411,6 +1435,9 @@ defineExpose({
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div v-for="p in peerRects" :key="p.id" class="peer-sel" :style="p.style">
|
||||||
|
<span class="peer-name">{{ p.name }}</span>
|
||||||
|
</div>
|
||||||
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
|
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
|
||||||
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
|
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
|
||||||
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
|
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
|
||||||
@@ -1578,6 +1605,26 @@ defineExpose({
|
|||||||
.cell.is-active {
|
.cell.is-active {
|
||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
}
|
}
|
||||||
|
/* someone else's selection (live collaboration) */
|
||||||
|
.peer-sel {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid;
|
||||||
|
z-index: 7;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.peer-name {
|
||||||
|
position: absolute;
|
||||||
|
top: -17px;
|
||||||
|
left: -2px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px 3px 3px 0;
|
||||||
|
background: var(--peer);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
/* one dashed rectangle around the whole copied range, not per cell */
|
/* one dashed rectangle around the whole copied range, not per cell */
|
||||||
.clip-marquee {
|
.clip-marquee {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+3
-20
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The numolus server API (Rails, /api). JSON in and out, the session cookie
|
* 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
|
* for the signed-in user, the CSRF token from the page's meta tag, and
|
||||||
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
|
* X-Share-Token (a share link's token, for access via link).
|
||||||
* and X-Share-Token (a share link's token, for access via link).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -36,7 +35,6 @@ export interface User {
|
|||||||
export interface ServerDocMeta {
|
export interface ServerDocMeta {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
version: number;
|
|
||||||
role: Role;
|
role: Role;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -44,8 +42,8 @@ export interface ServerDocMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerDoc extends ServerDocMeta {
|
export interface ServerDoc extends ServerDocMeta {
|
||||||
|
/** JSON snapshot (the live content comes through DocumentChannel) */
|
||||||
content?: string;
|
content?: string;
|
||||||
lock?: { locked_by_other: boolean; holder_name: string | null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShareInfo {
|
export interface ShareInfo {
|
||||||
@@ -54,20 +52,6 @@ export interface ShareInfo {
|
|||||||
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
|
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 {
|
function csrfToken(): string {
|
||||||
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||||
}
|
}
|
||||||
@@ -83,7 +67,6 @@ export async function api<T = Record<string, unknown>>(method: string, path: str
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-Token': csrfToken(),
|
'X-CSRF-Token': csrfToken(),
|
||||||
'X-Client-Id': clientId,
|
|
||||||
};
|
};
|
||||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* qualifier, so `Umsatz!B2` resolves the same from any sheet.
|
* 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 {
|
import {
|
||||||
Workbook,
|
Workbook,
|
||||||
toA1,
|
toA1,
|
||||||
@@ -91,6 +91,29 @@ export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) {
|
|||||||
persistHandler = fn;
|
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<boolean>;
|
||||||
|
canRedo: Ref<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
/** The part of a document that is *content* -- everything but the per-view
|
/** 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
|
* 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
|
* edit from a mere cursor move, and by the library for the same distinction
|
||||||
@@ -669,7 +692,10 @@ function createStore() {
|
|||||||
function scheduleSave() {
|
function scheduleSave() {
|
||||||
clearTimeout(persistTimer);
|
clearTimeout(persistTimer);
|
||||||
persistTimer = setTimeout(flushSave, 250);
|
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). */
|
/** Write a pending save right now (page hidden, switching documents). */
|
||||||
function flushSave() {
|
function flushSave() {
|
||||||
if (persistTimer === undefined) return;
|
if (persistTimer === undefined) return;
|
||||||
@@ -766,6 +792,7 @@ function createStore() {
|
|||||||
*/
|
*/
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
|
if (undoProvider.value) return; // shared document: undo lives in Yjs
|
||||||
if (!historyPending.value) {
|
if (!historyPending.value) {
|
||||||
redoStack.length = 0;
|
redoStack.length = 0;
|
||||||
historyTick.value++;
|
historyTick.value++;
|
||||||
@@ -789,10 +816,22 @@ function createStore() {
|
|||||||
historyTick.value++;
|
historyTick.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
|
const undoProvider = shallowRef<UndoProvider | null>(null);
|
||||||
const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
|
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() {
|
function undo() {
|
||||||
|
if (undoProvider.value) return undoProvider.value.undo();
|
||||||
commitHistoryStep(); // flush a pending (not yet coalesced) change first
|
commitHistoryStep(); // flush a pending (not yet coalesced) change first
|
||||||
const prev = undoStack.pop();
|
const prev = undoStack.pop();
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
@@ -806,6 +845,7 @@ function createStore() {
|
|||||||
historyTick.value++;
|
historyTick.value++;
|
||||||
}
|
}
|
||||||
function redo() {
|
function redo() {
|
||||||
|
if (undoProvider.value) return undoProvider.value.redo();
|
||||||
clearTimeout(historyTimer);
|
clearTimeout(historyTimer);
|
||||||
const next = redoStack.pop();
|
const next = redoStack.pop();
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
@@ -820,7 +860,8 @@ function createStore() {
|
|||||||
|
|
||||||
// ---- id / name helpers ------------------------------------------
|
// ---- id / name helpers ------------------------------------------
|
||||||
let seq = 0;
|
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>): string {
|
function uniqueName(base: string, taken: Set<string>): string {
|
||||||
const b = base.trim() || 'Element';
|
const b = base.trim() || 'Element';
|
||||||
@@ -2233,6 +2274,7 @@ function createStore() {
|
|||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows));
|
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);
|
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1);
|
||||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
|
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
|
||||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
|
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
|
||||||
@@ -2247,6 +2289,7 @@ function createStore() {
|
|||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
if (!t || t.rows <= 1) return;
|
if (!t || t.rows <= 1) return;
|
||||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1));
|
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);
|
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1);
|
||||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
|
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
|
||||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
|
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
|
||||||
@@ -2263,6 +2306,7 @@ function createStore() {
|
|||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols));
|
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);
|
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1);
|
||||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
|
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
|
||||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
|
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
|
||||||
@@ -2277,6 +2321,7 @@ function createStore() {
|
|||||||
const t = tableById(tableId);
|
const t = tableById(tableId);
|
||||||
if (!t || t.cols <= 1) return;
|
if (!t || t.cols <= 1) return;
|
||||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1));
|
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);
|
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1);
|
||||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
|
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
|
||||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, 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.
|
// as a new edit. Persistence, unlike undo history, has no such carve-out.
|
||||||
scheduleSave();
|
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
|
/** Loading a different document outright (JSON import "replace", "reset to
|
||||||
* seed") -- unlike undo/redo, this starts a fresh undo history rather
|
* seed") -- unlike undo/redo, this starts a fresh undo history rather
|
||||||
* than leaving the previous document's edits undoable into the new one. */
|
* than leaving the previous document's edits undoable into the new one. */
|
||||||
@@ -3159,6 +3235,8 @@ function createStore() {
|
|||||||
flushSave,
|
flushSave,
|
||||||
hasPendingSave,
|
hasPendingSave,
|
||||||
cancelSave,
|
cancelSave,
|
||||||
|
applyExternalState,
|
||||||
|
setUndoProvider,
|
||||||
engine: () => wb,
|
engine: () => wb,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+86
-231
@@ -10,16 +10,16 @@
|
|||||||
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
||||||
* the token is kept in this browser (localStorage) and sent along.
|
* the token is kept in this browser (localStorage) and sent along.
|
||||||
*
|
*
|
||||||
* Persistence: the store's debounced save lands in `persist()` and goes to
|
* Local documents are saved by the store's debounced save (`persist()`).
|
||||||
* IndexedDB or to the server. Server documents have a single-editor lock
|
* Server documents are edited live, together with everyone who has them
|
||||||
* (until live collaboration exists): whoever holds it edits, everyone else
|
* open: a CollabSession (src/collab/session.ts) keeps the store and the
|
||||||
* sees the document read-only and follows its changes by polling.
|
* shared Yjs document in step over ActionCable.
|
||||||
*
|
*
|
||||||
* Other tabs: a BroadcastChannel keeps lists current and lets a local
|
* Other tabs: a BroadcastChannel keeps lists current and lets a local
|
||||||
* document open in two tabs follow the other tab's saves.
|
* 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 { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document';
|
||||||
import type { DocumentState } from './types';
|
import type { DocumentState } from './types';
|
||||||
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
||||||
@@ -28,6 +28,7 @@ import { validateDocument } from '../io/json';
|
|||||||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||||
import { useToasts } from '../composables/useToasts';
|
import { useToasts } from '../composables/useToasts';
|
||||||
import { useSession } from './session';
|
import { useSession } from './session';
|
||||||
|
import { CollabSession } from '../collab/session';
|
||||||
|
|
||||||
/** The single-document localStorage keys used before the library existed --
|
/** The single-document localStorage keys used before the library existed --
|
||||||
* read once and moved into the library as its first document. */
|
* 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 CHANNEL = 'numolus:library';
|
||||||
const DEFAULT_TITLE = 'Unbenannt';
|
const DEFAULT_TITLE = 'Unbenannt';
|
||||||
const APP_TITLE = 'numolus';
|
const APP_TITLE = 'numolus';
|
||||||
const UPLOAD_DELAY = 800; // ms after the last change
|
const ANON_COLORS = ['#e67e22', '#2980b9', '#27ae60', '#8e44ad', '#c0392b', '#16a085'];
|
||||||
const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s)
|
const ANON_NAMES = ['Fuchs', 'Dachs', 'Luchs', 'Otter', 'Eule', 'Biber', 'Reh', 'Igel'];
|
||||||
const POLL = 10_000; // read-only: look for someone else's changes
|
|
||||||
|
|
||||||
export type DocSource = 'local' | 'server';
|
export type DocSource = 'local' | 'server';
|
||||||
export interface DocRef {
|
export interface DocRef {
|
||||||
@@ -61,8 +61,8 @@ type Msg =
|
|||||||
| { type: 'here'; id: string };
|
| { type: 'here'; id: string };
|
||||||
|
|
||||||
function metaOf(d: ServerDoc): ServerDocMeta {
|
function metaOf(d: ServerDoc): ServerDocMeta {
|
||||||
const { id, title, version, role, owner_name, updated_at, updated_by_name } = d;
|
const { id, title, role, owner_name, updated_at, updated_by_name } = d;
|
||||||
return { id, title, version, role, owner_name, updated_at, updated_by_name };
|
return { id, title, role, owner_name, updated_at, updated_by_name };
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLinks(): LinkDoc[] {
|
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 = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)];
|
||||||
|
return { name: `Anonym (${pick(ANON_NAMES)})`, color: pick(ANON_COLORS) };
|
||||||
|
}
|
||||||
|
|
||||||
function createLibrary() {
|
function createLibrary() {
|
||||||
const store = useDocument();
|
const store = useDocument();
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
@@ -96,13 +103,12 @@ function createLibrary() {
|
|||||||
/** documents opened via share link without an account */
|
/** documents opened via share link without an account */
|
||||||
linkDocs: readLinks(),
|
linkDocs: readLinks(),
|
||||||
current: null as DocRef | null,
|
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,
|
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<CollabSession | null>(null);
|
||||||
|
const identity = presenceIdentity(session.state.user);
|
||||||
|
|
||||||
const currentTitle = computed(() => {
|
const currentTitle = computed(() => {
|
||||||
const c = state.current;
|
const c = state.current;
|
||||||
@@ -114,11 +120,14 @@ function createLibrary() {
|
|||||||
const currentRole = computed<Role>(() =>
|
const currentRole = computed<Role>(() =>
|
||||||
state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner',
|
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 lib: DocumentLibrary | null = null;
|
||||||
let channel: BroadcastChannel | 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 = '';
|
let savedContent = '';
|
||||||
/** `updatedAt` of the open local document as this tab last wrote or read it */
|
/** `updatedAt` of the open local document as this tab last wrote or read it */
|
||||||
let knownUpdatedAt = 0;
|
let knownUpdatedAt = 0;
|
||||||
@@ -126,15 +135,6 @@ function createLibrary() {
|
|||||||
let writeQueue: Promise<void> = Promise.resolve();
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
const warnedShared = new Set<string>();
|
const warnedShared = new Set<string>();
|
||||||
|
|
||||||
// server document state
|
|
||||||
let uploadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let uploading: Promise<void> | null = null;
|
|
||||||
let pendingJson: string | null = null;
|
|
||||||
let pendingFp = '';
|
|
||||||
let holdsLock = false;
|
|
||||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
||||||
|
|
||||||
const post = (m: Msg) => channel?.postMessage(m);
|
const post = (m: Msg) => channel?.postMessage(m);
|
||||||
const enqueue = (job: () => Promise<void>) => {
|
const enqueue = (job: () => Promise<void>) => {
|
||||||
writeQueue = writeQueue.then(job).catch((err) => {
|
writeQueue = writeQueue.then(job).catch((err) => {
|
||||||
@@ -188,19 +188,15 @@ function createLibrary() {
|
|||||||
|
|
||||||
function applyReadOnly() {
|
function applyReadOnly() {
|
||||||
store.ui.readOnly =
|
store.ui.readOnly =
|
||||||
state.current?.source !== 'server'
|
state.current?.source === 'server' && currentRole.value === 'viewer'
|
||||||
? null
|
|
||||||
: currentRole.value === 'viewer'
|
|
||||||
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
||||||
: state.lockedBy
|
|
||||||
? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.`
|
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Put `next` into the store as the open document. */
|
/** Put `next` into the store as the open document. */
|
||||||
function show(ref: DocRef, next: DocumentState, keepView = false) {
|
function show(ref: DocRef, next: DocumentState, keepView = false) {
|
||||||
if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
|
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.activeSheetId = store.doc.activeSheetId;
|
||||||
next.activeObjectId = store.doc.activeObjectId;
|
next.activeObjectId = store.doc.activeObjectId;
|
||||||
next.selection = store.doc.selection;
|
next.selection = store.doc.selection;
|
||||||
@@ -213,12 +209,10 @@ function createLibrary() {
|
|||||||
updateWindowTitle();
|
updateWindowTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- persistence -----------------------------------------------------
|
// ---- persistence (local documents) ---------------------------------------
|
||||||
function persist(doc: DocumentState) {
|
function persist(doc: DocumentState) {
|
||||||
const c = state.current;
|
const c = state.current;
|
||||||
if (!c) return;
|
if (!c || c.source !== 'local' || !lib) return; // server documents: CollabSession
|
||||||
if (c.source === 'server') return persistServer(doc);
|
|
||||||
if (!lib) return;
|
|
||||||
const id = c.id;
|
const id = c.id;
|
||||||
const json = JSON.stringify(doc);
|
const json = JSON.stringify(doc);
|
||||||
const fp = contentFingerprint(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<void> {
|
|
||||||
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<boolean> {
|
|
||||||
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 -----------------------------------------------
|
// ---- leaving / opening -----------------------------------------------
|
||||||
async function leaveCurrent() {
|
async function leaveCurrent() {
|
||||||
const c = state.current;
|
const c = state.current;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (c.source === 'server') {
|
if (c.source === 'server') {
|
||||||
await flushServer();
|
collab.value?.destroy();
|
||||||
stopServerTimers();
|
collab.value = null;
|
||||||
releaseLock(c.id);
|
store.setUndoProvider(null);
|
||||||
state.lockedBy = null;
|
|
||||||
state.server = null;
|
state.server = null;
|
||||||
} else {
|
} else {
|
||||||
store.flushSave();
|
store.flushSave();
|
||||||
@@ -451,9 +278,10 @@ function createLibrary() {
|
|||||||
|
|
||||||
async function openServer(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
async function openServer(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||||
if (isCurrent({ source: 'server', id })) return true;
|
if (isCurrent({ source: 'server', id })) return true;
|
||||||
let r: { document: ServerDoc };
|
let meta: ServerDocMeta;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
toasts.error(
|
toasts.error(
|
||||||
err instanceof ApiError && err.status === 404
|
err instanceof ApiError && err.status === 404
|
||||||
@@ -462,20 +290,47 @@ function createLibrary() {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { content } = r.document;
|
const ref: DocRef = { source: 'server', id };
|
||||||
const meta = metaOf(r.document);
|
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;
|
let next: DocumentState;
|
||||||
try {
|
try {
|
||||||
next = validateDocument(JSON.parse(content ?? ''));
|
next = await s.start();
|
||||||
} catch (err) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
await leaveCurrent();
|
await leaveCurrent();
|
||||||
state.server = meta;
|
state.server = { ...meta, role: s.role };
|
||||||
state.lockedBy = null;
|
collab.value = s;
|
||||||
holdsLock = false;
|
show(ref, next);
|
||||||
show({ source: 'server', id }, next);
|
store.setUndoProvider(s.attach());
|
||||||
// remember the title for link-only documents
|
// remember the title for link-only documents
|
||||||
const link = state.linkDocs.find((l) => l.id === id);
|
const link = state.linkDocs.find((l) => l.id === id);
|
||||||
if (link && (link.title !== meta.title || link.role !== meta.role)) {
|
if (link && (link.title !== meta.title || link.role !== meta.role)) {
|
||||||
@@ -483,9 +338,7 @@ function createLibrary() {
|
|||||||
writeLinks(state.linkDocs);
|
writeLinks(state.linkDocs);
|
||||||
}
|
}
|
||||||
applyReadOnly();
|
applyReadOnly();
|
||||||
if (meta.role !== 'viewer') await takeLock(id);
|
setUrl(ref, opts.push ?? true);
|
||||||
startServerTimers();
|
|
||||||
setUrl({ source: 'server', id }, opts.push ?? true);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,11 +408,10 @@ function createLibrary() {
|
|||||||
|
|
||||||
async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> {
|
async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> {
|
||||||
if (isCurrent(ref)) {
|
if (isCurrent(ref)) {
|
||||||
if (ref.source === 'server') await flushServer();
|
// the open document: straight from the store, always current
|
||||||
else {
|
|
||||||
store.flushSave();
|
store.flushSave();
|
||||||
await writeQueue;
|
await writeQueue;
|
||||||
}
|
return { title: currentTitle.value, json: JSON.stringify(store.doc) };
|
||||||
}
|
}
|
||||||
if (ref.source === 'server') {
|
if (ref.source === 'server') {
|
||||||
const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) });
|
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
|
* this browser once it's safely on the server; an open one is reopened
|
||||||
* from there. Returns the server id of each uploaded document.
|
* from there. Returns the server id of each uploaded document.
|
||||||
*/
|
*/
|
||||||
async function upload_(localIds: string[]): Promise<Map<string, string>> {
|
async function upload(localIds: string[]): Promise<Map<string, string>> {
|
||||||
const done = new Map<string, string>();
|
const done = new Map<string, string>();
|
||||||
if (!lib || !session.signedIn.value) return done;
|
if (!lib || !session.signedIn.value) return done;
|
||||||
for (const id of localIds) {
|
for (const id of localIds) {
|
||||||
@@ -812,16 +664,17 @@ function createLibrary() {
|
|||||||
// don't lose the last moments of typing when the tab goes away
|
// don't lose the last moments of typing when the tab goes away
|
||||||
window.addEventListener('pagehide', () => {
|
window.addEventListener('pagehide', () => {
|
||||||
store.flushSave();
|
store.flushSave();
|
||||||
if (state.current?.source === 'server') {
|
collab.value?.destroy();
|
||||||
if (pendingJson !== null) void upload();
|
|
||||||
releaseLock(state.current.id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
window.addEventListener('beforeunload', (e) => {
|
// back from the browser's page cache: the live session is gone
|
||||||
if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes
|
window.addEventListener('pageshow', (e) => {
|
||||||
|
if (e.persisted && state.current?.source === 'server') location.reload();
|
||||||
});
|
});
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') store.flushSave();
|
if (document.visibilityState === 'hidden') {
|
||||||
|
store.flushSave();
|
||||||
|
collab.value?.flushLocal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
state.ready = true;
|
state.ready = true;
|
||||||
}
|
}
|
||||||
@@ -831,6 +684,8 @@ function createLibrary() {
|
|||||||
currentTitle,
|
currentTitle,
|
||||||
currentRole,
|
currentRole,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
peers,
|
||||||
|
online,
|
||||||
init,
|
init,
|
||||||
open,
|
open,
|
||||||
create,
|
create,
|
||||||
@@ -839,7 +694,7 @@ function createLibrary() {
|
|||||||
rename,
|
rename,
|
||||||
duplicate,
|
duplicate,
|
||||||
remove,
|
remove,
|
||||||
upload: upload_,
|
upload,
|
||||||
refreshServerList,
|
refreshServerList,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-26
@@ -1,6 +1,5 @@
|
|||||||
class Document < ApplicationRecord
|
class Document < ApplicationRecord
|
||||||
ROLES = %w[viewer editor owner].freeze
|
ROLES = %w[viewer editor owner].freeze
|
||||||
LOCK_TTL = 90.seconds
|
|
||||||
MAX_CONTENT_BYTES = 20.megabytes
|
MAX_CONTENT_BYTES = 20.megabytes
|
||||||
|
|
||||||
belongs_to :owner, class_name: "User"
|
belongs_to :owner, class_name: "User"
|
||||||
@@ -8,6 +7,7 @@ class Document < ApplicationRecord
|
|||||||
has_many :document_memberships, dependent: :delete_all
|
has_many :document_memberships, dependent: :delete_all
|
||||||
has_many :members, through: :document_memberships, source: :user
|
has_many :members, through: :document_memberships, source: :user
|
||||||
has_many :share_links, dependent: :delete_all
|
has_many :share_links, dependent: :delete_all
|
||||||
|
has_many :updates, class_name: "DocumentUpdate", dependent: :delete_all
|
||||||
|
|
||||||
validates :title, presence: true, length: { maximum: 200 }
|
validates :title, presence: true, length: { maximum: 200 }
|
||||||
validates :content, presence: true
|
validates :content, presence: true
|
||||||
@@ -36,39 +36,28 @@ class Document < ApplicationRecord
|
|||||||
role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s)
|
role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s)
|
||||||
end
|
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:)
|
def meta_json(role:)
|
||||||
{
|
{
|
||||||
id:, title:, version:, role:,
|
id:, title:, role:,
|
||||||
owner_name: owner.name,
|
owner_name: owner.name,
|
||||||
updated_at: content_updated_at.iso8601(3),
|
updated_at: content_updated_at.iso8601(3),
|
||||||
updated_by_name: updated_by&.name
|
updated_by_name: updated_by&.name
|
||||||
}
|
}
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def content_is_document_json
|
def content_is_document_json
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,7 +14,6 @@ Rails.application.routes.draw do
|
|||||||
patch "me" => "me#update"
|
patch "me" => "me#update"
|
||||||
|
|
||||||
resources :documents, only: %i[index create show update destroy] do
|
resources :documents, only: %i[index create show update destroy] do
|
||||||
resource :lock, only: %i[create destroy], controller: "document_locks"
|
|
||||||
resource :join, only: :create
|
resource :join, only: :create
|
||||||
get "share" => "shares#show"
|
get "share" => "shares#show"
|
||||||
post "share/links" => "shares#create_link"
|
post "share/links" => "shares#create_link"
|
||||||
|
|||||||
@@ -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
|
||||||
+12
-5
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "citext"
|
enable_extension "citext"
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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"
|
t.index ["user_id"], name: "index_document_memberships_on_user_id"
|
||||||
end
|
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|
|
create_table "documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.bigint "owner_id", null: false
|
t.bigint "owner_id", null: false
|
||||||
t.string "title", null: false
|
t.string "title", null: false
|
||||||
t.text "content", null: false
|
t.text "content", null: false
|
||||||
t.integer "version", default: 1, null: false
|
|
||||||
t.bigint "updated_by_id"
|
t.bigint "updated_by_id"
|
||||||
t.datetime "content_updated_at", null: false
|
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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["owner_id"], name: "index_documents_on_owner_id"
|
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", "documents", on_delete: :cascade
|
||||||
add_foreign_key "document_memberships", "share_links", 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_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: "owner_id", on_delete: :cascade
|
||||||
add_foreign_key "documents", "users", column: "updated_by_id", on_delete: :nullify
|
add_foreign_key "documents", "users", column: "updated_by_id", on_delete: :nullify
|
||||||
add_foreign_key "magic_links", "documents", on_delete: :cascade
|
add_foreign_key "magic_links", "documents", on_delete: :cascade
|
||||||
|
|||||||
@@ -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 |
|
| Thema | Umsetzung | Abweichung vom Konzept |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Anonym | Dokumente bleiben lokal in IndexedDB (`/?doc=<id>`) | kein Gast-Konto auf dem Server |
|
| Anonym | Dokumente bleiben lokal in IndexedDB (`/?doc=<id>`) | kein Gast-Konto auf dem Server |
|
||||||
| Konto | Magic Link + 6-stelliger Code, Konto entsteht bei der ersten Anmeldung | – |
|
| 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 |
|
| Ü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/<id>`; mit Konto werden neue Dokumente auf dem Server angelegt | Inhalt noch nicht als Yjs-Stand |
|
| Server-Dokumente | Inhalt = Yjs-Updates (`document_updates`), `documents.content` als JSON-Snapshot (Export, Duplizieren, erste Umwandlung); `/d/<id>` | – |
|
||||||
| 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 |
|
| 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 |
|
| 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 | – |
|
| Einladungen | Mail mit Link (7 Tage gültig), meldet gleichzeitig an | – |
|
||||||
| Sessions | signierte Session-ID im Cookie (Rails-8-Standard) | statt Token-Digest |
|
| Sessions | signierte Session-ID im Cookie (Rails-8-Standard) | statt Token-Digest |
|
||||||
|
|||||||
Generated
+87
-1
@@ -8,12 +8,17 @@
|
|||||||
"name": "numolus",
|
"name": "numolus",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"chart.js": "^4.4.6",
|
"chart.js": "^4.4.6",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
|
"lib0": "^0.2.119",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"y-protocols": "^1.0.7",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/rails__actioncable": "^8.0.3",
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
"@vue/eslint-config-prettier": "^10.1.0",
|
"@vue/eslint-config-prettier": "^10.1.0",
|
||||||
"@vue/eslint-config-typescript": "^14.1.3",
|
"@vue/eslint-config-typescript": "^14.1.3",
|
||||||
@@ -934,6 +939,12 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.5",
|
"version": "4.63.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
|
||||||
@@ -1298,6 +1309,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.68.0",
|
"version": "8.68.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
||||||
@@ -3349,6 +3367,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
@@ -3468,6 +3496,27 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
@@ -5146,6 +5195,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
+6
-1
@@ -14,12 +14,17 @@
|
|||||||
"format": "prettier --write \"app/frontend/**/*.{ts,vue,css}\""
|
"format": "prettier --write \"app/frontend/**/*.{ts,vue,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rails/actioncable": "^8.1.400",
|
||||||
"chart.js": "^4.4.6",
|
"chart.js": "^4.4.6",
|
||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
|
"lib0": "^0.2.119",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"y-protocols": "^1.0.7",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/rails__actioncable": "^8.0.3",
|
||||||
"@vitejs/plugin-vue": "^5.2.0",
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
"@vue/eslint-config-prettier": "^10.1.0",
|
"@vue/eslint-config-prettier": "^10.1.0",
|
||||||
"@vue/eslint-config-typescript": "^14.1.3",
|
"@vue/eslint-config-typescript": "^14.1.3",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class DocumentsApiTest < ActionDispatch::IntegrationTest
|
class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||||
|
include ActionCable::TestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@anna = create_user("anna@example.com")
|
@anna = create_user("anna@example.com")
|
||||||
@bob = create_user("bob@example.com")
|
@bob = create_user("bob@example.com")
|
||||||
@doc = create_document(@anna)
|
@doc = create_document(@anna)
|
||||||
end
|
end
|
||||||
|
|
||||||
def put_content(doc, base_version, headers: {})
|
def rename(doc, title, headers: {})
|
||||||
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
|
patch "/api/documents/#{doc.id}", params: { title: }, headers:, as: :json
|
||||||
end
|
end
|
||||||
|
|
||||||
test "listing and creating need an account" do
|
test "listing and creating need an account" do
|
||||||
@@ -38,39 +40,6 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :not_found
|
assert_response :not_found
|
||||||
end
|
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
|
test "share link without account: read with token; viewer cannot write" do
|
||||||
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
|
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
|
||||||
headers = { "X-Share-Token" => link.token }
|
headers = { "X-Share-Token" => link.token }
|
||||||
@@ -79,7 +48,7 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "viewer", json.dig("document", "role")
|
assert_equal "viewer", json.dig("document", "role")
|
||||||
get "/api/documents/#{@doc.id}", headers: headers
|
get "/api/documents/#{@doc.id}", headers: headers
|
||||||
assert_response :success
|
assert_response :success
|
||||||
put_content(@doc, 1, headers:)
|
rename(@doc, "Neu", headers:)
|
||||||
assert_response :forbidden
|
assert_response :forbidden
|
||||||
|
|
||||||
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
|
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
|
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
|
||||||
get "/api/documents"
|
get "/api/documents"
|
||||||
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
|
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
|
||||||
put_content(@doc, 1)
|
rename(@doc, "Von Bob")
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
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
|
test "owner deletes; a member only leaves" do
|
||||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||||
sign_in @bob
|
sign_in @bob
|
||||||
|
|||||||
@@ -49,15 +49,4 @@ class DocumentTest < ActiveSupport::TestCase
|
|||||||
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link)
|
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link)
|
||||||
assert_equal :editor, @doc.role_for(user: bob)
|
assert_equal :editor, @doc.role_for(user: bob)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user