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