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:
+12
-2
@@ -93,7 +93,8 @@
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]"
|
||||
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]",
|
||||
"yjs": "https://esm.sh/[email protected]"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -111,8 +112,11 @@
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
highlightActiveLine,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
} from "https://esm.sh/@codemirror/[email protected]?external=@codemirror/state";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorState, StateEffect } from "@codemirror/state";
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
@@ -196,6 +200,10 @@
|
||||
rust,
|
||||
oneDark,
|
||||
keymap,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
StateEffect,
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
@@ -1368,6 +1376,8 @@
|
||||
title="Renommer le fichier"
|
||||
/>
|
||||
<div class="editor-spacer"></div>
|
||||
<div class="collab-presence" id="collab-presence" style="display: none"
|
||||
title="Collaboration"></div>
|
||||
<span class="editor-save-dot ok" id="editor-save-dot"
|
||||
><span class="dot"></span
|
||||
><span id="editor-save-label">Saved</span></span
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { api } from './auth.js';
|
||||
import { openFile, showWelcome } from './viewer.js';
|
||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||
import { createAIToolbar } from './ai.js';
|
||||
import { startCollab, stopCollab } from './collab.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
@@ -409,6 +410,13 @@ async function openEditor(vaultName, filePath) {
|
||||
bodyEl.appendChild(state.fallbackEditorEl);
|
||||
}
|
||||
|
||||
// Real-time collaboration (ROADMAP #62) — bind Yjs to the CodeMirror view.
|
||||
if (state.editorView) {
|
||||
startCollab({ view: state.editorView, vault: vaultName, path: filePath }).catch((err) => {
|
||||
console.warn("Collab start failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up AI toolbar (recreate each time editor opens)
|
||||
var container = document.getElementById('ai-toolbar-container');
|
||||
if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) {
|
||||
@@ -480,6 +488,7 @@ function closeEditor() {
|
||||
// Restore brand text
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
stopCollab();
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"common.search": "Search",
|
||||
"common.unknown": "Unknown",
|
||||
"common.yes": "Yes",
|
||||
"collab.peers_title": "{count} person(s) connected",
|
||||
"collab.presence_title": "Real-time collaboration",
|
||||
"collab.status_connected": "Collaboration active",
|
||||
"collab.status_connecting": "Connecting to collaboration…",
|
||||
"collab.status_disconnected": "Collaboration lost — reconnecting…",
|
||||
"config.about": "About",
|
||||
"config.about_error": "Loading error",
|
||||
"config.about_loading": "Loading...",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"common.search": "Rechercher",
|
||||
"common.unknown": "Inconnue",
|
||||
"common.yes": "Oui",
|
||||
"collab.peers_title": "{count} personne(s) connectée(s)",
|
||||
"collab.presence_title": "Collaboration temps réel",
|
||||
"collab.status_connected": "Collaboration active",
|
||||
"collab.status_connecting": "Connexion à la collaboration…",
|
||||
"collab.status_disconnected": "Collaboration interrompue — reconnexion…",
|
||||
"config.about": "À propos",
|
||||
"config.about_error": "Erreur de chargement",
|
||||
"config.about_loading": "Chargement...",
|
||||
|
||||
@@ -2772,6 +2772,20 @@ select {
|
||||
.editor-filename-input:hover { border-color: var(--border); }
|
||||
.editor-filename-input:focus { border-color: var(--ok); background: var(--bg-input); }
|
||||
.editor-spacer { flex: 1; }
|
||||
/* Collaboration temps réel (#62) — indicateur de présence */
|
||||
.collab-presence { display: flex; align-items: center; gap: 6px; margin-right: 10px; }
|
||||
.collab-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.collab-status-connected { background: var(--ok); }
|
||||
.collab-status-connecting { background: var(--warn); animation: editor-pulse 1s infinite; }
|
||||
.collab-status-disconnected { background: var(--err); animation: editor-pulse 0.8s infinite; }
|
||||
.collab-avatars { display: flex; align-items: center; }
|
||||
.collab-avatar { width: 20px; height: 20px; border-radius: 50%; color: #fff; font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center; margin-left: -6px; border: 2px solid var(--bg-secondary); }
|
||||
.collab-avatar:first-child { margin-left: 0; }
|
||||
.collab-avatar-self { box-shadow: 0 0 0 2px var(--bg-primary); }
|
||||
.collab-count { font-size: 11px; color: var(--text-muted); min-width: 10px; }
|
||||
/* Collaboration temps réel (#62) — curseurs distants CodeMirror */
|
||||
.cm-remote-cursor { display: inline-block; width: 0; border-left: 2px solid; height: 1.2em; vertical-align: text-bottom; position: relative; margin-left: -1px; }
|
||||
.cm-remote-cursor-label { position: absolute; top: -1.35em; left: -1px; padding: 1px 4px; border-radius: 3px 3px 3px 0; color: #fff; font-size: 10px; font-weight: 600; white-space: nowrap; pointer-events: none; z-index: 5; }
|
||||
.editor-save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; }
|
||||
.editor-btn + .editor-btn { margin-left: 6px; }
|
||||
.editor-save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
/* ObsiGate — Service Worker (PWA offline + push)
|
||||
* Version: 2 (2026-09-11)
|
||||
* Version: 3 (2026-09-11)
|
||||
*
|
||||
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
|
||||
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
|
||||
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v2';
|
||||
const SW_VERSION = 'v3';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -38,6 +38,7 @@ const PRECACHE_URLS = [
|
||||
'/static/js/offline-db.js',
|
||||
'/static/js/sync.js',
|
||||
'/static/js/legacy.js',
|
||||
'/static/js/collab.js',
|
||||
];
|
||||
|
||||
// ── Install: precache the shell, then activate immediately ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user