Collaboration System¶
This page documents the real-time collaboration system for LaTeX and Markdown editors.
Technology stack
- YJS - CRDT-based conflict resolution
- Socket.IO - Real-time WebSocket communication
- CodeMirror 6 - Editor integration
- MariaDB - Persistent storage
Architecture overview¶
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ LatexEditor │ │MarkdownEditor│ │ WorkspaceGitPanel │ │
│ │ Pane │ │ Pane │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ └─────────┬─────────┘ │ │
│ │ │ │
│ ┌─────────▼─────────┐ ┌────────▼────────┐ │
│ │useYjsCollaboration│ │ checkForChanges │ │
│ │ (Composable) │◄─────────────│ (API Call) │ │
│ └─────────┬─────────┘ └─────────────────┘ │
│ │ document_saved │
└───────────────────┼─────────────────────────────────────────────────┘
│ Socket.IO (/collab)
▼
┌───────────────────────────────────────────────────────────────────┐
│ YJS Server (:8082) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Y.Doc │ │ Room │ │ Workspace Room │ │
│ │ Cache │ │ Manager │ │ (document_saved) │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ saveYdocToDB │ │
│ │ (2s debounce)│ │
│ └──────┬──────┘ │
└──────────────────────────┼─────────────────────────────────────────┘
│ SQL
▼
┌───────────────────────────────────────────────────────────────────┐
│ MariaDB │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ latex_documents │ │markdown_documents│ │
│ │ - content (YJS) │ │ - content (YJS) │ │
│ │ - content_text │ │ - content_text │ │
│ └─────────────────┘ └─────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
Data flow¶
1. Editor synchronization (YJS)¶
sequenceDiagram
participant User1 as User 1
participant Editor1 as Editor (Browser 1)
participant YJS as YJS Server
participant Editor2 as Editor (Browser 2)
participant User2 as User 2
User1->>Editor1: Types text
Editor1->>YJS: sync_update (Y.Doc Delta)
YJS->>Editor2: sync_update (Broadcast)
Editor2->>User2: Shows change
Note over YJS: After 2s inactivity
YJS->>YJS: saveYdocToDB()
YJS-->>Editor1: document_saved
YJS-->>Editor2: document_saved
2. Git Panel real-time updates¶
sequenceDiagram
participant Editor as EditorPane
participant YJS as YJS Server
participant Parent as WorkspaceComponent
participant GitPanel as GitPanel
Editor->>YJS: sync_update
Note over YJS: 2s debounce
YJS->>YJS: saveYdocToDB()
YJS->>Editor: document_saved (WebSocket)
Editor->>Parent: @document-saved event
Parent->>GitPanel: checkForChanges()
GitPanel->>GitPanel: API: GET /changes
GitPanel->>GitPanel: Update UI
YJS server events¶
Socket.IO namespace: /collab¶
The YJS server runs on port 8082 and communicates via Socket.IO.
Room structure¶
// Document rooms (for sync)
"latex_{document_id}" // e.g. "latex_42"
"markdown_{document_id}" // e.g. "markdown_15"
// Workspace rooms (for document_saved events)
"workspace_latex_{workspace_id}" // e.g. "workspace_latex_2"
"workspace_markdown_{workspace_id}" // e.g. "workspace_markdown_1"
Events (Client → Server)¶
join_room¶
Join a document room and automatically the associated workspace room.
socket.emit('join_room', {
room: 'latex_42', // Document room
username: 'admin'
})
// Server automatically executes:
// 1. socket.join('latex_42')
// 2. socket.join('workspace_latex_{workspace_id}')
sync_update¶
Send YJS changes to other clients.
leave_room¶
Leave a room.
reload_room¶
Force reload from the database (after rollback).
Events (Server → Client)¶
snapshot_document¶
Full document state on join.
sync_update¶
Incremental updates from other clients.
document_saved ⭐ NEW¶
Sent to all clients in the workspace room after the document is stored in the DB.
socket.on('document_saved', (data) => {
// data = {
// documentId: 42,
// workspaceId: 2,
// kind: 'latex', // 'latex' | 'markdown'
// contentLength: 1500,
// savedAt: '2025-01-03T12:00:00.000Z'
// }
// Typical usage: refresh Git panel
if (data.workspaceId === currentWorkspaceId) {
gitPanel.checkForChanges()
}
})
Important: This event is sent to the workspace room, not the document room. This way all users in the workspace receive it, regardless of the document they are editing.
room_state¶
Current users and cursor positions.
socket.on('room_state', (state) => {
// state = {
// users: { socketId: { username, color } },
// cursors: { socketId: { line, ch } }
// }
})
user_joined / user_left¶
User joins/leaves a room.
socket.on('user_joined', ({ userId, username, color }) => {
// Show new user in UI
})
socket.on('user_left', ({ userId }) => {
// Remove user cursor
})
Frontend integration¶
useYjsCollaboration composable¶
The composable manages the Socket.IO connection and YJS document synchronization.
import { useYjsCollaboration } from '@/components/PromptEngineering/composables/useYjsCollaboration'
const collaboration = useYjsCollaboration(
roomId, // Ref<string> - e.g. 'latex_42'
username, // string
processYDoc, // Callback for document updates
onUpdateCursor, // Callback for cursor updates
{
autoSync: true,
onColorUpdate: (userId, color) => { /* ... */ },
onDocumentSaved: (data) => {
// Real-time Git panel updates
emit('document-saved', data)
}
}
)
const {
ydoc, // Ref<Y.Doc>
socket, // Ref<Socket>
users, // Ref<Object>
initialize, // () => void
cleanup, // () => void
switchRoom, // (oldRoom, newRoom) => void
reloadRoom, // () => Promise<boolean>
reloadAnyRoom // (roomName) => Promise<boolean>
} = collaboration
Editor integration (LatexEditorPane)¶
<script setup>
const emit = defineEmits([
'content-change',
'document-saved' // NEW: For Git panel updates
])
const collaboration = useYjsCollaboration(roomId, username, processYDoc, onUpdateCursor, {
autoSync: true,
onDocumentSaved: (data) => {
emit('document-saved', data)
}
})
</script>
Parent component (LatexCollabWorkspace)¶
<template>
<LatexEditorPane
ref="editorRef"
:document="selectedNode"
@document-saved="handleDocumentSaved"
/>
<LatexWorkspaceGitPanel
ref="gitPanelRef"
:workspace-id="workspaceId"
/>
</template>
<script setup>
const gitPanelRef = ref(null)
function handleDocumentSaved(data) {
// Only update if event is for our workspace
if (data.workspaceId === workspaceId.value) {
gitPanelRef.value?.checkForChanges?.()
}
}
</script>
Database schema¶
latex_documents¶
CREATE TABLE latex_documents (
id INT PRIMARY KEY AUTO_INCREMENT,
workspace_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content LONGTEXT, -- YJS JSON state
content_text LONGTEXT, -- Plain text (for search/diff)
node_type ENUM('file', 'folder'),
parent_id INT,
order_index INT DEFAULT 0,
last_editor_username VARCHAR(255),
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME, -- Soft delete
FOREIGN KEY (workspace_id) REFERENCES latex_workspaces(id),
INDEX idx_workspace (workspace_id),
INDEX idx_parent (parent_id)
);
markdown_documents¶
Identical structure to latex_documents.
Dual content storage¶
Each document has two content fields:
| Field | Description | Usage |
|---|---|---|
content |
YJS JSON state | Collaboration sync |
content_text |
Plain text | Git diff, search, baseline |
Fallback logic: If content is corrupt (e.g. after a crash), content_text is used as fallback.
Git integration¶
Workspace-level Git panel¶
The Git panel shows changes for all documents in the workspace.
┌─────────────────────────────────────┐
│ Git Changes (3 files) │
├─────────────────────────────────────┤
│ ☑ main.tex +15 -3 [M] │
│ ☑ chapter1.tex +42 -0 [M] │
│ ☐ references.bib +5 -2 [M] │
├─────────────────────────────────────┤
│ Commit message: │
│ ┌─────────────────────────────────┐ │
│ │ Extended chapter 1 │ │
│ └─────────────────────────────────┘ │
│ [Commit] │
└─────────────────────────────────────┘
API endpoints¶
GET /api/{latex,markdown}-collab/workspaces/{id}/changes¶
Returns all uncommitted changes.
{
"success": true,
"workspace_id": 2,
"changed_files": [
{
"id": 42,
"title": "main.tex",
"path": "main.tex",
"status": "M",
"insertions": 15,
"deletions": 3,
"has_baseline": true
}
],
"deleted_files": [],
"total_changes": 3
}
POST /api/{latex,markdown}-collab/workspaces/{id}/commit¶
Commits multiple files at once.
// Request
{
"message": "Extended chapter 1",
"document_ids": [42, 43, 44]
}
// Response
{
"success": true,
"commits": [
{ "id": 100, "document_id": 42, "message": "..." },
{ "id": 101, "document_id": 43, "message": "..." }
],
"total_committed": 2
}
Real-time update flow¶
1. User types in editor
│
▼
2. YJS sync_update to server (immediate)
│
▼
3. Server saves after 2s inactivity
│
▼
4. Server emits document_saved to workspace room
│
▼
5. Frontend receives event
│
▼
6. Git panel calls checkForChanges()
│
▼
7. API call: GET /workspaces/{id}/changes
│
▼
8. UI updates
Rollback mechanism¶
Problem: YJS cache invalidation¶
During rollback the YJS server must invalidate its cache, otherwise it delivers stale data.
Solution: reload_room event¶
// Frontend after rollback
async function handleRollback(payload) {
const documentId = payload.documentId
const roomName = `latex_${documentId}`
if (selectedDocumentId === documentId) {
// Document is open: full reload
await editorRef.value?.reloadRoom?.()
} else {
// Document not open: just invalidate cache
await editorRef.value?.reloadAnyRoom?.(roomName)
}
}
Server side (reload_room handler)¶
socket.on('reload_room', async (data, callback) => {
const room = data.room
// 1. Cancel pending save
const timer = saveTimers.get(room)
if (timer) {
clearTimeout(timer)
saveTimers.delete(room)
}
// 2. Clear cache
ydocs.delete(room)
// 3. Reload from DB
const doc = await loadYdocFromDB(room)
ydocs.set(room, doc)
// 4. Broadcast to all clients
const fullState = Y.encodeStateAsUpdate(doc)
io.to(room).emit('snapshot_document', fullState)
callback({ success: true })
})
Error handling¶
Corrupt YJS data¶
If content cannot be parsed, content_text is used as fallback:
async function loadYdocFromDB(roomName) {
const [rows] = await pool.query(
'SELECT content, content_text FROM latex_documents WHERE id = ?',
[roomId]
)
if (rows[0].content) {
try {
const doc = jsonToYdoc(rows[0].content)
const text = doc.getText('content').toString()
// Check if YJS content is valid
if (text.length > 0 || !rows[0].content_text) {
return doc
}
} catch (e) {
console.error('YJS parse failed, using content_text fallback')
}
}
// Fallback: use content_text
if (rows[0].content_text) {
const doc = new Y.Doc()
doc.getText('content').insert(0, rows[0].content_text)
return doc
}
return new Y.Doc()
}
Connection loss¶
Socket.IO reconnects automatically:
socket.io.opts = {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000
}
socket.on('reconnect', () => {
// Rejoin room
socket.emit('join_room', { room: currentRoom })
})
Performance optimizations¶
1. Debounced persistence¶
Saving occurs after 2 seconds of inactivity:
// On every sync_update
const existingTimer = saveTimers.get(room)
if (existingTimer) clearTimeout(existingTimer)
saveTimers.set(room, setTimeout(async () => {
await saveYdocToDB(room, doc, ...)
}, 2000))
2. Workspace room broadcasts¶
document_saved events go only to clients in the same workspace, not to everyone:
const workspaceRoom = `workspace_latex_${workspaceId}`
io.to(workspaceRoom).emit('document_saved', data)
3. Selective Git panel updates¶
The Git panel only updates for events in the current workspace:
function handleDocumentSaved(data) {
if (data.workspaceId === workspaceId.value) {
gitPanelRef.value?.checkForChanges?.()
}
}
Debugging¶
YJS server logs¶
Relevant log messages:
[join_room] Also joined workspace room: workspace_latex_2
[saveYdocToDB] Room: latex_42, docId: 42, contentLength: 1500
[document_saved] Emitted to workspace_latex_2 for latex doc 42
[reload_room] START - Reloading room "latex_42" from database
Frontend console¶
// In useYjsCollaboration
socket.on('document_saved', (data) => {
console.log('[useYjsCollaboration] document_saved received:', data)
})
// In parent component
function handleDocumentSaved(data) {
console.log('[LatexCollabWorkspace] document_saved received:', data)
}
Network tab¶
WebSocket frames in Chrome DevTools:
- Filter:
WS - Frames:
document_saved,sync_update,snapshot_document
Troubleshooting¶
| Problem | Cause | Solution |
|---|---|---|
| Git panel does not update | Event not received | Check if in workspace room |
| Document empty after rollback | YJS cache not invalidated | Call reloadRoom() |
| Changes are lost | Save before disconnect | flush_document before navigation |
| Cursor jumps | Race condition during sync | Check applyingRemoteUpdate flag |
Files¶
Backend (YJS Server)¶
yjs-server/
├── server.js # Express + Socket.IO setup
├── websocket.js # Event handlers + saveYdocToDB
└── db/
└── db.js # MySQL pool
Frontend¶
llars-frontend/src/
├── components/
│ ├── LatexCollab/
│ │ ├── LatexEditorPane.vue
│ │ └── LatexWorkspaceGitPanel.vue
│ ├── MarkdownCollab/
│ │ ├── MarkdownEditorPane.vue
│ │ └── MarkdownGitPanel.vue (deprecated)
│ └── PromptEngineering/
│ └── composables/
│ └── useYjsCollaboration.js
└── views/
├── LatexCollab/
│ └── LatexCollabWorkspace.vue
└── MarkdownCollab/
└── MarkdownCollabWorkspace.vue