891 lines
27 KiB
Plaintext
891 lines
27 KiB
Plaintext
---
|
||
import ProposeModal from "../components/ProposeModal.astro";
|
||
import SiteNav from "../components/SiteNav.astro";
|
||
import LevelToggle from "../components/LevelToggle.astro";
|
||
import BuildStamp from "../components/BuildStamp.astro";
|
||
import "../styles/global.css";
|
||
|
||
const {
|
||
title,
|
||
editionLabel,
|
||
editionKey,
|
||
statusLabel,
|
||
statusKey,
|
||
level,
|
||
version,
|
||
} = Astro.props;
|
||
|
||
const lvl = level ?? 1;
|
||
|
||
const canonical = Astro.site
|
||
? new URL(Astro.url.pathname, Astro.site).href
|
||
: Astro.url.href;
|
||
|
||
// Cible Gitea (injectée au build)
|
||
const GITEA_BASE = import.meta.env.PUBLIC_GITEA_BASE ?? "";
|
||
const GITEA_OWNER = import.meta.env.PUBLIC_GITEA_OWNER ?? "";
|
||
const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
||
---
|
||
|
||
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>{title ? `${title} — Archicratie` : "Archicratie"}</title>
|
||
|
||
<link rel="canonical" href={canonical} />
|
||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||
|
||
<meta data-pagefind-filter="edition[content]" content={String(editionKey ?? editionLabel)} />
|
||
<meta data-pagefind-filter="level[content]" content={String(lvl)} />
|
||
<meta data-pagefind-filter="status[content]" content={String(statusKey ?? statusLabel)} />
|
||
|
||
<meta data-pagefind-meta={`edition:${String(editionKey ?? editionLabel)}`} />
|
||
<meta data-pagefind-meta={`level:${String(lvl)}`} />
|
||
<meta data-pagefind-meta={`status:${String(statusKey ?? statusLabel)}`} />
|
||
<meta data-pagefind-meta={`version:${String(version ?? "")}`} />
|
||
</head>
|
||
|
||
<body data-doc-title={title} data-doc-version={version}>
|
||
<header>
|
||
<SiteNav />
|
||
<div class="edition-bar">
|
||
<span class="badge"><strong>Édition</strong> : {editionLabel}</span>
|
||
<span class="badge"><strong>Statut</strong> : {statusLabel}</span>
|
||
<span class="badge"><strong>Niveau</strong> : {lvl}</span>
|
||
<span class="badge"><strong>Version</strong> : {version}</span>
|
||
|
||
<a class="resume-btn" id="resume-btn" href="#" hidden>Reprendre la lecture</a>
|
||
|
||
<LevelToggle initialLevel={lvl} />
|
||
</div>
|
||
</header>
|
||
|
||
<main class="page">
|
||
<div class="page-shell">
|
||
<aside class="page-aside" aria-label="Navigation de lecture">
|
||
<div class="page-aside__scroll" aria-label="Sommaires">
|
||
<slot name="aside" />
|
||
</div>
|
||
</aside>
|
||
|
||
<article class="reading" data-pagefind-body>
|
||
<slot />
|
||
<BuildStamp />
|
||
</article>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Bandeau lecture (H1/H2/H3 uniquement) -->
|
||
<div class="reading-follow" id="reading-follow" aria-hidden="true">
|
||
<div class="reading-follow__inner">
|
||
<button class="rf-line rf-h1" id="rf-h1" type="button" hidden></button>
|
||
<button class="rf-line rf-h2" id="rf-h2" type="button" hidden></button>
|
||
<button class="rf-line rf-h3" id="rf-h3" type="button" hidden></button>
|
||
|
||
<!-- Actions : haut chapitre / haut section -->
|
||
<div class="rf-actions" aria-label="Navigation rapide">
|
||
<button
|
||
class="rf-btn"
|
||
id="rf-top-chapter"
|
||
type="button"
|
||
hidden
|
||
aria-label="Haut du chapitre"
|
||
title="Haut du chapitre"
|
||
>↑</button>
|
||
|
||
<button
|
||
class="rf-btn"
|
||
id="rf-top-section"
|
||
type="button"
|
||
hidden
|
||
aria-label="Haut de la section"
|
||
title="Haut de la section"
|
||
>↥</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ProposeModal />
|
||
|
||
<style>
|
||
.page{
|
||
padding: var(--page-gap) 16px 48px;
|
||
}
|
||
|
||
.page-shell{
|
||
--aside-w: 320px;
|
||
--reading-w: 78ch;
|
||
--gap: 18px;
|
||
|
||
display: grid;
|
||
grid-template-columns: 1fr var(--aside-w) minmax(0, var(--reading-w)) 1fr;
|
||
column-gap: var(--gap);
|
||
align-items: start;
|
||
}
|
||
|
||
.page-aside{
|
||
grid-column: 2;
|
||
position: sticky;
|
||
|
||
/* colle sous header + padding haut */
|
||
top: calc(var(--sticky-header-h) + var(--page-gap));
|
||
|
||
/* ⛔️ plus de scroll ici */
|
||
overflow: visible;
|
||
max-height: none;
|
||
}
|
||
|
||
.page-aside__scroll{
|
||
/* ✅ le scroll est ici, donc englobe TOC global + local */
|
||
max-height: calc(100vh - (var(--sticky-header-h) + var(--page-gap) + 12px));
|
||
overflow: auto;
|
||
|
||
padding-right: 8px;
|
||
padding-top: 6px;
|
||
|
||
scrollbar-gutter: stable;
|
||
}
|
||
|
||
.reading{
|
||
grid-column: 3;
|
||
max-width: none;
|
||
margin: 0;
|
||
}
|
||
|
||
@media (max-width: 1100px){
|
||
.page-shell{
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
grid-template-columns: var(--aside-w) minmax(0, 1fr);
|
||
}
|
||
.page-aside{ grid-column: 1; }
|
||
.reading{
|
||
grid-column: 2;
|
||
max-width: 78ch;
|
||
margin: 0 auto;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 860px){
|
||
.page-shell{
|
||
max-width: 78ch;
|
||
margin: 0 auto;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.page-aside{
|
||
grid-column: 1;
|
||
position: static;
|
||
overflow: visible;
|
||
margin-bottom: 12px;
|
||
}
|
||
.page-aside__scroll{
|
||
max-height: none;
|
||
overflow: visible;
|
||
padding-right: 0;
|
||
padding-top: 0;
|
||
}
|
||
.reading{
|
||
grid-column: 1;
|
||
max-width: 78ch;
|
||
margin: 0 auto;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
||
(() => {
|
||
try {
|
||
const PAGE_GAP = 12; // doit matcher --page-gap (css)
|
||
const HYST = 4; // micro-hystérésis px
|
||
|
||
const headerEl = document.querySelector("header");
|
||
const followEl = document.getElementById("reading-follow");
|
||
const reading = document.querySelector("article.reading");
|
||
|
||
const rfH1 = document.getElementById("rf-h1");
|
||
const rfH2 = document.getElementById("rf-h2");
|
||
const rfH3 = document.getElementById("rf-h3");
|
||
|
||
const btnTopChapter = document.getElementById("rf-top-chapter");
|
||
const btnTopSection = document.getElementById("rf-top-section");
|
||
|
||
function px(n){ return `${Math.max(0, Math.round(n))}px`; }
|
||
function setRootVar(name, value) {
|
||
document.documentElement.style.setProperty(name, value);
|
||
}
|
||
|
||
function headerH() {
|
||
return headerEl ? headerEl.getBoundingClientRect().height : 0;
|
||
}
|
||
|
||
function syncHeaderH() {
|
||
setRootVar("--sticky-header-h", px(headerH()));
|
||
setRootVar("--page-gap", px(PAGE_GAP));
|
||
}
|
||
|
||
function syncReadingRect() {
|
||
if (!reading) return;
|
||
const r = reading.getBoundingClientRect();
|
||
setRootVar("--reading-left", px(r.left));
|
||
setRootVar("--reading-width", px(r.width));
|
||
}
|
||
|
||
function syncHeadingMetrics() {
|
||
if (!reading) return;
|
||
|
||
const h1 = reading.querySelector("h1");
|
||
const h2 = reading.querySelector("h2");
|
||
const h3 = reading.querySelector("h3");
|
||
|
||
const sync = (el, prefix) => {
|
||
if (!el) return;
|
||
const cs = getComputedStyle(el);
|
||
if (cs.fontSize) setRootVar(`--rf-${prefix}-size`, cs.fontSize);
|
||
if (cs.lineHeight) setRootVar(`--rf-${prefix}-lh`, cs.lineHeight);
|
||
if (cs.fontWeight) setRootVar(`--rf-${prefix}-fw`, cs.fontWeight);
|
||
};
|
||
|
||
sync(h1, "h1");
|
||
sync(h2, "h2");
|
||
sync(h3, "h3");
|
||
}
|
||
|
||
syncHeaderH();
|
||
syncReadingRect();
|
||
syncHeadingMetrics();
|
||
|
||
if (document.fonts && document.fonts.ready) {
|
||
document.fonts.ready.then(() => {
|
||
syncHeaderH();
|
||
syncReadingRect();
|
||
syncHeadingMetrics();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
window.addEventListener("resize", () => {
|
||
requestAnimationFrame(() => {
|
||
syncHeaderH();
|
||
syncReadingRect();
|
||
syncHeadingMetrics();
|
||
});
|
||
});
|
||
|
||
function getStickyOffsetPx() {
|
||
const raw = getComputedStyle(document.documentElement)
|
||
.getPropertyValue("--sticky-offset-px")
|
||
.trim();
|
||
const n = Number.parseFloat(raw);
|
||
return Number.isFinite(n) ? n : (headerH() + PAGE_GAP);
|
||
}
|
||
|
||
function absTop(el) {
|
||
const r = el.getBoundingClientRect();
|
||
return r.top + (window.scrollY || 0);
|
||
}
|
||
|
||
function scrollToElWithOffset(el, extra = 10, behavior = "auto") {
|
||
const y = absTop(el) - getStickyOffsetPx() - extra;
|
||
window.scrollTo({ top: Math.max(0, y), behavior });
|
||
}
|
||
|
||
function openDetailsIfNeeded(el) {
|
||
const d = el && el.closest && el.closest("details");
|
||
if (d && !d.open) d.open = true;
|
||
}
|
||
|
||
// ==========================
|
||
// Bookmarks (pinned + auto)
|
||
// ==========================
|
||
const PINNED_KEY = "archicratie:bookmark:pinned";
|
||
const LAST_KEY = `archicratie:bookmark:last:${window.location.pathname}`;
|
||
|
||
function readJSON(key) {
|
||
try { return JSON.parse(localStorage.getItem(key) || "null"); }
|
||
catch { return null; }
|
||
}
|
||
function writeJSON(key, obj) {
|
||
try { localStorage.setItem(key, JSON.stringify(obj)); } catch {}
|
||
}
|
||
|
||
function updateResumeButton() {
|
||
const btn = document.getElementById("resume-btn");
|
||
if (!btn) return;
|
||
|
||
const bm = readJSON(PINNED_KEY) || readJSON(LAST_KEY);
|
||
if (!bm || !bm.url) { btn.hidden = true; return; }
|
||
|
||
btn.hidden = false;
|
||
btn.href = bm.url;
|
||
btn.title = bm.title ? `Reprendre : ${bm.title}` : "Reprendre la lecture";
|
||
|
||
btn.onclick = (ev) => {
|
||
try {
|
||
const here = new URL(window.location.href);
|
||
here.search = "";
|
||
|
||
const target = new URL(bm.url, window.location.origin);
|
||
target.search = "";
|
||
|
||
if (here.pathname === target.pathname) {
|
||
ev.preventDefault();
|
||
const id = (target.hash || "").slice(1);
|
||
if (!id) return;
|
||
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
|
||
openDetailsIfNeeded(el);
|
||
scrollToElWithOffset(el, 12, "auto");
|
||
history.replaceState(null, "", here.pathname + "#" + id);
|
||
}
|
||
} catch {}
|
||
};
|
||
}
|
||
|
||
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
||
if (window.location.search.includes("body=")) {
|
||
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||
}
|
||
|
||
// Hotfix compat citabilité : fallback #p-<idx>-<hash>
|
||
(function resolveLegacyParagraphHash() {
|
||
const h = window.location.hash || "";
|
||
const m = h.match(/^#p-(\d+)-[0-9a-f]{8}$/i);
|
||
if (!m) return;
|
||
|
||
const oldId = h.slice(1);
|
||
if (document.getElementById(oldId)) return;
|
||
|
||
const idx = m[1];
|
||
const prefix = `p-${idx}-`;
|
||
const replacement = document.querySelector(`[id^="${prefix}"]`);
|
||
if (!replacement) return;
|
||
|
||
console.warn("[anchors] legacy hash fallback used:", `#${oldId}`, "→", `#${replacement.id}`);
|
||
scrollToElWithOffset(replacement, 12, "auto");
|
||
})();
|
||
|
||
const docTitle = document.body.dataset.docTitle || document.title;
|
||
const docVersion = document.body.dataset.docVersion || "";
|
||
|
||
updateResumeButton();
|
||
|
||
// ==========================
|
||
// Gitea propose (optional)
|
||
// ==========================
|
||
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
|
||
const FULL_TEXT_SOFT_LIMIT = 1600;
|
||
const URL_HARD_LIMIT = 6500;
|
||
|
||
// ✅ Gate par groupe (Traefik→Authelia→LLDAP via /_auth/whoami)
|
||
const WHOAMI_PATH = "/_auth/whoami";
|
||
const PROPOSE_REQUIRED_GROUP = "editors";
|
||
|
||
let _authInfoPromise = null;
|
||
|
||
function parseWhoamiLine(text, key) {
|
||
const re = new RegExp(`^${key}:\\s*(.*)$`, "mi");
|
||
const m = String(text || "").match(re);
|
||
return (m?.[1] ?? "").trim();
|
||
}
|
||
|
||
async function getAuthInfo() {
|
||
if (_authInfoPromise) return _authInfoPromise;
|
||
|
||
_authInfoPromise = (async () => {
|
||
const res = await fetch(`${WHOAMI_PATH}?_=${Date.now()}`, {
|
||
credentials: "include",
|
||
cache: "no-store",
|
||
redirect: "follow",
|
||
});
|
||
|
||
const text = await res.text().catch(() => "");
|
||
|
||
const groups = parseWhoamiLine(text, "Remote-Groups")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
|
||
return {
|
||
ok: Boolean(res && res.ok),
|
||
user: parseWhoamiLine(text, "Remote-User"),
|
||
name: parseWhoamiLine(text, "Remote-Name"),
|
||
email: parseWhoamiLine(text, "Remote-Email"),
|
||
groups,
|
||
raw: text,
|
||
};
|
||
})().catch(() => ({
|
||
ok: false,
|
||
user: "",
|
||
name: "",
|
||
email: "",
|
||
groups: [],
|
||
raw: "",
|
||
}));
|
||
|
||
return _authInfoPromise;
|
||
}
|
||
|
||
// Promise unique : est-on editor ?
|
||
const isEditorP = giteaReady
|
||
? getAuthInfo().then((info) => info.groups.includes(PROPOSE_REQUIRED_GROUP)).catch(() => false)
|
||
: Promise.resolve(false);
|
||
|
||
const quoteBlock = (s) =>
|
||
String(s || "")
|
||
.split(/\r?\n/)
|
||
.map((l) => (`> ${l}`).trimEnd())
|
||
.join("\n");
|
||
|
||
function buildIssueURL(anchorId, fullText, excerpt) {
|
||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||
|
||
const local = new URL(window.location.href);
|
||
local.search = "";
|
||
local.hash = anchorId;
|
||
|
||
const path = local.pathname;
|
||
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
|
||
|
||
const hasFull = Boolean(fullText && fullText.length);
|
||
const canTryFull = hasFull && fullText.length <= FULL_TEXT_SOFT_LIMIT;
|
||
|
||
const makeBody = (embedFull) => {
|
||
const header = [
|
||
`Chemin: ${path}`,
|
||
`URL locale: ${local.toString()}`,
|
||
`Ancre: #${anchorId}`,
|
||
`Version: ${docVersion || "(non renseignée)"}`,
|
||
`Type: type/correction`,
|
||
`State: state/recevable`,
|
||
``,
|
||
];
|
||
|
||
const texteActuel = embedFull
|
||
? [`Texte actuel (copie exacte du paragraphe):`, quoteBlock(fullText)]
|
||
: [
|
||
`Texte actuel (extrait):`,
|
||
quoteBlock(excerpt || ""),
|
||
``,
|
||
`Note: paragraphe long → extrait (pour éviter une URL trop longue).`,
|
||
];
|
||
|
||
const footer = [
|
||
``,
|
||
`Proposition (remplacer par):`,
|
||
``,
|
||
`Justification:`,
|
||
``,
|
||
`---`,
|
||
`Note: issue générée depuis le site (pré-remplissage).`,
|
||
];
|
||
|
||
return header.concat(texteActuel, footer).join("\n");
|
||
};
|
||
|
||
issue.searchParams.set("title", issueTitle);
|
||
issue.searchParams.set("body", makeBody(Boolean(canTryFull)));
|
||
|
||
if (issue.toString().length > URL_HARD_LIMIT) {
|
||
issue.searchParams.set("body", makeBody(false));
|
||
}
|
||
|
||
return issue.toString();
|
||
}
|
||
|
||
// ==========================
|
||
// Paragraph tools (+ pinned bookmark)
|
||
// ==========================
|
||
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
|
||
|
||
for (const p of paras) {
|
||
if (p.querySelector(".para-tools")) continue;
|
||
|
||
const tools = document.createElement("span");
|
||
tools.className = "para-tools";
|
||
|
||
const row = document.createElement("span");
|
||
row.className = "para-tools-row";
|
||
|
||
const a = document.createElement("a");
|
||
a.className = "para-anchor";
|
||
a.href = `#${p.id}`;
|
||
a.setAttribute("aria-label", "Lien vers ce paragraphe");
|
||
a.textContent = "¶";
|
||
|
||
const citeBtn = document.createElement("button");
|
||
citeBtn.type = "button";
|
||
citeBtn.className = "para-cite";
|
||
citeBtn.textContent = "Citer";
|
||
|
||
citeBtn.addEventListener("click", async () => {
|
||
const pageUrl = new URL(window.location.href);
|
||
pageUrl.search = "";
|
||
pageUrl.hash = p.id;
|
||
|
||
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(cite);
|
||
const prev = citeBtn.textContent;
|
||
citeBtn.textContent = "Copié";
|
||
setTimeout(() => (citeBtn.textContent = prev), 900);
|
||
} catch {
|
||
window.prompt("Copiez la citation :", cite);
|
||
}
|
||
});
|
||
|
||
row.appendChild(a);
|
||
row.appendChild(citeBtn);
|
||
|
||
if (giteaReady) {
|
||
const propose = document.createElement("a");
|
||
propose.className = "para-propose";
|
||
propose.textContent = "Proposer";
|
||
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
|
||
|
||
// ✅ fail-closed DUR : inline-style (ne peut pas être overridé par ton CSS)
|
||
propose.style.display = "none";
|
||
propose.dataset.requiresGroup = PROPOSE_REQUIRED_GROUP;
|
||
|
||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||
|
||
const issueUrl = buildIssueURL(p.id, raw, excerpt);
|
||
|
||
// Lien fallback (si JS casse totalement)
|
||
propose.href = issueUrl;
|
||
|
||
// ✅ Marqueurs pour ProposeModal (interception 2 étapes)
|
||
propose.dataset.propose = "1";
|
||
propose.dataset.url = issueUrl;
|
||
propose.dataset.full = raw;
|
||
|
||
row.appendChild(propose);
|
||
}
|
||
|
||
tools.appendChild(row);
|
||
|
||
const bmBtn = document.createElement("button");
|
||
bmBtn.type = "button";
|
||
bmBtn.className = "para-bookmark";
|
||
bmBtn.textContent = "Marque-page";
|
||
|
||
bmBtn.addEventListener("click", () => {
|
||
const pageUrl = new URL(window.location.href);
|
||
pageUrl.search = "";
|
||
pageUrl.hash = p.id;
|
||
|
||
writeJSON(PINNED_KEY, {
|
||
url: pageUrl.toString(),
|
||
path: pageUrl.pathname,
|
||
anchor: p.id,
|
||
title: docTitle,
|
||
version: docVersion,
|
||
ts: Date.now(),
|
||
});
|
||
|
||
updateResumeButton();
|
||
|
||
const prev = bmBtn.textContent;
|
||
bmBtn.textContent = "Marqué ✓";
|
||
setTimeout(() => (bmBtn.textContent = prev), 900);
|
||
});
|
||
|
||
tools.appendChild(bmBtn);
|
||
p.appendChild(tools);
|
||
}
|
||
|
||
// ✅ Après insertion : on autorise Proposer seulement si groupe editors
|
||
if (giteaReady) {
|
||
isEditorP.then((ok) => {
|
||
const els = document.querySelectorAll(".para-propose");
|
||
for (const el of els) {
|
||
if (ok) el.style.display = "";
|
||
else el.remove();
|
||
}
|
||
}).catch(() => {
|
||
// fail-closed
|
||
document.querySelectorAll(".para-propose").forEach((el) => el.remove());
|
||
});
|
||
}
|
||
|
||
// Auto-checkpoint
|
||
let lastAuto = 0;
|
||
function writeLastSeen(id) {
|
||
const now = Date.now();
|
||
if (now - lastAuto < 800) return;
|
||
lastAuto = now;
|
||
|
||
const u = new URL(window.location.href);
|
||
u.search = "";
|
||
u.hash = id;
|
||
|
||
writeJSON(LAST_KEY, {
|
||
url: u.toString(),
|
||
path: u.pathname,
|
||
anchor: id,
|
||
title: docTitle,
|
||
version: docVersion,
|
||
ts: now,
|
||
});
|
||
}
|
||
|
||
const ioPara = new IntersectionObserver((entries) => {
|
||
const best = entries
|
||
.filter(e => e.isIntersecting)
|
||
.sort((a,b) => (b.intersectionRatio - a.intersectionRatio))[0];
|
||
|
||
if (!best) return;
|
||
const el = best.target;
|
||
if (el && el.id) {
|
||
writeLastSeen(el.id);
|
||
updateResumeButton();
|
||
}
|
||
}, { threshold: [0.6] });
|
||
|
||
for (const p of paras) ioPara.observe(p);
|
||
|
||
// ==========================
|
||
// Reading-follow (H1/H2/H3)
|
||
// ==========================
|
||
const h1 = reading ? reading.querySelector("h1") : null;
|
||
|
||
// H2 : soit via details-anchor (pour citabilité stable), soit via h2[id]
|
||
const h2Anchors = Array.from(document.querySelectorAll("span.details-anchor[id]"))
|
||
.map((s) => {
|
||
const d = s.closest("details");
|
||
const h2 = d ? d.querySelector(".details-body h2") : null;
|
||
const title = (h2?.textContent || "").trim() || "Section";
|
||
return h2 ? { id: s.id, anchor: s, marker: s, title, h2 } : null;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
const h2Plain = Array.from(document.querySelectorAll(".reading h2[id]"))
|
||
.map((h2) => ({
|
||
id: h2.id,
|
||
anchor: h2,
|
||
marker: h2,
|
||
title: (h2.textContent || "").trim() || "Section",
|
||
h2
|
||
}));
|
||
|
||
// On préfère le mode "anchors" s'il existe
|
||
const H2 = (h2Anchors.length ? h2Anchors : h2Plain)
|
||
.slice()
|
||
.sort((a,b) => absTop(a.marker) - absTop(b.marker));
|
||
|
||
const H3 = Array.from(document.querySelectorAll(".reading h3[id]"))
|
||
.map((h3) => ({ id: h3.id, el: h3, title: (h3.textContent || "").trim() || "Sous-section" }));
|
||
|
||
let curH2 = null;
|
||
let curH3 = null;
|
||
let lastY = window.scrollY || 0;
|
||
|
||
function computeLineY(followH) {
|
||
// “ligne” = bas du bandeau (header + page-gap + bandeau)
|
||
return headerH() + PAGE_GAP + (followH || 0) + HYST;
|
||
}
|
||
|
||
function pickH2(lineY) {
|
||
let cand = null;
|
||
|
||
for (const t of H2) {
|
||
const top = t.marker.getBoundingClientRect().top;
|
||
if (top <= lineY) cand = t; else break;
|
||
}
|
||
if (!cand && H2.length) cand = H2[0];
|
||
|
||
const down = (window.scrollY || 0) >= lastY;
|
||
if (!down && cand && curH2 && cand.id !== curH2.id) {
|
||
const topNew = cand.marker.getBoundingClientRect().top;
|
||
if (topNew > lineY - (HYST * 2)) cand = curH2;
|
||
}
|
||
return cand;
|
||
}
|
||
|
||
function isAfter(start, el) {
|
||
if (!start || !el) return false;
|
||
return Boolean(start.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||
}
|
||
function isBefore(end, el) {
|
||
if (!end || !el) return true; // pas de borne haute => OK
|
||
return Boolean(end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
|
||
}
|
||
|
||
function pickH3(lineY, activeH2) {
|
||
if (!activeH2) return null;
|
||
|
||
// H3 visibles (pas dans un details fermé)
|
||
const visible = H3.filter((t) => {
|
||
const d = t.el.closest?.("details");
|
||
return !(d && !d.open);
|
||
});
|
||
|
||
// Scope : même <details> si applicable (plus robuste)
|
||
const d2 = activeH2.h2?.closest?.("details") || activeH2.anchor?.closest?.("details") || null;
|
||
let scoped = d2 ? visible.filter((t) => t.el.closest?.("details") === d2) : visible;
|
||
|
||
// Scope DOM : entre H2 courant et H2 suivant (gère aussi les pages "plates")
|
||
const i2 = H2.findIndex((t) => t.id === activeH2.id);
|
||
const nextH2 = (i2 >= 0 && i2 + 1 < H2.length) ? H2[i2 + 1] : null;
|
||
|
||
const startNode = activeH2.anchor || activeH2.h2;
|
||
const endNode = nextH2 ? (nextH2.anchor || nextH2.h2) : null;
|
||
|
||
scoped = scoped.filter((t) => isAfter(startNode, t.el) && isBefore(endNode, t.el));
|
||
|
||
// Choisir le dernier H3 du scope qui a passé la ligne
|
||
let cand = null;
|
||
for (const t of scoped) {
|
||
const top = t.el.getBoundingClientRect().top;
|
||
if (top <= lineY) cand = t;
|
||
else break;
|
||
}
|
||
return cand;
|
||
}
|
||
|
||
function updateFollow() {
|
||
if (!followEl) return;
|
||
|
||
// PASS 1 : H1 visible quand le H1 du reading passe sous la barre
|
||
const baseY = headerH() + PAGE_GAP;
|
||
const showH1 = !!(h1 && (h1.getBoundingClientRect().bottom <= baseY + HYST));
|
||
|
||
if (rfH1 && h1 && showH1) {
|
||
rfH1.hidden = false;
|
||
rfH1.textContent = (h1.textContent || "").trim();
|
||
} else if (rfH1) {
|
||
rfH1.hidden = true;
|
||
}
|
||
|
||
// PASS 2 : estimation H2/H3 sans bandeau (pour mesurer hauteur réelle)
|
||
const lineY0 = computeLineY(0);
|
||
curH2 = pickH2(lineY0);
|
||
curH3 = pickH3(lineY0, curH2);
|
||
|
||
if (rfH2 && curH2) { rfH2.hidden = false; rfH2.textContent = curH2.title; }
|
||
else if (rfH2) rfH2.hidden = true;
|
||
|
||
if (rfH3 && curH3) { rfH3.hidden = false; rfH3.textContent = curH3.title; }
|
||
else if (rfH3) rfH3.hidden = true;
|
||
|
||
const any0 = (rfH1 && !rfH1.hidden) || (rfH2 && !rfH2.hidden) || (rfH3 && !rfH3.hidden);
|
||
followEl.classList.toggle("is-on", Boolean(any0));
|
||
followEl.setAttribute("aria-hidden", any0 ? "false" : "true");
|
||
|
||
const inner = followEl.querySelector(".reading-follow__inner");
|
||
const followH = (any0 && inner) ? inner.getBoundingClientRect().height : 0;
|
||
|
||
// PASS 3 : recalc H2/H3 avec la vraie ligne (bas du bandeau)
|
||
const lineY = computeLineY(followH);
|
||
curH2 = pickH2(lineY);
|
||
curH3 = pickH3(lineY, curH2);
|
||
|
||
// H2 n’apparaît que s’il est “actif” (titre passé sous la ligne)
|
||
if (rfH2 && curH2) {
|
||
const on = curH2.marker.getBoundingClientRect().top <= lineY;
|
||
rfH2.hidden = !on;
|
||
if (on) rfH2.textContent = curH2.title;
|
||
} else if (rfH2) rfH2.hidden = true;
|
||
|
||
// H3 : actif seulement si un H3 du H2 actif a passé la ligne
|
||
if (rfH3 && curH3) {
|
||
const on = curH3.el.getBoundingClientRect().top <= lineY;
|
||
rfH3.hidden = !on;
|
||
if (on) rfH3.textContent = curH3.title;
|
||
} else if (rfH3) rfH3.hidden = true;
|
||
|
||
const any = (rfH1 && !rfH1.hidden) || (rfH2 && !rfH2.hidden) || (rfH3 && !rfH3.hidden);
|
||
followEl.classList.toggle("is-on", Boolean(any));
|
||
followEl.setAttribute("aria-hidden", any ? "false" : "true");
|
||
|
||
const inner2 = followEl.querySelector(".reading-follow__inner");
|
||
const followH2 = (any && inner2) ? inner2.getBoundingClientRect().height : 0;
|
||
|
||
setRootVar("--followbar-h", px(followH2));
|
||
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP + followH2)));
|
||
|
||
// Boutons actions
|
||
if (btnTopChapter) btnTopChapter.hidden = !any;
|
||
|
||
if (btnTopSection) {
|
||
const ok = Boolean(curH2 && rfH2 && !rfH2.hidden);
|
||
btnTopSection.hidden = !ok;
|
||
}
|
||
|
||
lastY = window.scrollY || 0;
|
||
|
||
// recalage horizontal
|
||
syncReadingRect();
|
||
}
|
||
|
||
let ticking = false;
|
||
const onScroll = () => {
|
||
if (ticking) return;
|
||
ticking = true;
|
||
requestAnimationFrame(() => {
|
||
ticking = false;
|
||
updateFollow();
|
||
});
|
||
};
|
||
|
||
window.addEventListener("scroll", onScroll, { passive: true });
|
||
window.addEventListener("resize", onScroll);
|
||
|
||
// clics bandeau
|
||
if (rfH1) rfH1.addEventListener("click", () => {
|
||
if (!h1) return;
|
||
scrollToElWithOffset(h1, 12, "smooth");
|
||
});
|
||
|
||
if (rfH2) rfH2.addEventListener("click", () => {
|
||
if (!curH2) return;
|
||
openDetailsIfNeeded(curH2.anchor);
|
||
scrollToElWithOffset(curH2.anchor, 12, "smooth");
|
||
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
|
||
});
|
||
|
||
if (rfH3) rfH3.addEventListener("click", () => {
|
||
if (!curH3) return;
|
||
openDetailsIfNeeded(curH3.el);
|
||
scrollToElWithOffset(curH3.el, 12, "smooth");
|
||
history.replaceState(null, "", `${window.location.pathname}#${curH3.id}`);
|
||
});
|
||
|
||
// clics actions
|
||
if (btnTopChapter) btnTopChapter.addEventListener("click", () => {
|
||
if (!h1) return;
|
||
scrollToElWithOffset(h1, 12, "smooth");
|
||
});
|
||
|
||
if (btnTopSection) btnTopSection.addEventListener("click", () => {
|
||
if (!curH2) return;
|
||
openDetailsIfNeeded(curH2.anchor);
|
||
scrollToElWithOffset(curH2.anchor, 12, "smooth");
|
||
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
|
||
});
|
||
|
||
updateFollow();
|
||
|
||
// arrivée avec hash => ouvrir + offset
|
||
const initialHash = (location.hash || "").slice(1);
|
||
if (initialHash) {
|
||
const el = document.getElementById(initialHash);
|
||
if (el) {
|
||
openDetailsIfNeeded(el);
|
||
setTimeout(() => scrollToElWithOffset(el, 12, "auto"), 0);
|
||
}
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error("[EditionLayout] init failed:", err);
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|