/** * The shared (Yjs) form of a numolus document, and the two directions * between it and the store's plain `DocumentState`: * * - `readDocument(ydoc)` Yjs → DocumentState (after someone else's change) * - `Binding.sync(state)` DocumentState → Yjs, as the *smallest* set of * changes (a cell, a property), so that edits by * different people merge instead of overwriting * * Layout (root = ydoc.getMap('numolus')): * * locale, csvDelimiter plain values * sheetOrder: Y.Array sheets: Y.Map * sheet: name, locale, background, ... plain values (zoom stays local) * objectOrder: Y.Array objects: Y.Map * object: kind, name, x, y, ... plain values * table additionally: * rowOrder / colOrder: Y.Array * cells: Y.Map<"colId|rowId", raw input> * formats: Y.Map<"colId|rowId", CellFormat> * colWidths: Y.Map rowHeights: Y.Map * fnOrder/fns, nameOrder/names, macroOrder/macros id-keyed lists * * Rows and columns have stable ids: the store addresses cells by position * ("col,row"), Yjs by id. Inserting a row inserts one id into `rowOrder` -- * no cell moves -- so someone typing into row 7 while someone else inserts * a row above still lands in the row they meant. The store reports such * inserts/deletes explicitly (`Binding.structure`), because a diff of * positional cells can't tell "row inserted" from "every cell below changed". */ import * as Y from 'yjs'; import type { CustomFunction, DocumentState, Macro, NamedRange, Sheet, SheetObject, TableObject, } from '../store/types'; /** transaction origin of this person's own edits (tracked by undo) */ export const LOCAL = Symbol('local'); /** transaction origin of changes received from the server */ export const REMOTE = Symbol('remote'); type YMap = Y.Map; type YArr = Y.Array; const TABLE_STRUCT = new Set(['rows', 'cols', 'cells', 'formats', 'colWidths', 'rowHeights']); const SHEET_STRUCT = new Set(['id', 'objects', 'zoom']); const LISTS = [ ['fnOrder', 'fns', 'customFunctions'], ['nameOrder', 'names', 'namedRanges'], ['macroOrder', 'macros', 'macros'], ] as const; const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b); const randomId = (p: string) => p + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6); // ---- building ------------------------------------------------------------ type IdGen = (axis: 'r' | 'c', index: number) => string; const deterministicIds: IdGen = (axis, i) => `${axis}${i}`; const randomIds: IdGen = (axis) => randomId(axis); function setPlain(m: YMap, obj: object, skip: Set) { for (const [k, v] of Object.entries(obj)) { if (skip.has(k) || v === undefined) continue; m.set(k, v); } } function buildTable(m: YMap, t: TableObject, ids: IdGen) { const rowIds = Array.from({ length: t.rows }, (_, i) => ids('r', i)); const colIds = Array.from({ length: t.cols }, (_, i) => ids('c', i)); const rowOrder = new Y.Array(); rowOrder.push(rowIds); const colOrder = new Y.Array(); colOrder.push(colIds); m.set('rowOrder', rowOrder); m.set('colOrder', colOrder); const cells = new Y.Map(); const formats = new Y.Map(); const colWidths = new Y.Map(); const rowHeights = new Y.Map(); const key = (k: string) => { const [c, r] = k.split(',').map(Number); return c < colIds.length && r < rowIds.length ? `${colIds[c]}|${rowIds[r]}` : null; }; for (const [k, v] of Object.entries(t.cells)) { const id = key(k); if (id) cells.set(id, v); } for (const [k, v] of Object.entries(t.formats ?? {})) { const id = key(k); if (id) formats.set(id, v); } for (const [c, w] of Object.entries(t.colWidths ?? {})) if (colIds[+c]) colWidths.set(colIds[+c], w); for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rowIds[+r]) rowHeights.set(rowIds[+r], h); m.set('cells', cells); m.set('formats', formats); m.set('colWidths', colWidths); m.set('rowHeights', rowHeights); } function buildObject(o: SheetObject, ids: IdGen): YMap { const m = new Y.Map(); if (o.kind === 'table') { setPlain(m, o, TABLE_STRUCT); buildTable(m, o, ids); } else { setPlain(m, o, new Set()); } return m; } function buildSheet(s: Sheet, ids: IdGen): YMap { const m = new Y.Map(); setPlain(m, s, SHEET_STRUCT); m.set('id', s.id); const order = new Y.Array(); order.push(s.objects.map((o) => o.id)); const objects = new Y.Map(); for (const o of s.objects) objects.set(o.id, buildObject(o, ids)); m.set('objectOrder', order); m.set('objects', objects); return m; } function build(root: YMap, d: DocumentState, ids: IdGen) { root.set('locale', d.locale); root.set('csvDelimiter', d.csvDelimiter); const order = new Y.Array(); order.push(d.sheets.map((s) => s.id)); const sheets = new Y.Map(); for (const s of d.sheets) sheets.set(s.id, buildSheet(s, ids)); root.set('sheetOrder', order); root.set('sheets', sheets); for (const [orderKey, itemsKey, field] of LISTS) { const list = d[field] as { id: string }[]; const o = new Y.Array(); o.push(list.map((x) => x.id)); const items = new Y.Map(); for (const x of list) items.set(x.id, x); root.set(orderKey, o); root.set(itemsKey, items); } } /** * The Yjs update that turns an empty document into `d`. Deterministic: the * same `d` always gives byte-identical output (fixed client id, row/column * ids `r0…`, `c0…`), so two people opening a not-yet-converted document at * the same moment produce the same update, which Yjs merges into one. */ export function initialUpdate(d: DocumentState): Uint8Array { const y = new Y.Doc(); y.clientID = 1; y.transact(() => build(y.getMap('numolus'), d, deterministicIds)); return Y.encodeStateAsUpdate(y); } export const isInitialized = (y: Y.Doc) => y.getMap('numolus').has('sheets'); // ---- reading ---------------------------------------------------------------- function orderedItems(order: YArr | undefined, items: Y.Map | undefined): T[] { if (!order || !items) return []; const seen = new Set(); const out: T[] = []; for (const id of order.toArray()) { if (seen.has(id) || !items.has(id)) continue; // a concurrent double insert seen.add(id); out.push(items.get(id) as T); } return out; } export interface Axes { rows: string[]; cols: string[]; } /** Row/column ids of a table (duplicates from concurrent moves dropped). */ export function axesOf(t: YMap): Axes { const dedupe = (a: string[]) => [...new Set(a)]; return { rows: dedupe((t.get('rowOrder') as YArr).toArray()), cols: dedupe((t.get('colOrder') as YArr).toArray()), }; } function readTable(m: YMap): TableObject { const t: Record = {}; for (const [k, v] of m.entries()) { if (!(v instanceof Y.AbstractType)) t[k] = v; } const { rows, cols } = axesOf(m); const ri = new Map(rows.map((id, i) => [id, i])); const ci = new Map(cols.map((id, i) => [id, i])); const pos = (key: string) => { const [c, r] = key.split('|'); const cc = ci.get(c); const rr = ri.get(r); return cc === undefined || rr === undefined ? null : `${cc},${rr}`; }; const cells: Record = {}; for (const [k, v] of (m.get('cells') as Y.Map).entries()) { const p = pos(k); if (p) cells[p] = v; } const formats: Record = {}; for (const [k, v] of (m.get('formats') as Y.Map).entries()) { const p = pos(k); if (p) formats[p] = v; } const colWidths: Record = {}; for (const [k, v] of (m.get('colWidths') as Y.Map).entries()) { const c = ci.get(k); if (c !== undefined) colWidths[c] = v; } const rowHeights: Record = {}; for (const [k, v] of (m.get('rowHeights') as Y.Map).entries()) { const r = ri.get(k); if (r !== undefined) rowHeights[r] = v; } t.rows = Math.max(1, rows.length); t.cols = Math.max(1, cols.length); t.cells = cells; t.colWidths = colWidths; if (Object.keys(formats).length) t.formats = formats; if (Object.keys(rowHeights).length) t.rowHeights = rowHeights; return t as unknown as TableObject; } function readObject(m: YMap): SheetObject { if (m.get('kind') === 'table') return readTable(m); return Object.fromEntries(m.entries()) as unknown as SheetObject; } function readSheet(m: YMap): Sheet { const s: Record = {}; for (const [k, v] of m.entries()) { if (!(v instanceof Y.AbstractType)) s[k] = v; } s.objects = orderedItems(m.get('objectOrder') as YArr, m.get('objects') as Y.Map).map(readObject); return s as unknown as Sheet; } /** The whole document as the store holds it (view fields left at defaults). */ export function readDocument(y: Y.Doc): DocumentState { const root = y.getMap('numolus'); const sheets = orderedItems(root.get('sheetOrder') as YArr, root.get('sheets') as Y.Map).map(readSheet); const list = (o: string, i: string) => orderedItems(root.get(o) as YArr, root.get(i) as Y.Map); return { version: 2, sheets, activeSheetId: sheets[0]?.id ?? '', activeObjectId: null, selection: null, locale: (root.get('locale') as DocumentState['locale']) ?? 'de-DE', csvDelimiter: (root.get('csvDelimiter') as ',' | ';') ?? ',', customFunctions: list('fnOrder', 'fns'), namedRanges: list('nameOrder', 'names'), macros: list('macroOrder', 'macros'), }; } /** Current row/column ids of every table (for mapping positions to ids). */ export function allAxes(y: Y.Doc): Map { const out = new Map(); const sheets = y.getMap('numolus').get('sheets') as Y.Map | undefined; if (!sheets) return out; for (const s of sheets.values() as IterableIterator) { for (const [id, o] of (s.get('objects') as Y.Map).entries() as IterableIterator<[string, YMap]>) { if (o.get('kind') === 'table') out.set(id, axesOf(o)); } } return out; } // ---- writing: store state → Yjs, as small changes ------------------------------ interface StructureEvent { tableId: string; axis: 'row' | 'col'; at: number; delta: 1 | -1; } function syncPlain(m: YMap, obj: object, skip: Set, keepKeys: Set) { const entries = obj as Record; for (const [k, v] of Object.entries(entries)) { if (skip.has(k)) continue; if (v === undefined) { if (m.has(k)) m.delete(k); } else if (!same(m.get(k), v)) { m.set(k, v); } } for (const k of [...m.keys()]) { if (!keepKeys.has(k) && !skip.has(k) && !(k in entries)) m.delete(k); } } /** Make `order` equal `ids`, touching only what differs. */ function syncOrder(order: YArr, ids: string[]) { const want = new Set(ids); const cur = order.toArray(); for (let i = cur.length - 1; i >= 0; i--) { if (!want.has(cur[i]) || cur.indexOf(cur[i]) !== i) order.delete(i, 1); } for (let i = 0; i < ids.length; i++) { const now = order.toArray(); if (now[i] === ids[i]) continue; const j = now.indexOf(ids[i]); if (j >= 0) order.delete(j, 1); // moved order.insert(i, [ids[i]]); } } function syncKeyedMap(ymap: Y.Map, want: Map) { for (const [k, v] of want) if (!same(ymap.get(k), v)) ymap.set(k, v); for (const k of [...ymap.keys()]) if (!want.has(k)) ymap.delete(k); } function syncTable(m: YMap, t: TableObject, events: StructureEvent[]) { syncPlain(m, t, TABLE_STRUCT, new Set(['rowOrder', 'colOrder', 'cells', 'formats', 'colWidths', 'rowHeights'])); const rowOrder = m.get('rowOrder') as YArr; const colOrder = m.get('colOrder') as YArr; // mid-table inserts/deletes first, in the order they happened ... for (const e of events) { const order = e.axis === 'row' ? rowOrder : colOrder; if (e.delta > 0) order.insert(Math.min(e.at, order.length), [randomId(e.axis === 'row' ? 'r' : 'c')]); else if (e.at < order.length) order.delete(e.at, 1); } // ... then edge growth/shrink (addRows, setTableRows, ...) const fit = (order: YArr, n: number, p: 'r' | 'c') => { if (order.length < n) order.push(Array.from({ length: n - order.length }, () => randomId(p))); else if (order.length > n) order.delete(n, order.length - n); }; fit(rowOrder, t.rows, 'r'); fit(colOrder, t.cols, 'c'); const { rows, cols } = axesOf(m); const key = (k: string) => { const [c, r] = k.split(',').map(Number); return cols[c] !== undefined && rows[r] !== undefined ? `${cols[c]}|${rows[r]}` : null; }; const cells = new Map(); for (const [k, v] of Object.entries(t.cells)) { const id = key(k); if (id) cells.set(id, v); } syncKeyedMap(m.get('cells') as Y.Map, cells); const formats = new Map(); for (const [k, v] of Object.entries(t.formats ?? {})) { const id = key(k); if (id) formats.set(id, v); } syncKeyedMap(m.get('formats') as Y.Map, formats); const widths = new Map(); for (const [c, w] of Object.entries(t.colWidths ?? {})) if (cols[+c]) widths.set(cols[+c], w); syncKeyedMap(m.get('colWidths') as Y.Map, widths); const heights = new Map(); for (const [r, h] of Object.entries(t.rowHeights ?? {})) if (rows[+r]) heights.set(rows[+r], h); syncKeyedMap(m.get('rowHeights') as Y.Map, heights); } function syncObjects(sheet: YMap, s: Sheet, events: StructureEvent[]) { const objects = sheet.get('objects') as Y.Map; const want = new Set(s.objects.map((o) => o.id)); for (const id of [...objects.keys()]) if (!want.has(id)) objects.delete(id); for (const o of s.objects) { const m = objects.get(o.id) as YMap | undefined; if (!m || m.get('kind') !== o.kind) { objects.set(o.id, buildObject(o, randomIds)); } else if (o.kind === 'table') { syncTable(m, o, events.filter((e) => e.tableId === o.id)); } else { syncPlain(m, o, new Set(), new Set()); } } syncOrder(sheet.get('objectOrder') as YArr, s.objects.map((o) => o.id)); } export class Binding { private events: StructureEvent[] = []; constructor(private y: Y.Doc) {} /** A row/column was inserted/deleted in the store (before the next sync). */ structure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1) { this.events.push({ tableId, axis, at, delta }); } get hasPending() { return this.events.length > 0; } /** Bring Yjs in line with the store, as one local transaction. */ sync(d: DocumentState) { const events = this.events; this.events = []; const root = this.y.getMap('numolus'); this.y.transact(() => { if (!same(root.get('locale'), d.locale)) root.set('locale', d.locale); if (!same(root.get('csvDelimiter'), d.csvDelimiter)) root.set('csvDelimiter', d.csvDelimiter); const sheets = root.get('sheets') as Y.Map; const want = new Set(d.sheets.map((s) => s.id)); for (const id of [...sheets.keys()]) if (!want.has(id)) sheets.delete(id); for (const s of d.sheets) { const m = sheets.get(s.id) as YMap | undefined; if (!m) { sheets.set(s.id, buildSheet(s, randomIds)); continue; } syncPlain(m, s, SHEET_STRUCT, new Set(['objectOrder', 'objects'])); syncObjects(m, s, events); } syncOrder(root.get('sheetOrder') as YArr, d.sheets.map((s) => s.id)); for (const [orderKey, itemsKey, field] of LISTS) { const list = d[field] as { id: string }[]; syncKeyedMap(root.get(itemsKey) as Y.Map, new Map(list.map((x) => [x.id, x]))); syncOrder(root.get(orderKey) as YArr, list.map((x) => x.id)); } }, LOCAL); } }