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>
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|