Files
archicratie-edition/src/layouts/EditionLayout.astro
Archicratia 99cf0947da
All checks were successful
SMOKE / smoke (push) Successful in 5s
CI / build-and-anchors (push) Successful in 47s
CI / build-and-anchors (pull_request) Successful in 43s
fix(glossaire): align reading follow top actions with glossary navigation
2026-03-26 22:30:05 +01:00

1888 lines
57 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 SidePanel from "../components/SidePanel.astro";
import "../styles/global.css";
import "../styles/panel.css";
const {
title,
editionLabel,
editionKey,
statusLabel,
statusKey,
level,
version,
stickyMode = "default",
} = Astro.props;
const lvl = level ?? 1;
const isGlossaryEdition = String(editionKey ?? "") === "glossaire";
const showLevelToggle = !isGlossaryEdition;
const stickyModeValue = String(stickyMode || "default");
const canonical = Astro.site
? new URL(Astro.url.pathname, Astro.site).href
: Astro.url.href;
const IS_DEV = import.meta.env.DEV;
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 ?? "";
// ✅ OPTIONNEL : bridge serveur (proxy same-origin)
const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
// ✅ Auth whoami (same-origin) — configurable, antifragile en dev
const WHOAMI_PATH = import.meta.env.PUBLIC_WHOAMI_PATH ?? "/_auth/whoami";
// Par défaut: en DEV local on SKIP pour éviter le spam 404.
// Pour tester lauth en dev: export PUBLIC_WHOAMI_IN_DEV=1
const WHOAMI_IN_DEV = (import.meta.env.PUBLIC_WHOAMI_IN_DEV ?? "") === "1";
const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ?? "") === "1";
---
<!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 ?? "")}`} />
{/* ✅ BOOT EARLY : SidePanel dépend de ces globals. */}
<script
is:inline
define:vars={{
IS_DEV,
GITEA_BASE,
GITEA_OWNER,
GITEA_REPO,
ISSUE_BRIDGE_PATH,
WHOAMI_PATH,
WHOAMI_IN_DEV,
WHOAMI_FORCE_LOCALHOST,
}}
>
(() => {
// ✅ anti double-init (HMR / inclusion accidentelle)
if (window.__archiBootOnce === 1) return;
window.__archiBootOnce = 1;
var __DEV__ = Boolean(IS_DEV);
// ===== Gitea globals =====
var base = String(GITEA_BASE || "").replace(/\/+$/, "");
var owner = String(GITEA_OWNER || "");
var repo = String(GITEA_REPO || "");
window.__archiGitea = {
ready: Boolean(base && owner && repo),
base, owner, repo
};
// ===== optional issue bridge (same-origin proxy) =====
var rawBridge = String(ISSUE_BRIDGE_PATH || "").trim();
var normBridge = rawBridge
? (rawBridge.startsWith("/") ? rawBridge : ("/" + rawBridge.replace(/^\/+/, ""))).replace(/\/+$/, "")
: "";
window.__archiIssueBridge = { ready: Boolean(normBridge), path: normBridge };
// ===== whoami config =====
var __WHOAMI_PATH__ = String(WHOAMI_PATH || "/_auth/whoami");
var __WHOAMI_IN_DEV__ = Boolean(WHOAMI_IN_DEV);
// En dev: par défaut on SKIP (=> pas de spam 404). Override via PUBLIC_WHOAMI_IN_DEV=1.
var SHOULD_FETCH_WHOAMI = (!__DEV__) || __WHOAMI_IN_DEV__;
window.__archiFlags = Object.assign({}, window.__archiFlags, {
dev: __DEV__,
whoamiPath: __WHOAMI_PATH__,
whoamiInDev: __WHOAMI_IN_DEV__,
whoamiFetch: SHOULD_FETCH_WHOAMI,
});
var REQUIRED_GROUP = "editors";
var READ_GROUP = "readers";
function parseWhoamiLine(text, key) {
var re = new RegExp("^" + key + ":\\s*(.*)$", "mi");
var m = String(text || "").match(re);
return (m && m[1] ? m[1] : "").trim();
}
function inGroup(groups, g) {
var gg = String(g || "").toLowerCase();
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
}
// ===== Auth info promise (single source of truth) =====
if (!window.__archiAuthInfoP) {
window.__archiAuthInfoP = (async () => {
// ✅ dev default: skip
if (!SHOULD_FETCH_WHOAMI) {
return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
}
var res = null;
try {
res = await fetch(__WHOAMI_PATH__ + "?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
redirect: "manual",
headers: { Accept: "text/plain" },
});
} catch {
res = null;
}
if (!res) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.type === "opaqueredirect") return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.status >= 300 && res.status < 400) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.status === 404) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
var text = "";
try { text = await res.text(); } catch { text = ""; }
var looksLikeWhoami = /Remote-(User|Groups|Email|Name)\s*:/i.test(text);
if (!res.ok || !looksLikeWhoami) {
return { ok: false, user: "", name: "", email: "", groups: [], raw: text };
}
var groups = parseWhoamiLine(text, "Remote-Groups")
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.toLowerCase());
return {
ok: true,
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: "" }));
}
// readers + editors (strict)
if (!window.__archiCanReadP) {
window.__archiCanReadP = window.__archiAuthInfoP.then((info) =>
Boolean(info && info.ok && (inGroup(info.groups, READ_GROUP) || inGroup(info.groups, REQUIRED_GROUP)))
);
}
// editors gate for "Proposer"
if (!window.__archiIsEditorP) {
window.__archiIsEditorP = window.__archiAuthInfoP
// ✅ DEV fallback: si whoami absent/KO => Proposer autorisé (comme ton intention initiale)
.then((info) => Boolean(inGroup(info.groups, REQUIRED_GROUP) || (__DEV__ && !(info && info.ok))))
.catch(() => Boolean(__DEV__));
}
})();
</script>
</head>
<body
data-doc-title={title}
data-doc-version={version}
data-reading-level={String(lvl)}
data-edition-key={String(editionKey ?? "")}
data-sticky-mode={stickyModeValue}
>
<header>
<SiteNav />
<div class="edition-bar">
<span class="badge" data-badge="edition"><strong>Édition</strong> : {editionLabel}</span>
<span class="badge" data-badge="status"><strong>Statut</strong> : {statusLabel}</span>
<span class="badge" data-badge="version"><strong>Version</strong> : {version}</span>
<a class="resume-btn" id="resume-btn" href="#" hidden>Reprendre la lecture</a>
<form class="jump-form" id="jump-form" role="search" aria-label="Aller à un paragraphe par identifiant">
<input
class="jump-input"
id="jump-input"
type="search"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="Aller à ¶ (p-… / URL#p-… / extrait)"
aria-label="Identifiant ou extrait de paragraphe"
/>
<button class="jump-btn" id="jump-go" type="submit">Aller</button>
</form>
{showLevelToggle && <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>
<SidePanel />
</div>
</main>
<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>
<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>
:global(:root){
--glossary-local-sticky-h: 0px;
}
.page{
padding: var(--page-gap) 16px 48px;
}
/* ✅ Grille robustifiée + panel plus confortable (base) */
.page-shell{
--aside-w: 320px;
--reading-w: 78ch;
--panel-w: 420px;
--gap: 18px;
max-width: min(
calc(var(--aside-w) + var(--reading-w) + var(--panel-w) + (var(--gap) * 2)),
calc(100vw - 32px)
);
margin: 0 auto;
display: grid;
grid-template-columns:
var(--aside-w)
minmax(0, var(--reading-w))
minmax(0, var(--panel-w));
column-gap: var(--gap);
align-items: start;
}
.page-aside{
grid-column: 1;
position: sticky;
top: calc(var(--sticky-header-h) + var(--page-gap));
overflow: visible;
max-height: none;
}
.page-aside__scroll{
max-height: calc(100vh - (var(--sticky-header-h) + var(--page-gap) + 12px));
overflow: auto;
padding-right: 8px;
padding-top: 6px;
scrollbar-gutter: stable;
}
/* ✅ Mode “lecture continue” :
- on garde <details> pour les ancres / JS
- mais on supprime lUX accordéon (summary) et on montre le vrai H2
*/
:global(.reading details.details-section){
border: 0 !important;
padding: 0 !important;
margin: 22px 0 !important;
background: transparent !important;
}
/* summary invisible (plus de “Ouvrir la section…”) */
:global(.reading details.details-section > summary.details-summary){
position: absolute !important;
left: -9999px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
white-space: nowrap !important;
border: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
/* pas de “gap” artificiel ajouté après le summary */
:global(.reading details .details-body){
margin-top: 0 !important;
}
/* le vrai H2 redevient visible */
:global(.reading details .details-body > h2:first-child){
position: static !important;
left: auto !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
white-space: normal !important;
}
.reading{
grid-column: 2;
max-width: none;
margin: 0;
}
/* Niveau 1 : panel OFF + lecture large */
:global(body[data-reading-level="1"]) .page-shell{
--panel-w: 0px;
max-width: min(1560px, calc(100vw - 32px));
grid-template-columns: var(--aside-w) minmax(0, 1fr);
}
:global(body[data-reading-level="1"]) .page-panel{ display: none !important; }
/* Niveau 3 : lecture plus étroite + panel LARGE ; TOC masqué */
:global(body[data-reading-level="3"]) .page-shell{
--reading-w: 60ch;
--panel-w: clamp(720px, 48vw, 940px);
max-width: min(
calc(var(--reading-w) + var(--panel-w) + var(--gap)),
calc(100vw - 32px)
);
grid-template-columns:
minmax(0, var(--reading-w))
minmax(0, var(--panel-w));
}
:global(body[data-reading-level="3"]) .page-aside{ display: none; }
:global(body[data-reading-level="3"]) .reading{ grid-column: 1; }
/* Niveau 4 : TOC à gauche + panel confortable */
:global(body[data-reading-level="4"]) .page-shell{
--aside-w: 320px;
--reading-w: 64ch;
--panel-w: clamp(520px, 36vw, 720px);
max-width: min(
calc(var(--aside-w) + var(--reading-w) + var(--panel-w) + (var(--gap) * 2)),
calc(100vw - 32px)
);
grid-template-columns:
var(--aside-w)
minmax(0, var(--reading-w))
minmax(0, var(--panel-w));
}
:global(body[data-reading-level="4"]) .page-aside{ display: block; }
:global(body[data-reading-level="4"]) .reading{ grid-column: 2; }
@media (max-width: 1260px){
.page-shell{
max-width: 1180px;
grid-template-columns: var(--aside-w) minmax(0, 1fr);
}
.reading{ max-width: 78ch; margin: 0 auto; }
}
@media (max-width: 860px){
.page-shell{
max-width: 78ch;
grid-template-columns: 1fr;
}
.page-aside{
position: static;
margin-bottom: 12px;
}
.page-aside__scroll{
max-height: none;
overflow: visible;
padding-right: 0;
padding-top: 0;
}
.reading{ max-width: 78ch; margin: 0 auto; }
}
/* ===== para-tools layout (non destructif, surcouche) ===== */
:global(.reading p[id^="p-"]){
position: relative;
padding-right: 132px;
}
/* Glossaire : pas de rail para-tools */
:global(body[data-edition-key="glossaire"] .reading p[id^="p-"]){
padding-right: 0;
}
:global(.para-tools){
position: absolute;
right: 0;
top: -2px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
z-index: 2;
}
:global(.para-id){
border: 1px solid rgba(127,127,127,0.45);
background: rgba(127,127,127,0.08);
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 750;
cursor: pointer;
user-select: none;
}
:global(.para-tools-actions){
display: flex;
gap: 6px;
}
:global(.para-bookmark){
align-self: flex-end;
}
/* ✅ repère visuel du para "pilotant" le SidePanel */
:global(.reading p.is-panel-current){
background: rgba(140,140,255,0.07);
border-radius: 12px;
}
:global(body[data-edition-key="glossaire"] .reading p.is-panel-current){
background: transparent;
border-radius: 0;
}
</style>
<script is:inline>
(() => {
const safe = (label, fn) => {
try { fn(); }
catch (e) { console.error(`[EditionLayout] ${label} failed:`, e); }
};
const PAGE_GAP = 12;
const HYST = 4;
const body = document.body;
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");
const docTitle = document.body?.dataset?.docTitle || document.title || "Archicratie";
const docVersion = document.body?.dataset?.docVersion || "";
const docEditionKey = document.body?.dataset?.editionKey || "";
const docStickyMode = String(document.body?.dataset?.stickyMode || "default");
const isGlossaryEdition = docEditionKey === "glossaire";
const hasLocalGlossaryFollow =
isGlossaryEdition && Boolean(document.getElementById("glossary-hero-follow"));
const isGlossaryHomeMode =
docStickyMode === "glossary-home" || hasLocalGlossaryFollow;
const isGlossaryEntryMode =
isGlossaryEdition && docStickyMode === "glossary-entry";
const isGlossaryPortalMode =
isGlossaryEdition && docStickyMode === "glossary-portal";
function syncGlossaryFollowState(isOn) {
if (!body || !isGlossaryEdition) return;
body.classList.toggle(
"glossary-follow-on",
Boolean(isOn && isGlossaryPortalMode)
);
}
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 readPxVar(name) {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
const n = Number.parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}
function autoMeasureGlossaryLocalStickyHeight() {
if (isGlossaryEntryMode) {
const head = document.querySelector(".glossary-entry-head");
return head ? Math.round(head.getBoundingClientRect().height) : 0;
}
if (isGlossaryPortalMode) {
const hero =
document.querySelector(".scene-hero") ||
document.querySelector(".glossary-portal-hero") ||
document.querySelector(".glossary-page-hero");
return hero ? Math.round(hero.getBoundingClientRect().height) : 0;
}
return 0;
}
function getLocalStickyHeight() {
const explicit = readPxVar("--glossary-local-sticky-h");
if (explicit > 0) return explicit;
return autoMeasureGlossaryLocalStickyHeight();
}
function syncFollowTop() {
if (!followEl) return;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
followEl.style.top = px(headerH() + PAGE_GAP + localH);
}
window.__archiSetLocalStickyHeight = (n) => {
const value = Math.max(0, Math.round(Number(n) || 0));
setRootVar("--glossary-local-sticky-h", px(value));
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
};
window.__archiResetLocalStickyHeight = () => {
setRootVar("--glossary-local-sticky-h", "0px");
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
};
function syncHeaderH() {
setRootVar("--sticky-header-h", px(headerH()));
setRootVar("--page-gap", px(PAGE_GAP));
syncFollowTop();
}
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(() => {});
}
function getStickyOffsetPx() {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sticky-offset-px")
.trim();
const n = Number.parseFloat(raw);
if (Number.isFinite(n)) return n;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
return headerH() + PAGE_GAP + localH;
}
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 });
}
// ===== details open (robuste) + export =====
function openDetailsIfNeeded(el) {
try {
if (!el) return;
let d = el.closest?.("details") || null;
if (!d && el.classList?.contains("details-anchor")) {
const n = el.nextElementSibling;
if (n && n.tagName === "DETAILS") d = n;
}
if (!d) {
const s = el.closest?.("summary");
if (s && s.parentElement && s.parentElement.tagName === "DETAILS") d = s.parentElement;
}
if (d && d.tagName === "DETAILS" && !d.open) d.open = true;
} catch {}
}
window.__archiOpenDetailsIfNeeded = openDetailsIfNeeded;
function highlightEl(el) {
try {
if (!el) return;
el.classList.add("para-highlight");
setTimeout(() => el.classList.remove("para-highlight"), 2400);
} catch {}
}
function highlightFromTarget(el) {
try {
if (!el) return;
if (el.matches?.('.reading p[id^="p-"]')) return highlightEl(el);
const p = el.closest?.('.reading p[id^="p-"]');
if (p) return highlightEl(p);
if (el.classList?.contains("details-anchor")) {
const d = (el.nextElementSibling && el.nextElementSibling.tagName === "DETAILS") ? el.nextElementSibling : null;
const p2 = d ? d.querySelector('.details-body p[id^="p-"]') : null;
if (p2) return highlightEl(p2);
}
highlightEl(el);
} catch {}
}
function cssEscape(s) {
try { return (window.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/["\\]/g, "\\$&"); }
catch { return String(s).replace(/["\\]/g, "\\$&"); }
}
// ===== 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");
highlightFromTarget(el);
history.replaceState(null, "", here.pathname + "#" + id);
}
} catch {}
};
}
window.__archiUpdateResumeButton = updateResumeButton;
if (window.location.search.includes("body=")) {
history.replaceState(null, "", window.location.pathname + window.location.hash);
}
// ===== legacy hash fallback (inchangé) =====
safe("legacy-hash", () => {
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(`.reading [id^="${prefix}"]`);
if (!replacement) return;
console.warn("[anchors] legacy hash fallback used:", `#${oldId}`, "→", `#${replacement.id}`);
scrollToElWithOffset(replacement, 12, "auto");
highlightFromTarget(replacement);
});
updateResumeButton();
// ===== Jump (id / URL#id / extrait) =====
const jumpForm = document.getElementById("jump-form");
const jumpInput = document.getElementById("jump-input");
function flashJumpError() {
if (!jumpInput) return;
jumpInput.classList.add("is-error");
setTimeout(() => jumpInput.classList.remove("is-error"), 650);
}
function normalizeParaId(input) {
const raw = String(input || "").trim();
if (!raw) return "";
const hashIdx = raw.lastIndexOf("#");
const h = (hashIdx >= 0) ? raw.slice(hashIdx + 1) : raw;
const id = h.replace(/^#/, "").trim();
if (!/^p-\d+-/i.test(id)) return "";
return id;
}
function findParaLocalByIdOrPrefix(id) {
const exact = document.getElementById(id);
if (exact) return exact;
return document.querySelector(`.reading [id^="${cssEscape(id)}"]`);
}
function normalizeForSearch(s) {
return String(s || "")
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/\s+/g, " ")
.trim();
}
let _paraIndexP = null;
async function loadParaIndex() {
if (_paraIndexP) return _paraIndexP;
_paraIndexP = (async () => {
const res = await fetch("/para-index.json?_=" + Date.now(), { cache: "no-store" }).catch(() => null);
if (res && res.ok) return await res.json();
return null;
})();
return _paraIndexP;
}
function gotoPageAnchor(page, id) {
const url = new URL(page, window.location.origin);
url.hash = id;
window.location.assign(url.toString());
}
async function doJump(input) {
const raw = String(input || "").trim();
if (!raw) return false;
const id = normalizeParaId(raw);
if (id) {
const local = findParaLocalByIdOrPrefix(id);
if (local) {
openDetailsIfNeeded(local);
scrollToElWithOffset(local, 12, "auto");
highlightFromTarget(local);
history.replaceState(null, "", `${window.location.pathname}#${local.id}`);
return true;
}
const idx = await loadParaIndex();
if (idx?.byId && idx.items && idx.byId[id] != null) {
const item = idx.items[idx.byId[id]];
if (item?.page && item?.id) {
const targetPath = new URL(item.page, window.location.origin).pathname;
if (targetPath === window.location.pathname) {
window.location.hash = item.id;
return true;
}
gotoPageAnchor(item.page, item.id);
return true;
}
}
flashJumpError();
return false;
}
const q = normalizeForSearch(raw);
if (q.length < 10) { flashJumpError(); return false; }
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
for (const p of paras) {
const t = normalizeForSearch(p.innerText || p.textContent || "");
if (t.includes(q)) {
openDetailsIfNeeded(p);
scrollToElWithOffset(p, 12, "auto");
highlightFromTarget(p);
history.replaceState(null, "", `${window.location.pathname}#${p.id}`);
return true;
}
}
const idx = await loadParaIndex();
if (!idx?.items) { flashJumpError(); return false; }
let best = null;
let bestScore = -1;
for (const it of idx.items) {
if (!it?.text || !it?.page || !it?.id) continue;
const t = normalizeForSearch(it.text);
const pos = t.indexOf(q);
if (pos < 0) continue;
const score = (100000 - pos) + Math.min(4000, q.length * 8);
if (score > bestScore) { bestScore = score; best = it; }
}
if (!best) { flashJumpError(); return false; }
const targetPath = new URL(best.page, window.location.origin).pathname;
if (targetPath === window.location.pathname) {
window.location.hash = best.id;
return true;
}
gotoPageAnchor(best.page, best.id);
return true;
}
safe("jump-form", () => {
if (!jumpForm || !jumpInput) return;
jumpForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const ok = await doJump(jumpInput.value);
if (ok) jumpInput.value = "";
});
});
// ===== Gitea globals (déjà posés par le boot head) =====
const giteaReady = Boolean(window.__archiGitea && window.__archiGitea.ready);
const quoteBlock = (s) =>
String(s || "")
.split(/\r?\n/)
.map((l) => (`> ${l}`).trimEnd())
.join("\n");
function buildIssueURLCorrection(anchorId, fullText, excerpt) {
const g = window.__archiGitea || { base:"", owner:"", repo:"" };
const base = String(g.base).replace(/\/+$/, "");
const issue = new URL(`${base}/${g.owner}/${g.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 makeBody = (useFull) => {
const header = [
`Chemin: ${path}`,
`URL: ${local.toString()}`,
`Ancre: #${anchorId}`,
`Version: ${docVersion || "(non renseignée)"}`,
`Type: type/correction`,
`State: state/recevable`,
``,
];
const texteActuel = useFull
? [`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);
const fullOk = Boolean(fullText && fullText.length && fullText.length <= 1600);
issue.searchParams.set("body", makeBody(fullOk));
if (issue.toString().length > 6500) {
issue.searchParams.set("body", makeBody(false));
}
return issue.toString();
}
// ===== Paragraph tools + pinned bookmark =====
const parasAll = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
function normalizeSpaces(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
function getSelectionTextWithinPara(pEl) {
try {
const sel = window.getSelection ? window.getSelection() : null;
if (!sel || sel.isCollapsed) return "";
const t = String(sel.toString() || "").trim();
if (!t) return "";
const a = sel.anchorNode;
const f = sel.focusNode;
if (!a || !f) return "";
if (!pEl.contains(a) || !pEl.contains(f)) return "";
return normalizeSpaces(t);
} catch { return ""; }
}
function getCleanParagraphText(pEl) {
try {
const clone = pEl.cloneNode(true);
clone.querySelectorAll(".para-tools").forEach((n) => n.remove());
clone.querySelectorAll("[data-noquote]").forEach((n) => n.remove());
return normalizeSpaces(clone.innerText || "");
} catch {
return normalizeSpaces(pEl.innerText || pEl.textContent || "");
}
}
function hideEl(el) {
try {
el.hidden = true;
el.style.display = "none";
el.setAttribute("aria-hidden", "true");
el.setAttribute("tabindex", "-1");
} catch {}
}
function showEl(el) {
try {
el.hidden = false;
el.style.display = "";
el.removeAttribute("aria-hidden");
el.removeAttribute("tabindex");
} catch {}
}
safe("para-tools", () => {
if (isGlossaryEdition) return;
for (const p of parasAll) {
if (p.querySelector(".para-tools")) continue;
const tools = document.createElement("span");
tools.className = "para-tools";
tools.setAttribute("data-noquote", "1");
const idBtn = document.createElement("button");
idBtn.type = "button";
idBtn.className = "para-id";
idBtn.textContent = p.id;
idBtn.title = "Copier le lien du paragraphe";
idBtn.addEventListener("click", async () => {
try {
const u = new URL(window.location.href);
u.search = "";
u.hash = p.id;
await navigator.clipboard.writeText(u.toString());
const prev = idBtn.textContent;
idBtn.textContent = "Lien copié";
setTimeout(() => (idBtn.textContent = prev), 900);
} catch {}
});
const actions = document.createElement("span");
actions.className = "para-tools-actions";
const citeBtn = document.createElement("button");
citeBtn.type = "button";
citeBtn.className = "para-cite";
citeBtn.textContent = "Citer";
citeBtn.addEventListener("click", async (ev) => {
const pageUrl = new URL(window.location.href);
pageUrl.search = "";
pageUrl.hash = p.id;
const signature = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
let payload = signature;
if (!(ev && ev.shiftKey)) {
const selTxt = getSelectionTextWithinPara(p);
const paraTxt = selTxt || getCleanParagraphText(p);
payload = paraTxt ? `${paraTxt}\n\n${signature}` : signature;
}
try {
await navigator.clipboard.writeText(payload);
const prev = citeBtn.textContent;
citeBtn.textContent = (ev && ev.shiftKey) ? "Lien copié" : "Copié";
setTimeout(() => (citeBtn.textContent = prev), 900);
} catch {
window.prompt("Copiez la citation :", payload);
}
});
actions.appendChild(citeBtn);
let propose = null;
if (giteaReady) {
propose = document.createElement("a");
propose.className = "para-propose";
propose.textContent = "Proposer";
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
const raw = getCleanParagraphText(p);
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
const issueUrl = buildIssueURLCorrection(p.id, raw, excerpt);
propose.href = issueUrl;
propose.target = "_blank";
propose.rel = "noopener noreferrer";
propose.dataset.propose = "1";
propose.dataset.url = issueUrl;
propose.dataset.full = raw;
hideEl(propose);
actions.appendChild(propose);
}
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(idBtn);
tools.appendChild(actions);
tools.appendChild(bmBtn);
p.appendChild(tools);
}
});
safe("propose-gate", () => {
if (isGlossaryEdition) return;
if (!giteaReady) return;
const p = window.__archiIsEditorP || Promise.resolve(false);
p.then((ok) => {
document.querySelectorAll(".para-propose").forEach((el) => {
if (ok) showEl(el);
else hideEl(el); // ✅ jamais remove => antifragile
});
}).catch((err) => {
console.warn("[proposer] gate failed; keeping Proposer hidden", err);
document.querySelectorAll(".para-propose").forEach((el) => hideEl(el));
});
});
// ===== Auto-checkpoint (sur paragraphe ACTIF, pas sur hover) =====
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,
});
}
// ===== Détection “para actif” : juste sous reading-follow (✅ fixe le mismatch SidePanel) =====
let __activeParaId = "";
let __hoverParaId = "";
let __panelParaEl = null;
let __rafActive = 0;
function markPanelCurrent(id) {
try {
if (__panelParaEl) __panelParaEl.classList.remove("is-panel-current");
__panelParaEl = null;
const el = document.getElementById(id);
const p = el?.matches?.('.reading p[id^="p-"]') ? el : el?.closest?.('.reading p[id^="p-"]');
if (p) {
p.classList.add("is-panel-current");
__panelParaEl = p;
}
} catch {}
}
function dispatchCurrentPara(id, kind) {
try { window.dispatchEvent(new CustomEvent("archicratie:currentPara", { detail: { id, kind } })); } catch {}
}
function setCurrentPara(id, kind = "active") {
if (!id) return;
if (window.__archiCurrentParaId === id && kind !== "hover") return;
window.__archiCurrentParaId = id;
window.__archiLastParaId = id; // utile pour level switch
markPanelCurrent(id);
dispatchCurrentPara(id, kind);
}
function probeParaUnderFollow() {
if (!reading) return null;
const rr = reading.getBoundingClientRect();
if (!rr || rr.width < 10) return null;
const x = Math.round(rr.left + Math.min(140, rr.width * 0.45));
const baseY = Math.round(getStickyOffsetPx() + 14);
const ys = [baseY, baseY + 18, baseY + 36, baseY + 54, baseY + 72, baseY + 90, baseY + 108];
for (const y of ys) {
const stack = (document.elementsFromPoint ? document.elementsFromPoint(x, y) : [document.elementFromPoint(x, y)])
.filter(Boolean);
for (const el of stack) {
const p = el.closest?.('.reading p[id^="p-"]');
if (p && p.id) return p;
}
}
const y0 = baseY;
for (const p of parasAll) {
const r = p.getBoundingClientRect();
if (r.top <= y0 && r.bottom > y0) return p;
if (r.top > y0) return p;
}
return null;
}
function recomputeActivePara() {
const p = probeParaUnderFollow();
const id = p?.id || "";
if (!id) return;
if (id !== __activeParaId) {
__activeParaId = id;
writeLastSeen(id);
updateResumeButton();
}
if (!__hoverParaId) setCurrentPara(__activeParaId, "active");
}
function scheduleActive() {
if (__rafActive) return;
__rafActive = requestAnimationFrame(() => {
__rafActive = 0;
recomputeActivePara();
});
}
safe("active-para", () => {
if (isGlossaryEdition) return;
scheduleActive();
window.addEventListener("scroll", scheduleActive, { passive: true });
});
safe("hover-para", () => {
if (isGlossaryEdition) return;
if (!reading) return;
let t = 0;
reading.addEventListener("pointerover", (ev) => {
const p = ev.target?.closest?.('.reading p[id^="p-"]');
if (!p || !p.id) return;
__hoverParaId = p.id;
if (t) { clearTimeout(t); t = 0; }
setCurrentPara(__hoverParaId, "hover");
});
reading.addEventListener("pointerout", () => {
if (t) clearTimeout(t);
t = setTimeout(() => {
__hoverParaId = "";
if (__activeParaId) setCurrentPara(__activeParaId, "active");
}, 120);
});
});
/* ✅ NOUVEAU : clic paragraphe => snap sous reading-follow + SidePanel aligné */
safe("click-para-align", () => {
if (isGlossaryEdition) return;
if (!reading) return;
reading.addEventListener("click", (ev) => {
try {
const t = ev.target;
// ne pas casser les boutons / liens / para-tools
if (t && t.closest && t.closest(".para-tools")) return;
const a = t && t.closest ? t.closest("a") : null;
if (a) return;
const p = t && t.closest ? t.closest('.reading p[id^="p-"]') : null;
if (!p || !p.id) return;
// si sélection texte en cours => ne pas snap
const sel = window.getSelection ? window.getSelection() : null;
if (sel && !sel.isCollapsed) return;
openDetailsIfNeeded(p);
// snap immédiat sous followbar
scrollToElWithOffset(p, 12, "auto");
// URL stable
history.replaceState(null, "", `${location.pathname}#${p.id}`);
// force la source de vérité (SidePanel suit via event archicratie:currentPara)
__hoverParaId = "";
__activeParaId = p.id;
setCurrentPara(p.id, "click");
writeLastSeen(p.id);
updateResumeButton();
} catch {}
}, { passive: true });
});
// ===== Hash handling (robuste) =====
function resolveParaIdFromEl(el) {
if (!el) return "";
if (el.matches?.('.reading p[id^="p-"]')) return el.id;
const p = el.closest?.('.reading p[id^="p-"]');
if (p?.id) return p.id;
if (el.classList?.contains("details-anchor")) {
const d = (el.nextElementSibling && el.nextElementSibling.tagName === "DETAILS") ? el.nextElementSibling : null;
const p2 = d ? d.querySelector('.details-body p[id^="p-"]') : null;
if (p2?.id) return p2.id;
}
return "";
}
function handleHashTarget(id, behavior = "auto") {
try {
const clean = String(id || "").replace(/^#/, "").trim();
if (!clean) return false;
const el = document.getElementById(clean);
if (!el) return false;
openDetailsIfNeeded(el);
scrollToElWithOffset(el, 12, behavior);
highlightFromTarget(el);
history.replaceState(null, "", `${window.location.pathname}#${clean}`);
const pid = resolveParaIdFromEl(el);
if (pid) {
__activeParaId = pid;
setCurrentPara(pid, "hash");
writeLastSeen(pid);
updateResumeButton();
}
return true;
} catch { return false; }
}
safe("hash-links", () => {
document.addEventListener("click", (ev) => {
const a = ev.target?.closest?.('a[href^="#"]');
if (!a) return;
const href = a.getAttribute("href") || "";
if (!href || href === "#") return;
const id = href.slice(1);
if (!id) return;
if (!document.getElementById(id)) return;
ev.preventDefault();
handleHashTarget(id, "smooth");
});
window.addEventListener("hashchange", () => {
handleHashTarget((window.location.hash || "").slice(1), "auto");
});
});
// ===== TOC local sync (inchangé) =====
function isScrollable(el) {
try {
if (!el || !(el instanceof HTMLElement)) return false;
const cs = getComputedStyle(el);
const oy = cs.overflowY;
if (!(oy === "auto" || oy === "scroll" || oy === "overlay")) return false;
return el.scrollHeight > el.clientHeight + 2;
} catch { return false; }
}
function findScrollParent(fromEl, stopEl) {
let n = fromEl;
while (n && n !== document.body) {
if (stopEl && n === stopEl) break;
if (isScrollable(n)) return n;
n = n.parentElement;
}
if (stopEl && isScrollable(stopEl)) return stopEl;
return null;
}
function syncTocLocal(activeId) {
if (!activeId) return;
const list =
document.querySelector(".toc-local__list") ||
document.querySelector("[data-toc-local]") ||
document.querySelector(".toc-local ul") ||
null;
if (!list) return;
const sel = `a[href="#${cssEscape(activeId)}"]`;
const link = list.querySelector(sel);
if (!link) return;
list.querySelectorAll("a[aria-current]").forEach((a) => a.removeAttribute("aria-current"));
link.setAttribute("aria-current", "location");
const stop = document.querySelector(".page-aside__scroll") || null;
const scrollBox = findScrollParent(link, stop) || stop || list;
if (!scrollBox) return;
const pad = 12;
const rLink = link.getBoundingClientRect();
const rBox = scrollBox.getBoundingClientRect();
const linkTopInBox = (rLink.top - rBox.top) + scrollBox.scrollTop;
const target = Math.max(0, Math.round(linkTopInBox - pad));
try { scrollBox.scrollTo({ top: target, behavior: "auto" }); }
catch { scrollBox.scrollTop = target; }
}
window.__archiSyncTocLocal = syncTocLocal;
// ===== Reading-follow (H1/H2/H3 only) =====
safe("reading-follow", () => {
if (!reading || !followEl) return;
// Home du glossaire : le hero local gère déjà son propre follow.
// Le follow global doit s'effacer totalement.
if (isGlossaryHomeMode) {
if (rfH1) rfH1.hidden = true;
if (rfH2) rfH2.hidden = true;
if (rfH3) rfH3.hidden = true;
if (btnTopChapter) btnTopChapter.hidden = true;
if (btnTopSection) btnTopSection.hidden = true;
followEl.classList.remove("is-on");
followEl.setAttribute("aria-hidden", "true");
followEl.style.display = "none";
setRootVar("--followbar-h", "0px");
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP)));
syncGlossaryFollowState(false);
return;
}
const h1 = reading.querySelector("h1");
const topChapterLabel =
isGlossaryEntryMode
? "Haut de la fiche"
: isGlossaryEdition
? "Haut de la page"
: "Haut du chapitre";
if (btnTopChapter) {
btnTopChapter.setAttribute("aria-label", topChapterLabel);
btnTopChapter.setAttribute("title", topChapterLabel);
}
function scrollToTopChapter(behavior = "smooth") {
if (isGlossaryEdition) {
window.scrollTo({ top: 0, behavior });
return;
}
if (h1) {
scrollToElWithOffset(h1, 12, behavior);
}
}
function getH2ScrollTarget(item) {
return item?.h2 || item?.anchor || item?.marker || null;
}
const h2Anchors = Array.from(reading.querySelectorAll(".details-anchor[id]"))
.map((s) => {
const d = (s.nextElementSibling && s.nextElementSibling.tagName === "DETAILS")
? s.nextElementSibling
: (s.closest?.("details") || null);
const h2 = d ? d.querySelector(".details-body h2") : null;
const sum = d ? d.querySelector("summary") : null;
const title =
((s.getAttribute("data-title") || "").trim()
|| (h2?.textContent || "").trim()
|| (sum?.textContent || "").trim()
|| "Section");
const marker = d || s;
return { id: s.id, anchor: s, marker, title, h2, details: d };
})
.filter(Boolean);
const h2Plain = Array.from(reading.querySelectorAll("h2[id]"))
.filter((h2) => !h2.closest("details.details-section"))
.map((h2) => ({
id: h2.id,
anchor: h2,
marker: h2,
title: (h2.textContent || "").trim() || "Section",
h2,
}));
const H2 = [...h2Anchors, ...h2Plain]
.slice()
.sort((a, b) => absTop(a.marker) - absTop(b.marker));
const H3 = Array.from(reading.querySelectorAll("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;
let lastOpenedH2 = "";
function computeLineY(followH) {
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
return headerH() + PAGE_GAP + localH + (followH || 0) + HYST;
}
function hasCrossedTop(el, lineY) {
return Boolean(el && el.getBoundingClientRect().top <= lineY);
}
function hasCrossedBottom(el, lineY) {
return Boolean(el && el.getBoundingClientRect().bottom <= lineY);
}
function getH2DisplayEl(item) {
return item?.h2 || item?.marker || item?.anchor || null;
}
function getH3DisplayEl(item) {
return item?.el || null;
}
function pickH2(lineY) {
if (!H2.length) return null;
let cand = null;
for (const t of H2) {
const box = getH2DisplayEl(t);
if (hasCrossedBottom(box, lineY)) cand = t;
else break;
}
if (!cand) return null;
const down = (window.scrollY || 0) >= lastY;
if (!down && cand && curH2 && cand.id !== curH2.id) {
const box = getH2DisplayEl(cand);
const bottomNew = box ? box.getBoundingClientRect().bottom : Infinity;
if (bottomNew > 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;
return Boolean(end.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);
}
function pickH3(lineY, activeH2) {
if (!activeH2) return null;
const visible = H3.filter((t) => {
const d = t.el.closest?.("details");
return !(d && !d.open);
});
const d2 = activeH2.details || activeH2.h2?.closest?.("details") || null;
let scoped = d2 ? visible.filter((t) => t.el.closest?.("details") === d2) : visible;
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 || activeH2.marker;
const endNode = nextH2 ? (nextH2.anchor || nextH2.h2 || nextH2.marker) : null;
scoped = scoped.filter((t) => isAfter(startNode, t.el) && isBefore(endNode, t.el));
let cand = null;
for (const t of scoped) {
const box = getH3DisplayEl(t);
if (hasCrossedBottom(box, lineY)) cand = t;
else break;
}
return cand;
}
function maybeOpenActiveSection(activeH2, lineY) {
if (!activeH2 || !activeH2.id) return;
const pre = hasCrossedTop(activeH2.marker, lineY + 140);
if (!pre) return;
if (activeH2.id !== lastOpenedH2) {
lastOpenedH2 = activeH2.id;
openDetailsIfNeeded(activeH2.anchor || activeH2.h2 || activeH2.marker);
}
}
function renderPass(lineY) {
// Pour le glossaire, on évite le doublon du H1 dans la barre flottante.
const showH1 =
!isGlossaryEdition &&
!!(h1 && h1.getBoundingClientRect().bottom <= (lineY - HYST));
if (rfH1) {
rfH1.hidden = !showH1;
if (showH1) rfH1.textContent = (h1.textContent || "").trim();
}
const nextH2 = pickH2(lineY);
maybeOpenActiveSection(nextH2, lineY);
const nextH3 = pickH3(lineY, nextH2);
curH2 = nextH2;
curH3 = nextH3;
if (rfH2) {
const show = Boolean(curH2 && hasCrossedBottom(getH2DisplayEl(curH2), lineY));
rfH2.hidden = !show;
if (show) rfH2.textContent = curH2.title;
}
if (rfH3) {
const show = Boolean(curH3 && hasCrossedBottom(getH3DisplayEl(curH3), lineY));
rfH3.hidden = !show;
if (show) rfH3.textContent = curH3.title;
}
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");
try {
syncTocLocal(
(curH3 && rfH3 && !rfH3.hidden) ? curH3.id :
(curH2 && rfH2 && !rfH2.hidden) ? curH2.id :
""
);
} catch {}
return any;
}
function updateFollow() {
followEl.style.display = "";
syncFollowTop();
const lineY0 = computeLineY(0);
renderPass(lineY0);
const inner = followEl.querySelector(".reading-follow__inner");
const followH0 =
followEl.classList.contains("is-on") && inner
? inner.getBoundingClientRect().height
: 0;
const lineY1 = computeLineY(followH0);
renderPass(lineY1);
const inner2 = followEl.querySelector(".reading-follow__inner");
const followH =
followEl.classList.contains("is-on") && inner2
? inner2.getBoundingClientRect().height
: 0;
const localH =
(isGlossaryEntryMode || isGlossaryPortalMode)
? getLocalStickyHeight()
: 0;
setRootVar("--followbar-h", px(followH));
setRootVar("--sticky-offset-px", String(Math.round(headerH() + PAGE_GAP + localH + followH)));
syncGlossaryFollowState(followEl.classList.contains("is-on") && followH > 0);
if (btnTopChapter) {
const showTopChapter =
isGlossaryEdition
? Boolean(followEl.classList.contains("is-on"))
: Boolean(rfH1 && !rfH1.hidden);
btnTopChapter.hidden = !showTopChapter;
}
if (btnTopSection) {
btnTopSection.hidden = !(curH2 && rfH2 && !rfH2.hidden);
}
lastY = window.scrollY || 0;
syncReadingRect();
scheduleActive();
}
window.__archiUpdateFollow = updateFollow;
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
updateFollow();
});
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
if (rfH1) {
rfH1.addEventListener("click", () => {
if (h1) scrollToElWithOffset(h1, 12, "smooth");
});
}
if (rfH2) {
rfH2.addEventListener("click", () => {
if (!curH2) return;
const target = getH2ScrollTarget(curH2);
if (!target) return;
openDetailsIfNeeded(target);
scrollToElWithOffset(target, 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}`);
});
}
if (btnTopChapter) {
btnTopChapter.addEventListener("click", () => {
scrollToTopChapter("smooth");
});
}
if (btnTopSection) {
btnTopSection.addEventListener("click", () => {
if (!curH2) return;
const target = getH2ScrollTarget(curH2);
if (!target) return;
openDetailsIfNeeded(target);
scrollToElWithOffset(target, 12, "smooth");
history.replaceState(null, "", `${window.location.pathname}#${curH2.id}`);
});
}
updateFollow();
const initialHash = (location.hash || "").slice(1);
if (initialHash) {
const el = document.getElementById(initialHash);
if (el) {
openDetailsIfNeeded(el);
setTimeout(() => {
scrollToElWithOffset(el, 12, "auto");
highlightFromTarget(el);
scheduleActive();
}, 0);
}
}
});
// ✅ Reflow sync : resize + changement de niveau (restaure le même paragraphe)
function afterReflow(fn) {
requestAnimationFrame(() => requestAnimationFrame(fn));
}
safe("reflow-sync", () => {
window.addEventListener("resize", () => {
afterReflow(() => {
syncHeaderH(); syncReadingRect(); syncHeadingMetrics();
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
scheduleActive();
});
});
window.addEventListener("archicratie:readingLevel", () => {
afterReflow(() => {
syncHeaderH(); syncReadingRect(); syncHeadingMetrics();
syncFollowTop();
try { window.__archiUpdateFollow?.(); } catch {}
const ctx = window.__archiLevelSwitchCtx || null;
const want =
(ctx && ctx.paraId) ||
window.__archiCurrentParaId ||
__activeParaId ||
window.__archiLastParaId ||
String(location.hash || "").replace(/^#/, "");
if (want) {
const el = document.getElementById(want);
if (el) {
openDetailsIfNeeded(el);
scrollToElWithOffset(el, 12, "auto");
highlightFromTarget(el);
history.replaceState(null, "", `${location.pathname}#${want}`);
__activeParaId = want;
setCurrentPara(want, "level");
} else if (ctx && Number.isFinite(ctx.scrollY)) {
window.scrollTo({ top: ctx.scrollY, behavior: "auto" });
}
}
scheduleActive();
});
});
});
// ===== Auto-unfold <details> (robuste) =====
safe("details-autounfold", () => {
if (!reading) return;
const sections = Array.from(reading.querySelectorAll("details.details-section"));
if (!sections.length) return;
// ✅ Lecture continue : tout ouvert, plus daccordéon
for (const d of sections) d.open = true;
try { window.__archiUpdateFollow?.(); } catch {}
scheduleActive();
});
})();
</script>
</body>
</html>