const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})(); /* eslint-disable */ /* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */ window.__fdRT = (function () { const SELF = { id: RT.id, login: RT.login, full_name: RT.full_name, color: RT.color, }; const COLORS = [ "#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594", "#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333", ]; let E = null; // editorState (window.E) let ws = null; let mode = 'off'; // 'ws' | 'poll' let open = false; let base = []; // dernier snapshot serveur des blocs let version = 0; let pendingOps = 0; let needSync = false; let peers = []; // liste des présents (hors moi) let remoteCursors = {}; // userId -> {peer, block, offset} let myCursor = null; // {block, offset} let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null; let _pid = 0; // Detach global listeners from a previous editor instance so that // partial (HTMX) navigation doesn't accumulate stale handlers. function unbindGlobal() { const g = window.__fdRTGlobal; if (!g) return; try { document.removeEventListener('selectionchange', g.onSel, true); window.removeEventListener('scroll', g.onScroll, true); window.removeEventListener('resize', g.onResize); } catch (e) { /* noop */ } window.__fdRTGlobal = null; } const clone = (o) => JSON.parse(JSON.stringify(o)); function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; } function initials(p) { const n = (p && (p.full_name || p.login)) || ''; const parts = String(n).trim().split(/\s+/); if (!parts[0]) return '?'; return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2); } function send(obj) { if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ } } } /* ── ops miroir du serveur (apply_op/merge) ── */ function applyOpJS(blocks, op) { const t = op && op.type; if (t === 'insert') { const blk = op.block || {}; const id = blk.id || ('rb' + Date.now().toString(36)); blk.id = id; if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]); let idx = op.index != null ? op.index : blocks.length; idx = Math.max(0, Math.min(idx, blocks.length)); return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx)); } if (t === 'update') { const nb = op.block || {}; if (!nb.id) return blocks; return blocks.map(b => (b.id === nb.id ? nb : b)); } if (t === 'delete') { const bid = op.id; return blocks.filter(b => b.id !== bid); } if (t === 'move') { const bid = op.id, idx = op.index || 0; const out = blocks.filter(b => b.id !== bid); const moved = blocks.find(b => b.id === bid); if (!moved) return blocks; const i2 = Math.max(0, Math.min(idx, out.length)); out.splice(i2, 0, moved); return out; } return blocks; } /* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */ function diffOps(cur) { if (!base.length && !cur.length) return []; const ops = []; const baseById = {}, curById = {}; base.forEach(b => { baseById[b.id] = b; }); cur.forEach(b => { curById[b.id] = b; }); cur.forEach(b => { if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) { // v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur // fait un merge 3-voix au lieu d'écraser le bloc (LWW). ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) }); } }); base.forEach(b => { if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id }); }); // structure : simule l'état serveur (base − supprimés) pour indices valides let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b)); const simById = {}; sim.forEach(b => { simById[b.id] = b; }); const finalOrder = cur.map(b => b.id); let si = 0; finalOrder.forEach(id => { if (simById[id] !== undefined) { const curPos = sim.findIndex(b => b.id === id); if (curPos !== si) ops.push({ type: 'move', id, index: si }); const [mv] = sim.splice(curPos, 1); sim.splice(si, 0, mv); si++; } else { ops.push({ type: 'insert', index: si, block: clone(curById[id]) }); sim.splice(si, 0, curById[id]); simById[id] = curById[id]; si++; } }); return ops; } /* ── rendu + présence ── */ function activeBlockId() { const a = E && E.getActiveBlock ? E.getActiveBlock() : null; return a ? a.bid : null; } function refocus(fid) { if (!fid) return; setTimeout(() => { const el = E.getEl(fid); if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } } }, 30); } function renderPresence() { let host = document.querySelector('.topbar-right.header-actions'); if (!host) return; let box = document.getElementById('rtPresence'); if (!box) { box = document.createElement('span'); box.id = 'rtPresence'; box.className = 'rt-presence'; host.insertBefore(box, host.firstChild); } const shown = peers.filter(p => p.id !== (SELF.id || 0)); if (!shown.length) { box.style.display = 'none'; return; } box.style.display = 'inline-flex'; box.innerHTML = ''; shown.forEach(p => { const c = document.createElement('span'); c.className = 'rt-avatar'; c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : ''); c.textContent = initials(p); c.style.background = p.color || colorOf(p.id); box.appendChild(c); }); if (mode === 'poll') { let off = document.getElementById('rtOffline'); if (!off) { off = document.createElement('span'); off.id = 'rtOffline'; off.className = 'rt-offline'; off.textContent = '●'; off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s'; host.insertBefore(off, box.nextSibling || null); } off.style.display = 'inline'; } } /* ── curseurs ── */ function rangeFromOffset(el, offset) { try { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let n = walker.nextNode(), count = 0; while (n) { const len = (n.nodeValue || '').length; if (count + len >= offset) { const r = document.createRange(); r.setStart(n, Math.min(offset - count, len)); r.collapse(true); return r; } count += len; n = walker.nextNode(); } const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); return r; } catch (e) { return null; } } function drawCursors() { const layer = document.getElementById('rtCursors'); if (!layer) return; layer.innerHTML = ''; const ids = Object.keys(remoteCursors); if (!ids.length) return; ids.forEach(uid => { const c = remoteCursors[uid]; if (!c || !c.block) return; const el = E.getEl(c.block); if (!el) return; const r = rangeFromOffset(el, c.offset || 0); let x, y, h; if (r) { const rc = r.getBoundingClientRect(); if (!rc.width && !rc.height) return; // hors viewport positionné x = rc.left; y = rc.top; h = Math.max(rc.height, 16); } else { const rc = el.getBoundingClientRect(); x = rc.left; y = rc.top; h = Math.max(rc.height, 16); } const m = document.createElement('div'); m.className = 'rt-cursor'; m.style.left = (x - 1) + 'px'; m.style.top = (y - 1) + 'px'; m.style.height = h + 'px'; m.style.background = c.peer.color || colorOf(c.peer.id); const nm = document.createElement('span'); nm.className = 'rt-cursor-name'; nm.textContent = initials(c.peer); nm.style.background = m.style.background; m.appendChild(nm); layer.appendChild(m); }); } function emitCursor() { const a = E && E.getActiveBlock ? E.getActiveBlock() : null; let block = null, offset = 0; if (a && a.el && a.bid) { block = a.bid; try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; } } const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset; myCursor = { block, offset }; if (!changed) return; send({ t: 'sel', block, offset }); } function scheduleDraw() { clearTimeout(drawT); drawT = setTimeout(drawCursors, 40); } /* ── application des changements distants ── */ function applySync(blocks, title) { if (!E || !E.blocks) return; // v5.13.1: guarantee ids before comparing/merging. Server rooms may still // carry legacy id-less blocks; without this they collide on // data-bid="undefined" and the merge below duplicated every line. blocks = (blocks || []).slice(); if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks); const curIds = JSON.stringify((E.blocks || []).map(b => b.id)); const srvIds = JSON.stringify(blocks.map(b => b.id)); if (curIds === srvIds) { base = clone(E.blocks); E.dirty = false; return; } const fid = activeBlockId(); const curById = {}; (E.blocks || []).forEach(b => { curById[b.id] = b; }); let out; try { if (!blocks.length) { // serveur vide : ne pas effacer le contenu local (page neuve). out = (E.blocks || []).slice(); } else { out = blocks.map(b => { const cb = curById[b.id]; if (cb && b.id === fid) return cb; // garde la frappe locale en cours return b; }); } } catch (e) { return; } E.blocks = out; const tEl = document.getElementById('_titleEl'); if (title && title !== E.pageTitle && document.activeElement !== tEl) { tEl.textContent = title; E.pageTitle = title; } E.dirty = true; base = clone(E.blocks); E.render(); refocus(fid); scheduleDraw(); } function applyRemoteOp(op) { const fid = activeBlockId(); if (op.type === 'update') { const nb = op.block || {}; if (nb.id === fid) { // bloc en cours d'édition : on garde la valeur locale, le serveur a déjà // l'op ; la prochaine frappe locale repartira (LWW). base = applyOpJS(base, op); return; } E.blocks = applyOpJS(E.blocks, op); base = applyOpJS(base, op); E.dirty = true; E.render(); refocus(fid); } else { E.blocks = applyOpJS(E.blocks, op); base = applyOpJS(base, op); E.dirty = true; E.render(); refocus(fid); } scheduleDraw(); } /* ── diffusion des modifications locales ── */ function emit() { if (mode !== 'ws' || !open || !E || !E.blocks) return; if (needSync) { send({ t: 'sync_req' }); return; } const cur = E.blocks.filter(b => b && b.id); const ops = diffOps(cur); if (!ops.length) return; ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; }); base = clone(cur); } function onMsg(m) { if (!m || !m.t) return; if (m.t === 'sync') { version = m.version || 0; base = clone(m.blocks || []); needSync = false; pendingOps = 0; if (typeof m.blocks === 'undefined') return; applySync(m.blocks || [], m.title || ''); } else if (m.t === 'ack') { version = m.v || 0; if (pendingOps > 0) pendingOps--; // v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On // l'adopte comme nouvelle base ; s'il diffère de notre saisie locale // c'est qu'un autre utilisateur avait modifié le même bloc. if (m.merged) { const mid = m.merged.id; const localBlock = (E && E.blocks || []).find(b => b.id === mid); const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged); base = applyOpJS(base, { type: 'update', block: m.merged }); if (differs && E) { const fid = activeBlockId(); if (fid !== mid) { E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged }); E.dirty = true; E.render(); refocus(fid); } if (m.conflict && window.showToast) { window.showToast('Editing conflict merged on a block', 'info'); } } } if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); } } else if (m.t === 'op') { if (m.v) version = m.v; if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; } applyRemoteOp(m.op); } else if (m.t === 'title') { const tEl = document.getElementById('_titleEl'); if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) { tEl.textContent = m.title || ''; E.pageTitle = m.title || ''; } if (m.v) version = m.v; scheduleDraw(); } else if (m.t === 'sel') { if (m.from === (SELF.id || 0)) return; if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; } remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 }; scheduleDraw(); } else if (m.t === 'welcome') { peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0)); renderPresence(); } else if (m.t === 'peer_join') { if (m.peer && m.peer.id !== (SELF.id || 0)) { peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]); renderPresence(); } } else if (m.t === 'peer_leave') { peers = peers.filter(p => p.id !== m.id); delete remoteCursors[m.id]; renderPresence(); scheduleDraw(); } else if (m.t === 'synced_update') { // A synced block was updated — re-sync the whole page if (m.synced_id) { needSync = true; send({ t: 'sync_req' }); } } } /* ── connexion WS + fallback polling ── */ function startPolling() { if (pollT) return; mode = 'poll'; renderPresence(); scheduleDraw(); pollT = setInterval(poll, 10000); } function stopPolling() { if (pollT) { clearInterval(pollT); pollT = null; } const off = document.getElementById('rtOffline'); if (off) off.style.display = 'none'; } async function poll() { if (!E || !_pid) return; try { const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' }); if (!r.ok) return; const d = await r.json(); if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return; const serverBlocks = JSON.parse(d.content); // en cas de modifs locales non persistées : on garde local (LWW au prochain save) if (E.dirty) return; const cur = JSON.stringify((E.blocks || []).map(b => b.id)); const srv = JSON.stringify(serverBlocks.map(b => b.id)); if (cur === srv) return; version = version; // pas de version via polling — on adopte l'état serveur applySync(serverBlocks, d.title || E.pageTitle); } catch (e) { /* noop */ } } function connect() { if (!E || !_pid || ws) return; const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; try { ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid); } catch (e) { startPolling(); return; } ws.onopen = () => { open = true; mode = 'ws'; stopPolling(); send({ t: 'hello' }); }; ws.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } onMsg(m); }; ws.onclose = () => { open = false; ws = null; if (retryT) clearTimeout(retryT); retryT = setTimeout(connect, 15000); startPolling(); }; ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } }; setTimeout(() => { if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } } }, 5000); } function titleInput() { const tEl = document.getElementById('_titleEl'); if (!tEl) return; clearTimeout(titleT); titleT = setTimeout(() => { send({ t: 'title', title: (tEl.textContent || '').trim() }); }, 500); } function wire() { const ct = document.getElementById('_blocksCt'); if (ct) { ct.addEventListener('input', () => { clearTimeout(emitT); emitT = setTimeout(emit, 350); setTimeout(emitCursor, 80); }, true); ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true); ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true); ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true); } const tEl = document.getElementById('_titleEl'); if (tEl) tEl.addEventListener('input', titleInput); unbindGlobal(); const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }; const onScroll = () => scheduleDraw(); const onResize = () => scheduleDraw(); document.addEventListener('selectionchange', onSel, true); window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onResize); window.__fdRTGlobal = { onSel, onScroll, onResize }; } function start(ed) { if (!ed) return; if ((ed.contentFormat || 'blocks') !== 'blocks') return; E = ed; _pid = ed.pid || 0; if (!_pid) return; base = clone(ed.blocks || []); wire(); connect(); } // poussée immédiate des ops après une mutation programmatique (v5.10.0) function syncNow() { if (mode !== 'ws' || !open || !E || !E.blocks) return; clearTimeout(emitT); emit(); } return { start, syncNow }; })();