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>
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|