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