/* ObsiGate — Collaboration temps réel (ROADMAP #62). * * Édition simultanée d'un même document markdown : * - Yjs (CRDT) côté client : deux personnes qui tapent au même endroit * fusionnent sans conflit ni perte ; * - WebSocket `/ws/collab/{vault}/{path}` : une room par fichier, le serveur * relaie les mises à jour et l'awareness, puis persiste le document * (debounce 2 s) ; * - awareness : curseurs distants colorés + indicateur de présence. * * Le module n'a aucune dépendance statique externe : Yjs est chargé * dynamiquement depuis l'import map (`yjs`), CodeMirror via `window.CodeMirror`. * Cela permet de tester les fonctions pures en Node/JSDOM. */ import { state } from './state.js'; import { t } from './i18n.js'; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; const TEXT_SYNC_MS = 300; const PING_MS = 25000; // ── Fonctions pures (testables) ──────────────────────────────────────────── /** * Calcule un diff minimal à remplacement unique entre deux textes. * Retourne `null` si les textes sont identiques, sinon `{from, to, insert}`. */ export function computeTextDiff(oldText, newText) { if (oldText === newText) return null; const oldLen = oldText.length; const newLen = newText.length; let start = 0; const maxPrefix = Math.min(oldLen, newLen); while (start < maxPrefix && oldText.charCodeAt(start) === newText.charCodeAt(start)) { start++; } let end = 0; const maxSuffix = Math.min(oldLen - start, newLen - start); while ( end < maxSuffix && oldText.charCodeAt(oldLen - 1 - end) === newText.charCodeAt(newLen - 1 - end) ) { end++; } return { from: start, to: oldLen - end, insert: newText.slice(start, newLen - end) }; } /** Initiale affichée dans l'avatar d'un pair (1er caractère, majuscule). */ export function initialOf(name) { const value = String(name || '').trim(); if (!value) return '?'; return value.charAt(0).toUpperCase(); } /** Construit l'URL WebSocket de collaboration (segments de chemin encodés). */ export function buildCollabUrl(loc, vault, path) { const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; const segments = String(path || '') .split('/') .filter((s) => s.length > 0) .map(encodeURIComponent) .join('/'); return `${proto}//${loc.host}/ws/collab/${encodeURIComponent(vault)}/${segments}`; } /** Nombre de pairs affiché dans l'indicateur de présence. */ export function formatPeerCount(count) { return String(Math.max(0, Number(count) || 0)); } // ── Encodage base64 binaire ──────────────────────────────────────────────── function toBase64(bytes) { let binary = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(binary); } function fromBase64(encoded) { const binary = atob(encoded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function currentUserName() { const el = document.querySelector('.user-display-name'); const name = el && el.textContent ? el.textContent.trim() : ''; return name || 'Utilisateur'; } // ── Session de collaboration ─────────────────────────────────────────────── let _session = null; export function isCollabActive() { return !!_session; } export function getCollabSession() { return _session; } /** * Démarre une session collaborative sur l'éditeur *view*. * @returns {Promise} la session, ou null si Yjs est indisponible. */ export async function startCollab({ view, vault, path }) { await stopCollab(); if (!view || !vault || !path) return null; let Y; try { Y = await import('yjs'); } catch (err) { console.warn('ObsiGate collab: Yjs indisponible, édition locale seulement', err); return null; } const ydoc = new Y.Doc(); const ytext = ydoc.getText('content'); const session = { Y, ydoc, ytext, view, vault, path, userName: currentUserName(), color: null, connId: null, ws: null, status: 'connecting', stopped: false, reconnectAttempt: 0, reconnectTimer: null, pingTimer: null, textTimer: null, applying: false, peers: new Map(), notifyAwareness: null, observer: null, bindingExt: null, }; _session = session; bindYText(session); renderPresence(session); connect(session); return session; } /** Arrête la session courante et nettoie tous les listeners. */ export async function stopCollab() { const session = _session; if (!session) return; session.stopped = true; _session = null; if (session.reconnectTimer) clearTimeout(session.reconnectTimer); if (session.pingTimer) clearInterval(session.pingTimer); if (session.textTimer) clearTimeout(session.textTimer); try { if (session.ws && session.ws.readyState === 1) { // Flush final du texte pour que le serveur persiste le dernier état. sendText(session); session.ws.close(1000, 'editor closed'); } else if (session.ws) { session.ws.close(); } } catch (e) { /* ignore */ } if (session.observer) { try { session.ytext.unobserve(session.observer); } catch (e) { /* ignore */ } } try { session.ydoc.destroy(); } catch (e) { /* ignore */ } removePresence(); } // ── Liaison Y.Text ↔ CodeMirror ──────────────────────────────────────────── function bindYText(session) { const CM = window.CodeMirror; if (!CM) return; const { EditorView } = CM; const listener = EditorView.updateListener.of((update) => { if (session.stopped) return; if (update.docChanged && !session.applying) { applyEditorChangesToY(session); } if (update.selectionSet || update.docChanged) { sendAwareness(session); } }); const cursorExt = remoteCursors(session); session.notifyAwareness = cursorExt.notify; session.bindingExt = [listener, cursorExt.extension]; session.view.dispatch({ effects: CM.StateEffect.appendConfig.of(session.bindingExt) }); session.observer = (event, transaction) => { if (transaction && transaction.origin === 'local') return; syncFromY(session); }; session.ytext.observe(session.observer); syncFromY(session); } function applyEditorChangesToY(session) { const { view, ydoc, ytext } = session; const diff = computeTextDiff(ytext.toString(), view.state.doc.toString()); if (!diff) return; ydoc.transact(() => { if (diff.to > diff.from) ytext.delete(diff.from, diff.to - diff.from); if (diff.insert.length) ytext.insert(diff.from, diff.insert); }, 'local'); } function syncFromY(session) { if (session.stopped) return; const { view, ytext } = session; const next = ytext.toString(); const current = view.state.doc.toString(); const diff = computeTextDiff(current, next); if (!diff) return; session.applying = true; try { view.dispatch({ changes: diff }); } finally { session.applying = false; } scheduleTextSync(session); } // ── Awareness / présence ─────────────────────────────────────────────────── function sendAwareness(session) { const { ws } = session; if (!ws || ws.readyState !== 1 || !session.color) return; let cursor = null; try { const sel = session.view.state.selection.main; cursor = { anchor: sel.anchor, head: sel.head }; } catch (e) { cursor = null; } try { ws.send( JSON.stringify({ type: 'awareness', clientId: session.ydoc.clientID, state: { user: { name: session.userName, color: session.color }, cursor }, }), ); } catch (e) { /* ignore */ } } function scheduleTextSync(session) { if (session.textTimer) clearTimeout(session.textTimer); session.textTimer = setTimeout(() => sendText(session), TEXT_SYNC_MS); } function sendText(session) { const { ws } = session; if (!ws || ws.readyState !== 1) return; try { ws.send(JSON.stringify({ type: 'text', text: session.ytext.toString() })); } catch (e) { /* ignore */ } } // ── Extension CodeMirror : curseurs distants ─────────────────────────────── function remoteCursors(session) { const CM = window.CodeMirror; if (!CM) return { extension: [], notify: () => {} }; const { Decoration, ViewPlugin, WidgetType, StateEffect } = CM; const awarenessEffect = StateEffect.define(); class CursorWidget extends WidgetType { constructor(name, color) { super(); this.name = name; this.color = color; } eq(other) { return other.name === this.name && other.color === this.color; } toDOM() { const caret = document.createElement('span'); caret.className = 'cm-remote-cursor'; caret.style.borderLeftColor = this.color; const label = document.createElement('span'); label.className = 'cm-remote-cursor-label'; label.style.backgroundColor = this.color; label.textContent = this.name; caret.appendChild(label); return caret; } ignoreEvent() { return true; } } const plugin = ViewPlugin.fromClass( class { constructor(view) { this.decorations = this._build(view); } update(update) { if ( update.docChanged || update.transactions.some((tr) => tr.effects.some((e) => e.is(awarenessEffect)), ) ) { this.decorations = this._build(update.view); } } _build(view) { const ranges = []; const docLength = view.state.doc.length; for (const [clientId, peer] of session.peers) { if (clientId === session.ydoc.clientID) continue; if (!peer || !peer.cursor) continue; const pos = Math.max(0, Math.min(peer.cursor.head, docLength)); const name = (peer.user && peer.user.name) || '?'; const color = (peer.user && peer.user.color) || '#4363d8'; ranges.push(Decoration.widget({ widget: new CursorWidget(name, color), side: 1 }).range(pos)); } return Decoration.set(ranges, true); } }, { decorations: (v) => v.decorations }, ); return { extension: plugin, notify: () => { if (session.stopped) return; try { session.view.dispatch({ effects: awarenessEffect.of(null) }); } catch (e) { /* ignore */ } }, }; } // ── WebSocket ────────────────────────────────────────────────────────────── function connect(session) { if (session.stopped) return; session.status = 'connecting'; renderPresence(session); let ws; try { ws = new WebSocket(buildCollabUrl(window.location, session.vault, session.path)); } catch (e) { scheduleReconnect(session); return; } session.ws = ws; ws.onopen = () => { session.reconnectAttempt = 0; session.pingTimer = setInterval(() => { if (ws.readyState === 1) { try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) { /* ignore */ } } }, PING_MS); }; ws.onmessage = (event) => handleMessage(session, event.data); ws.onclose = () => { if (session.pingTimer) clearInterval(session.pingTimer); if (session.ws === ws && !session.stopped) { session.status = 'disconnected'; renderPresence(session); scheduleReconnect(session); } }; ws.onerror = () => { /* onclose follows */ }; } function scheduleReconnect(session) { if (session.stopped || session.reconnectTimer) return; const delay = Math.min(RECONNECT_BASE_MS * 2 ** session.reconnectAttempt, RECONNECT_MAX_MS); session.reconnectAttempt++; session.reconnectTimer = setTimeout(() => { session.reconnectTimer = null; connect(session); }, delay); } function handleMessage(session, raw) { let msg; try { msg = JSON.parse(raw); } catch (e) { return; } const { Y } = session; switch (msg.type) { case 'init': { session.connId = msg.connId; session.color = msg.color || session.color; session.peers.clear(); for (const peer of msg.peers || []) { if (peer.clientId == null) continue; session.peers.set(peer.clientId, { user: { name: peer.displayName, color: peer.color }, cursor: null, }); } for (const entry of msg.awareness || []) { if (entry.clientId != null) session.peers.set(entry.clientId, entry.state); } for (const encoded of msg.updates || []) { try { Y.applyUpdate(session.ydoc, fromBase64(encoded), 'remote'); } catch (e) { /* ignore malformed update */ } } if (typeof msg.seed === 'string' && session.ytext.length === 0) { session.ydoc.transact(() => { session.ytext.insert(0, msg.seed); }, 'local'); } session.status = 'connected'; syncFromY(session); sendFullState(session); sendAwareness(session); renderPresence(session); break; } case 'update': { if (typeof msg.update !== 'string') return; try { Y.applyUpdate(session.ydoc, fromBase64(msg.update), 'remote'); } catch (e) { /* ignore malformed update */ } break; } case 'awareness': { if (msg.clientId == null) return; if (msg.state) session.peers.set(msg.clientId, msg.state); else session.peers.delete(msg.clientId); if (session.notifyAwareness) session.notifyAwareness(); renderPresence(session); break; } case 'peer_left': { if (msg.clientId != null) session.peers.delete(msg.clientId); else if (msg.peer) { for (const [id, peer] of session.peers) { if (peer && peer.user && peer.user.name === msg.peer.displayName) { session.peers.delete(id); } } } if (session.notifyAwareness) session.notifyAwareness(); renderPresence(session); break; } case 'peer_joined': { if (msg.peer && msg.peer.clientId != null && !session.peers.has(msg.peer.clientId)) { session.peers.set(msg.peer.clientId, { user: { name: msg.peer.displayName, color: msg.peer.color }, cursor: null, }); } renderPresence(session); break; } case 'pong': default: break; } } function sendFullState(session) { const { ws, Y, ydoc } = session; if (!ws || ws.readyState !== 1) return; try { ws.send(JSON.stringify({ type: 'sync', update: toBase64(Y.encodeStateAsUpdate(ydoc)) })); } catch (e) { /* ignore */ } } // ── Indicateur de présence (DOM) ─────────────────────────────────────────── function presenceContainer() { return document.getElementById('collab-presence'); } function removePresence() { const el = presenceContainer(); if (el) { el.innerHTML = ''; el.style.display = 'none'; } } function renderPresence(session) { const el = presenceContainer(); if (!el) return; el.innerHTML = ''; el.style.display = 'flex'; el.title = t('collab.presence_title'); const status = document.createElement('span'); status.className = 'collab-status collab-status-' + session.status; const statusKey = session.status === 'connected' ? 'collab.status_connected' : session.status === 'connecting' ? 'collab.status_connecting' : 'collab.status_disconnected'; status.title = t(statusKey); status.setAttribute('aria-label', t(statusKey)); el.appendChild(status); const peers = []; peers.push({ name: session.userName, color: session.color || '#4363d8', self: true }); for (const [clientId, peer] of session.peers) { if (clientId === session.ydoc.clientID) continue; const user = (peer && peer.user) || {}; peers.push({ name: user.name || '?', color: user.color || '#4363d8', self: false }); } const avatars = document.createElement('div'); avatars.className = 'collab-avatars'; for (const peer of peers.slice(0, 6)) { const avatar = document.createElement('span'); avatar.className = 'collab-avatar' + (peer.self ? ' collab-avatar-self' : ''); avatar.style.backgroundColor = peer.color; avatar.title = peer.name; avatar.textContent = initialOf(peer.name); avatars.appendChild(avatar); } el.appendChild(avatars); const count = document.createElement('span'); count.className = 'collab-count'; count.textContent = formatPeerCount(peers.length); count.title = t('collab.peers_title', { count: peers.length }); el.appendChild(count); }