Files
ObsiGate/frontend/js/collab.js
T
bruno 063b02e996
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat: collaboration temps reel - edition simultanee (#62)
WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
2026-09-11 23:27:22 -04:00

597 lines
17 KiB
JavaScript

/* 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<object|null>} 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);
}