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.
597 lines
17 KiB
JavaScript
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);
|
|
}
|