fix: A27 phase 2b — +3 801 L extraits (recette config JSON) (v7.23.0)
4 blocs interpolés extraits avec la recette de la 2a (config JSON inline +
JS statique, substitutions sur le CORPS du bloc) :
- local_workspace.html → local_workspace.js (2 031 L, lw-config :
current_folder_id, workspace_id)
- settings.html → settings.js (1 093 L, st-config : avatar, user
full_name/login/email, is_admin (bool), auth_method — 2 routes rendent ce
template, expressions « or "" » préservées pour les valeurs Undefined)
- _page_editor_realtime.html → page_editor_realtime.js (531 L, rt-config :
SELF id/login/full_name/color)
- board.html → board.js (146 L, bd-config : owner/repo/initial_view)
BONUS sécurité : les valeurs passent par |tojson (échappement JSON explicite)
au lieu d'être interpolées dans des strings JS. Tags : config JSON (nonce
conservé) + <script src> ?v={{ asset_version }} ; loaders JSON.parse en tête
(try/catch → {}). Correctif sur le loader (accolade try en trop, caught par
node --check avant tout commit).
Cumul A27 : 10 560 L extraites (13 904 → 3 344 restantes, -76 %).
Reste structurel : base 1 338 ({% block %}/{% for %}) + database_table 1 323
(if/else) + 279 warnings eslint (12 fichiers, 0 erreur).
suite **1089/1089** · ruff OK · node --check ×4 vert · docs à jour
This commit is contained in:
@@ -1,5 +1,35 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
|
||||||
|
JSON (identique à la 2a) :
|
||||||
|
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
|
||||||
|
current_folder_id, workspace_id)
|
||||||
|
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
|
||||||
|
avatar_color, user full_name/login/email, is_admin, auth_method —
|
||||||
|
**2 routes rendent ce template**, les expressions `or ""` sont préservées
|
||||||
|
pour les valeurs Undefined, `is_admin` reste un booléen)
|
||||||
|
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
|
||||||
|
`rt-config` : SELF id/login/full_name/color)
|
||||||
|
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
|
||||||
|
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
|
||||||
|
explicite) au lieu d'être interpolées dans des strings JS
|
||||||
|
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
|
||||||
|
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
|
||||||
|
des fichiers extraits (try/catch → `{}`)
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
|
||||||
|
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
|
||||||
|
(12 fichiers, baseline mise à jour)
|
||||||
|
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
|
||||||
|
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
|
||||||
|
- Suite complète : **1089/1089**
|
||||||
|
|
||||||
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.22.0 (audit — A27 phase 2 : éditeur 2 516 L via page-data JSON) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.23.0 (audit — A27 phase 2b : +3 801 L, cumul 10 560 L) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.22.0",
|
version="7.23.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -9,535 +9,5 @@
|
|||||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||||
</style>
|
</style>
|
||||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
|
||||||
/* eslint-disable */
|
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
|
||||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
|
||||||
window.__fdRT = (function () {
|
|
||||||
const SELF = {
|
|
||||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
|
||||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
|
||||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
|
||||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
|
||||||
};
|
|
||||||
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 };
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
+2
-147
@@ -151,151 +151,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
|
||||||
var owner = '{{ owner }}';
|
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
|
||||||
var repo = '{{ repo }}';
|
|
||||||
var initialView = '{{ initial_view }}';
|
|
||||||
|
|
||||||
// Auto-switch to view from URL param
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (initialView && initialView !== 'kanban') {
|
|
||||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
|
||||||
if (tab) tab.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setActiveTab(el) {
|
|
||||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
|
||||||
el.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function kanbanBoard() {
|
|
||||||
return {
|
|
||||||
collapsedGroups: [],
|
|
||||||
toggleGroup(id) {
|
|
||||||
const idx = this.collapsedGroups.indexOf(id);
|
|
||||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
|
||||||
},
|
|
||||||
newCard(groupId, status) {
|
|
||||||
document.getElementById('new-issue-form').style.display = 'block';
|
|
||||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
|
||||||
},
|
|
||||||
newGroup() { console.log('New group'); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterSystem() {
|
|
||||||
return {
|
|
||||||
activeFilters: [],
|
|
||||||
statusFilters: [],
|
|
||||||
showStatusMenu: false,
|
|
||||||
statusOptions: [
|
|
||||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
|
||||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
|
||||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
|
||||||
],
|
|
||||||
get statusFilterLabel() {
|
|
||||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
|
||||||
},
|
|
||||||
toggleStatus(val) {
|
|
||||||
const idx = this.statusFilters.indexOf(val);
|
|
||||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
|
||||||
},
|
|
||||||
addFilter() {
|
|
||||||
const prop = prompt('Filter by property (status, assignee, label):');
|
|
||||||
if (!prop) return;
|
|
||||||
const val = prompt('Value:');
|
|
||||||
if (!val) return;
|
|
||||||
this.activeFilters.push({ property: prop, value: val });
|
|
||||||
this.refreshView();
|
|
||||||
},
|
|
||||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
|
||||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
|
||||||
refreshView() {
|
|
||||||
const activeTab = document.querySelector('.view-tab.active');
|
|
||||||
if (activeTab) activeTab.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortSystem() {
|
|
||||||
return {
|
|
||||||
sorts: [],
|
|
||||||
showSortPanel: false,
|
|
||||||
addSort() {
|
|
||||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
|
||||||
if (!field) return;
|
|
||||||
this.sorts.push({ field, dir: 'asc' });
|
|
||||||
this.applySorts();
|
|
||||||
},
|
|
||||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
|
||||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
|
||||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
|
||||||
applySorts() {
|
|
||||||
const activeTab = document.querySelector('.view-tab.active');
|
|
||||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
|
||||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
|
||||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function newIssueForm() {
|
|
||||||
return {
|
|
||||||
title: '', status: 'todo',
|
|
||||||
create() {
|
|
||||||
if (!this.title.trim()) return;
|
|
||||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
||||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
this.title = '';
|
|
||||||
this.hide();
|
|
||||||
// Refresh current view
|
|
||||||
const activeTab = document.querySelector('.view-tab.active');
|
|
||||||
if (activeTab) activeTab.click();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
|
||||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNewIssue() {
|
|
||||||
const form = document.getElementById('new-issue-form');
|
|
||||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init SortableJS after HTMX swaps
|
|
||||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
|
||||||
if (evt.target.id === 'view-content') {
|
|
||||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
|
||||||
if (el._sortable) el._sortable.destroy();
|
|
||||||
el._sortable = new Sortable(el, {
|
|
||||||
group: 'kanban',
|
|
||||||
animation: 200,
|
|
||||||
ghostClass: 'sortable-ghost',
|
|
||||||
dragClass: 'sortable-drag',
|
|
||||||
onEnd: function(evt) {
|
|
||||||
const cardId = evt.item.dataset.cardId;
|
|
||||||
const toStatus = evt.to.dataset.status;
|
|
||||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
||||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// ponytail: refresh current view after move
|
|
||||||
const activeTab = document.querySelector('.view-tab.active');
|
|
||||||
if (activeTab) activeTab.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2
-1094
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.22.0"
|
"version": "7.23.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
|
||||||
|
|
||||||
|
var owner = BD.owner;
|
||||||
|
var repo = BD.repo;
|
||||||
|
var initialView = BD.initial_view;
|
||||||
|
|
||||||
|
// Auto-switch to view from URL param
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (initialView && initialView !== 'kanban') {
|
||||||
|
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||||
|
if (tab) tab.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setActiveTab(el) {
|
||||||
|
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanBoard() {
|
||||||
|
return {
|
||||||
|
collapsedGroups: [],
|
||||||
|
toggleGroup(id) {
|
||||||
|
const idx = this.collapsedGroups.indexOf(id);
|
||||||
|
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||||
|
},
|
||||||
|
newCard(groupId, status) {
|
||||||
|
document.getElementById('new-issue-form').style.display = 'block';
|
||||||
|
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||||
|
},
|
||||||
|
newGroup() { console.log('New group'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSystem() {
|
||||||
|
return {
|
||||||
|
activeFilters: [],
|
||||||
|
statusFilters: [],
|
||||||
|
showStatusMenu: false,
|
||||||
|
statusOptions: [
|
||||||
|
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||||
|
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||||
|
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
get statusFilterLabel() {
|
||||||
|
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||||
|
},
|
||||||
|
toggleStatus(val) {
|
||||||
|
const idx = this.statusFilters.indexOf(val);
|
||||||
|
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||||
|
},
|
||||||
|
addFilter() {
|
||||||
|
const prop = prompt('Filter by property (status, assignee, label):');
|
||||||
|
if (!prop) return;
|
||||||
|
const val = prompt('Value:');
|
||||||
|
if (!val) return;
|
||||||
|
this.activeFilters.push({ property: prop, value: val });
|
||||||
|
this.refreshView();
|
||||||
|
},
|
||||||
|
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||||
|
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||||
|
refreshView() {
|
||||||
|
const activeTab = document.querySelector('.view-tab.active');
|
||||||
|
if (activeTab) activeTab.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSystem() {
|
||||||
|
return {
|
||||||
|
sorts: [],
|
||||||
|
showSortPanel: false,
|
||||||
|
addSort() {
|
||||||
|
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||||
|
if (!field) return;
|
||||||
|
this.sorts.push({ field, dir: 'asc' });
|
||||||
|
this.applySorts();
|
||||||
|
},
|
||||||
|
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||||
|
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||||
|
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||||
|
applySorts() {
|
||||||
|
const activeTab = document.querySelector('.view-tab.active');
|
||||||
|
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||||
|
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||||
|
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newIssueForm() {
|
||||||
|
return {
|
||||||
|
title: '', status: 'todo',
|
||||||
|
create() {
|
||||||
|
if (!this.title.trim()) return;
|
||||||
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
this.title = '';
|
||||||
|
this.hide();
|
||||||
|
// Refresh current view
|
||||||
|
const activeTab = document.querySelector('.view-tab.active');
|
||||||
|
if (activeTab) activeTab.click();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||||
|
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewIssue() {
|
||||||
|
const form = document.getElementById('new-issue-form');
|
||||||
|
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init SortableJS after HTMX swaps
|
||||||
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.target.id === 'view-content') {
|
||||||
|
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||||
|
if (el._sortable) el._sortable.destroy();
|
||||||
|
el._sortable = new Sortable(el, {
|
||||||
|
group: 'kanban',
|
||||||
|
animation: 200,
|
||||||
|
ghostClass: 'sortable-ghost',
|
||||||
|
dragClass: 'sortable-drag',
|
||||||
|
onEnd: function(evt) {
|
||||||
|
const cardId = evt.item.dataset.cardId;
|
||||||
|
const toStatus = evt.to.dataset.status;
|
||||||
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// ponytail: refresh current view after move
|
||||||
|
const activeTab = document.querySelector('.view-tab.active');
|
||||||
|
if (activeTab) activeTab.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
|||||||
|
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch(e){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 };
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user