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
+37 -8
View File
@@ -143,11 +143,11 @@ onMounted(() => {
uploadOpen.value = true;
});
const statusNote = computed(() => {
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
if (library.state.syncState === 'saving') return 'Speichert …';
return '';
});
const statusNote = computed(() =>
library.state.current?.source === 'server' && !library.online.value
? 'Offline – Änderungen werden übertragen, sobald die Verbindung zurück ist'
: '',
);
// ---- document title (inline rename) ------------------------------------
const titleDraft = ref(library.currentTitle.value);
@@ -439,10 +439,17 @@ async function onSample() {
</button>
</div>
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">Nur Lesen</span>
<span v-if="statusNote" class="status-note" :title="statusNote">Offline</span>
<span v-if="library.peers.value.length" class="peers" :title="library.peers.value.map((p) => p.name).join(', ')">
<span
v-for="p in library.peers.value.slice(0, 5)"
:key="p.clientId"
class="avatar peer"
:style="{ background: p.color }"
>{{ p.name.replace(/^Anonym \(/, '').slice(0, 1).toUpperCase() }}</span>
<span v-if="library.peers.value.length > 5" class="more">+{{ library.peers.value.length - 5 }}</span>
</span>
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
<div class="spacer" />
@@ -748,6 +755,28 @@ async function onSample() {
cursor: help;
}
.status-note {
padding: 3px 9px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-weight: 600;
cursor: help;
}
.peers {
display: inline-flex;
align-items: center;
}
.peer {
margin-left: -5px;
box-shadow: 0 0 0 2px var(--surface);
cursor: help;
}
.peer:first-child {
margin-left: 0;
}
.more {
margin-left: 4px;
font-size: 12px;
color: var(--text-dim);
}
.account {
+47
View File
@@ -13,6 +13,7 @@
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
@@ -22,6 +23,7 @@ import FormulaSuggest from './FormulaSuggest.vue';
const props = defineProps<{ tableId: string; selected: boolean }>();
const store = useDocument();
const library = useLibrary();
const table = computed(() => store.tableById(props.tableId));
const DEFAULT_W = 104;
@@ -270,6 +272,28 @@ const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1)
// ---- clipboard source marker (one dashed rectangle, Excel-style "marching
// ants" -- overlaid once over the whole range, not per cell). --------------
const clipRect = computed(() => store.clipboardRect.value);
/** Where the others (live collaboration) have their selection in this table. */
const peerRects = computed(() =>
library.peers.value
.filter((p) => p.rect && p.tableId === props.tableId)
.map((p) => {
const k = p.rect!;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
id: p.clientId,
name: p.name,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(Math.min(k.c1, (table.value?.cols ?? 1) - 1) + 1) - left + 'px',
height: rowTop(Math.min(k.r1, (table.value?.rows ?? 1) - 1) + 1) - top + 'px',
borderColor: p.color,
'--peer': p.color,
},
};
}),
);
const clipMarqueeStyle = computed(() => {
const k = clipRect.value;
if (!k || k.tableId !== props.tableId) return null;
@@ -1411,6 +1435,9 @@ defineExpose({
</tr>
</tbody>
</table>
<div v-for="p in peerRects" :key="p.id" class="peer-sel" :style="p.style">
<span class="peer-name">{{ p.name }}</span>
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
@@ -1578,6 +1605,26 @@ defineExpose({
.cell.is-active {
box-shadow: inset 0 0 0 2px var(--accent);
}
/* someone else's selection (live collaboration) */
.peer-sel {
position: absolute;
pointer-events: none;
border: 2px solid;
z-index: 7;
box-sizing: border-box;
}
.peer-name {
position: absolute;
top: -17px;
left: -2px;
padding: 1px 5px;
border-radius: 3px 3px 3px 0;
background: var(--peer);
color: #fff;
font-size: 10.5px;
line-height: 15px;
white-space: nowrap;
}
/* one dashed rectangle around the whole copied range, not per cell */
.clip-marquee {
position: absolute;