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:
wkh
2026-09-28 20:41:02 +02:00
co-authored by Claude Opus 5.5
parent e14bada114
commit 7fce972fdc
27 changed files with 1685 additions and 413 deletions
+82 -4
View File
@@ -17,7 +17,7 @@
* 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 {
Workbook,
toA1,
@@ -91,6 +91,29 @@ export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) {
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
* 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
@@ -669,7 +692,10 @@ function createStore() {
function scheduleSave() {
clearTimeout(persistTimer);
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). */
function flushSave() {
if (persistTimer === undefined) return;
@@ -766,6 +792,7 @@ function createStore() {
*/
function markDirty() {
scheduleSave();
if (undoProvider.value) return; // shared document: undo lives in Yjs
if (!historyPending.value) {
redoStack.length = 0;
historyTick.value++;
@@ -789,10 +816,22 @@ function createStore() {
historyTick.value++;
}
const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
const undoProvider = shallowRef<UndoProvider | null>(null);
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() {
if (undoProvider.value) return undoProvider.value.undo();
commitHistoryStep(); // flush a pending (not yet coalesced) change first
const prev = undoStack.pop();
if (!prev) return;
@@ -806,6 +845,7 @@ function createStore() {
historyTick.value++;
}
function redo() {
if (undoProvider.value) return undoProvider.value.redo();
clearTimeout(historyTimer);
const next = redoStack.pop();
if (!next) return;
@@ -820,7 +860,8 @@ function createStore() {
// ---- id / name helpers ------------------------------------------
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 {
const b = base.trim() || 'Element';
@@ -2233,6 +2274,7 @@ function createStore() {
const t = tableById(tableId);
if (!t) return;
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);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
@@ -2247,6 +2289,7 @@ function createStore() {
const t = tableById(tableId);
if (!t || t.rows <= 1) return;
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);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
@@ -2263,6 +2306,7 @@ function createStore() {
const t = tableById(tableId);
if (!t) return;
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);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
@@ -2277,6 +2321,7 @@ function createStore() {
const t = tableById(tableId);
if (!t || t.cols <= 1) return;
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);
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', 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.
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
* seed") -- unlike undo/redo, this starts a fresh undo history rather
* than leaving the previous document's edits undoable into the new one. */
@@ -3159,6 +3235,8 @@ function createStore() {
flushSave,
hasPendingSave,
cancelSave,
applyExternalState,
setUndoProvider,
engine: () => wb,
};