1635 lines
51 KiB
Plaintext
1635 lines
51 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 SidePanel from "../components/SidePanel.astro";
|
||
import "../styles/global.css";
|
||
import "../styles/panel.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;
|
||
|
||
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 l’auth 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)}
|
||
>
|
||
<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>
|
||
|
||
<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>
|
||
.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 l’UX 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;
|
||
}
|
||
: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;
|
||
}
|
||
</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 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 || "";
|
||
|
||
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(() => {});
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
// ===== 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", () => {
|
||
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 (!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", () => {
|
||
scheduleActive();
|
||
window.addEventListener("scroll", scheduleActive, { passive: true });
|
||
});
|
||
|
||
safe("hover-para", () => {
|
||
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 (!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;
|
||
|
||
const h1 = reading.querySelector("h1");
|
||
|
||
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]"))
|
||
.map((h2) => ({ id: h2.id, anchor: h2, marker: h2, title: (h2.textContent || "").trim() || "Section", h2 }));
|
||
|
||
const H2 = (h2Anchors.length ? 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) {
|
||
return headerH() + PAGE_GAP + (followH || 0) + HYST;
|
||
}
|
||
|
||
function pickH2(lineY) {
|
||
if (!H2.length) return null;
|
||
let cand = null;
|
||
for (const t of H2) {
|
||
const top = t.marker.getBoundingClientRect().top;
|
||
if (top <= lineY) cand = t;
|
||
else break;
|
||
}
|
||
if (!cand) 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;
|
||
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 top = t.el.getBoundingClientRect().top;
|
||
if (top <= lineY) cand = t;
|
||
else break;
|
||
}
|
||
return cand;
|
||
}
|
||
|
||
function maybeOpenActiveSection(activeH2, lineY) {
|
||
if (!activeH2 || !activeH2.id) return;
|
||
const pre = activeH2.marker.getBoundingClientRect().top <= (lineY + 140);
|
||
if (!pre) return;
|
||
if (activeH2.id !== lastOpenedH2) {
|
||
lastOpenedH2 = activeH2.id;
|
||
openDetailsIfNeeded(activeH2.anchor || activeH2.h2 || activeH2.marker);
|
||
}
|
||
}
|
||
|
||
function updateFollow() {
|
||
if (!followEl) return;
|
||
|
||
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;
|
||
|
||
const lineY0 = computeLineY(0);
|
||
curH2 = pickH2(lineY0);
|
||
maybeOpenActiveSection(curH2, lineY0);
|
||
curH3 = pickH3(lineY0, curH2);
|
||
|
||
try { syncTocLocal((curH3 && curH3.id) ? curH3.id : (curH2 && curH2.id) ? curH2.id : ""); } catch {}
|
||
|
||
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;
|
||
|
||
const lineY = computeLineY(followH);
|
||
curH2 = pickH2(lineY);
|
||
maybeOpenActiveSection(curH2, lineY);
|
||
curH3 = pickH3(lineY, curH2);
|
||
|
||
try { syncTocLocal((curH3 && curH3.id) ? curH3.id : (curH2 && curH2.id) ? curH2.id : ""); } catch {}
|
||
|
||
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;
|
||
|
||
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)));
|
||
|
||
if (btnTopChapter) btnTopChapter.hidden = !any;
|
||
if (btnTopSection) {
|
||
const ok = Boolean(curH2 && rfH2 && !rfH2.hidden);
|
||
btnTopSection.hidden = !ok;
|
||
}
|
||
|
||
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;
|
||
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
|
||
scrollToElWithOffset(curH2.marker, 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", () => { if (h1) scrollToElWithOffset(h1, 12, "smooth"); });
|
||
if (btnTopSection) btnTopSection.addEventListener("click", () => {
|
||
if (!curH2) return;
|
||
openDetailsIfNeeded(curH2.anchor || curH2.h2 || curH2.marker);
|
||
scrollToElWithOffset(curH2.marker, 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();
|
||
try { window.__archiUpdateFollow?.(); } catch {}
|
||
scheduleActive();
|
||
});
|
||
});
|
||
|
||
window.addEventListener("archicratie:readingLevel", () => {
|
||
afterReflow(() => {
|
||
syncHeaderH(); syncReadingRect(); syncHeadingMetrics();
|
||
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 d’accordéon
|
||
for (const d of sections) d.open = true;
|
||
|
||
try { window.__archiUpdateFollow?.(); } catch {}
|
||
scheduleActive();
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |