3 familles, 13 fichiers (+153/−167) :
1. no-empty ×70 = TOUS des `catch (x) {}` vides → `catch { /* volontaire */ }`
(binding optionnel ES2019 + commentaire : passe no-empty ET
no-unused-vars, zéro changement de comportement).
2. no-unused-vars ×171 :
- bindings de catch inutilisés retirés (e/err/ex/e2/e3)
- 24 lignes mortes déterministes, chaque suppression validée par assert
sur le texte exact (`var self = this` ×8, `var lang`, `var acc`,
`var today`, `var path/restored/files/resolved/items/clickEl`,
`uid()`/`propName()` sans un seul appel, `.then` + `resolved++`
compteurs jamais lus)
- `/* exported */` sur les 10 fonctions appelées depuis les attributs
HTML des templates (vérifiées par grep : 1 template chacune) :
setActiveTab/kanbanBoard/filterSystem/sortSystem/newIssueForm/
showNewIssue, importWizard, libraryPage, workspacesPage, settingsInit
3. no-undef ×44 = vrais globaux déclarés dans eslint.config.mjs
(getSvgIcon = script inline de base.html, TextDecoder = API navigateur,
Prism = CDN) + 2 vrais correctifs :
- settings.js : `typeof toast === 'function'` = guard TOUJOURS faux
(pas de toast global) → les toasts timezone/SAML ne s'affichaient
jamais → `window.showToast` (2 sites)
- local_workspace.js : `_wsInitData = window._wsInitData`
(auto-affectation sans effet, global implicite) supprimé
eslint static/js : **0 erreur / 0 warning** (285 → 0) · node --check vert
sur tous les fichiers · suite **1089/1089** · ruff OK · docs à jour
533 lines
18 KiB
JavaScript
533 lines
18 KiB
JavaScript
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 };
|
||
})();
|