feat: collaboration temps reel - edition simultanee (#62)
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
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
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.
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
/* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user