Files
flowdeck/static/js/page_editor_realtime.js
bruno afbc236cc2
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m16s
fix: navigation partielle Alpine sans course + scripts de page idempotents (v7.45.2)
- app.js : x-ignore sur .main-wrapper au swap fdLoad (nœud remplacé
  uniquement), démontage unique quand tous les <script src> ont exécuté
  (load/error + filet 4s) via Alpine.initTree idempotent (_x_marker) —
  fini les cascades « Undefined variable » (éditeur) et « reading 'has' »
  ($store.fdCtx) : la zone s'initialisait avant composants et stores.
- 5 scripts de page gardés contre la ré-exécution (SyntaxError
  « Identifier 'LW' has already been declared » à la 2e visite
  partielle) : local_workspace, board, settings, database_table,
  page_editor_realtime.
- if (window.Alpine) → Alpine.data immédiat sinon listener alpine:init
  (déjà passé sur swap) : 8 scripts + 7 templates inline.
- app.css : purge des 7 blocs @font-face Inter orphelins (fichiers
  inexistants → 302 HTML → « Failed to decode downloaded font »).
- Gates : e2e/regression_editor_mount + e2e/regression_partial_nav
  (7 pages x complet/1er/2e passage) + regression_logout_dnd + smoke
  = 6/6 verts sur l'image rebuildée · pytest 1094 passed · ruff OK ·
  OpenAPI 7.45.2.
2026-10-02 21:29:46 -04:00

536 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
if (!window.__fdRtScriptsLoaded) {
window.__fdRtScriptsLoaded = true;
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 };
})();
}