Real-time collaboration (Yjs + WebSocket)

- Go WebSocket hub: rooms per document, broadcast updates, persist state
- Yjs integration: connect/disconnect, sync document state
- Collab toggle button in toolbar (Solo/Live)
- When Live: edits broadcast to all connected users in real-time
- Yjs state persisted to SQLite (survives server restart)
- gorilla/websocket dependency added
This commit is contained in:
2026-05-22 23:49:12 +02:00
parent 1a77d068a7
commit ed4d0b261f
6 changed files with 226 additions and 1 deletions
+22
View File
@@ -59,6 +59,9 @@
<div class="toolbar-right">
<span class="file-name">{{ currentFile || 'No file open' }}</span>
<span class="file-meta" v-if="fileMeta">{{ fileMeta }}</span>
<button v-if="currentFile" class="toolbar-btn" @click="toggleCollab" :title="collabActive ? 'Disconnect collab' : 'Enable collab'">
{{ collabActive ? '👥 Live' : '👤 Solo' }}
</button>
<button v-if="currentFile" class="toolbar-btn" @click="showHistory = !showHistory" title="History">📜</button>
<button v-if="currentFile" class="toolbar-btn" @click="showShareDialog = !showShareDialog" title="Share">🤝</button>
<button v-if="currentFile" class="toolbar-btn" @click="aiVerify" title="Verify with AI">🤖 Verify</button>
@@ -248,6 +251,7 @@ import MilkdownEditor from './components/MilkdownEditor.vue'
import { api, setToken } from './lib/api.js'
import { renderMarkdown } from './lib/markdown.js'
import { cacheFile, getCachedFile, addPendingChange, getPendingChanges, clearAllPending } from './lib/offline.js'
import { connectCollab, disconnectCollab, setCollabContent } from './lib/collab.js'
const authenticated = ref(false)
const email = ref('')
@@ -277,6 +281,7 @@ const shareMsg = ref('')
const gitDirty = ref(0)
const aiResult = ref('')
const trashItems = ref([])
const collabActive = ref(false)
// Preferences
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
@@ -678,6 +683,23 @@ async function aiVerify() {
}
}
// ─── Collab ──────────────────────────────────────────────────────────────────
function toggleCollab() {
if (collabActive.value) {
disconnectCollab()
collabActive.value = false
} else {
if (!currentFile.value) return
const fileId = currentFile.value.replace(/[^a-zA-Z0-9]/g, '-')
connectCollab(fileId, (newContent) => {
content.value = newContent
})
setCollabContent(content.value)
collabActive.value = true
}
}
// ─── Formatting ──────────────────────────────────────────────────────────────
function insertFormat(before, after) {
+60
View File
@@ -0,0 +1,60 @@
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
let ydoc = null
let provider = null
let ytext = null
/**
* Connect to the collab WebSocket for a given file.
* Returns the Yjs Text type that can be bound to an editor.
*/
export function connectCollab(fileId, onUpdate) {
disconnectCollab()
ydoc = new Y.Doc()
ytext = ydoc.getText('content')
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws/collab/${fileId}`
provider = new WebsocketProvider(wsUrl, fileId, ydoc, { connect: true })
ytext.observe((event) => {
if (onUpdate) {
onUpdate(ytext.toString())
}
})
provider.on('status', (event) => {
console.log('[collab]', event.status)
})
return { ydoc, ytext, provider }
}
export function disconnectCollab() {
if (provider) {
provider.destroy()
provider = null
}
if (ydoc) {
ydoc.destroy()
ydoc = null
}
ytext = null
}
export function setCollabContent(text) {
if (!ytext) return
ydoc.transact(() => {
ytext.delete(0, ytext.length)
ytext.insert(0, text)
})
}
export function getCollabContent() {
return ytext ? ytext.toString() : ''
}
export function isConnected() {
return provider && provider.wsconnected
}