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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user