147 lines
5.7 KiB
JavaScript
147 lines
5.7 KiB
JavaScript
/*
|
|
* pjax.js — navigation sans rechargement de page complet.
|
|
*
|
|
* Principe : le serveur ne change pas (mêmes routes Flask, mêmes pages
|
|
* générées en Jinja) — on intercepte simplement chaque clic sur un lien
|
|
* interne et chaque soumission de formulaire interne, on va chercher la
|
|
* page suivante nous-mêmes en arrière-plan (fetch), et on ne remplace que
|
|
* <title>, la classe du <body>, l'en-tête (fil d'Ariane) et le contenu
|
|
* principal (<main>) — au lieu de laisser le navigateur tout recharger.
|
|
* L'URL affichée et l'historique (bouton "précédent") restent corrects
|
|
* grâce à history.pushState/popstate.
|
|
*
|
|
* Les scripts propres à chaque page (dans le bloc "content", donc réinjectés
|
|
* à chaque changement de page) ne s'exécutent pas automatiquement quand on
|
|
* les insère via innerHTML — on les rejoue explicitement à chaque
|
|
* navigation (voir runScriptsIn ci-dessous). C'est pour ça que ces scripts
|
|
* ne doivent déclarer aucune variable globale avec `let`/`const` au premier
|
|
* niveau (une seconde exécution lèverait "already declared") — seuls `var`
|
|
* et les déclarations de fonction sont sûrs à rejouer, ce qui est déjà le
|
|
* cas de tous les scripts actuels du moteur.
|
|
*
|
|
* Pour désactiver ce comportement sur un lien ou un formulaire précis (cas
|
|
* rare), ajouter l'attribut data-no-pjax.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
function isInternalUrl(url) {
|
|
return url.origin === location.origin;
|
|
}
|
|
|
|
function runScriptsIn(container) {
|
|
var scripts = container.querySelectorAll("script");
|
|
scripts.forEach(function (old) {
|
|
var fresh = document.createElement("script");
|
|
for (var i = 0; i < old.attributes.length; i++) {
|
|
var attr = old.attributes[i];
|
|
fresh.setAttribute(attr.name, attr.value);
|
|
}
|
|
fresh.textContent = old.textContent;
|
|
old.replaceWith(fresh);
|
|
});
|
|
}
|
|
|
|
function mergeHeadAssets(newHead) {
|
|
// N'ajoute que ce qui manque encore (ex. la police Google Fonts propre
|
|
// à l'éditeur d'écran) — ne retire jamais rien, un lien en trop est sans
|
|
// conséquence alors que le retirer pourrait faire clignoter le rendu.
|
|
var current = document.head;
|
|
var currentHrefs = Array.prototype.map.call(
|
|
current.querySelectorAll("link[href]"),
|
|
function (l) { return l.getAttribute("href"); }
|
|
);
|
|
newHead.querySelectorAll("link[rel='stylesheet']").forEach(function (link) {
|
|
var href = link.getAttribute("href");
|
|
if (href && currentHrefs.indexOf(href) === -1) {
|
|
current.appendChild(link.cloneNode(true));
|
|
}
|
|
});
|
|
}
|
|
|
|
function swapDocument(html, url, push) {
|
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
|
|
var newMain = doc.querySelector("main.content");
|
|
var currentMain = document.querySelector("main.content");
|
|
if (!newMain || !currentMain) {
|
|
// Page inattendue (pas une page du moteur, ex. redirection externe) :
|
|
// par sécurité, on fait une vraie navigation plutôt que d'afficher un
|
|
// contenu à moitié cohérent.
|
|
location.href = url;
|
|
return;
|
|
}
|
|
|
|
document.title = doc.title;
|
|
document.body.className = doc.body.className;
|
|
currentMain.className = newMain.className;
|
|
currentMain.innerHTML = newMain.innerHTML;
|
|
|
|
var newHeader = doc.querySelector("header.topbar");
|
|
var currentHeader = document.querySelector("header.topbar");
|
|
if (newHeader && currentHeader) {
|
|
currentHeader.innerHTML = newHeader.innerHTML;
|
|
}
|
|
|
|
mergeHeadAssets(doc.head);
|
|
runScriptsIn(currentMain);
|
|
|
|
if (push) {
|
|
history.pushState({ pjax: true }, "", url);
|
|
}
|
|
window.scrollTo(0, 0);
|
|
document.dispatchEvent(new CustomEvent("pjax:navigated", { detail: { url: url } }));
|
|
}
|
|
|
|
function navigate(url, push, fetchOpts) {
|
|
fetch(url, Object.assign({ headers: { "X-Requested-With": "pjax" } }, fetchOpts || {}))
|
|
.then(function (resp) {
|
|
return resp.text().then(function (html) {
|
|
return { html: html, finalUrl: resp.url || url };
|
|
});
|
|
})
|
|
.then(function (result) {
|
|
swapDocument(result.html, result.finalUrl, push);
|
|
})
|
|
.catch(function () {
|
|
// Échec réseau ou réponse inexploitable : on retombe sur une vraie
|
|
// navigation plutôt que de laisser l'utilisateur bloqué.
|
|
location.href = url;
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", function (e) {
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
var a = e.target.closest("a");
|
|
if (!a || !a.href) return;
|
|
if (a.target === "_blank" || a.hasAttribute("download") || a.hasAttribute("data-no-pjax")) return;
|
|
if (a.getAttribute("href") && a.getAttribute("href").charAt(0) === "#") return;
|
|
var url = new URL(a.href, location.href);
|
|
if (!isInternalUrl(url)) return;
|
|
if (url.href === location.href.split("#")[0]) return; // lien vers soi-même : rien à refaire
|
|
e.preventDefault();
|
|
navigate(url.href, true);
|
|
});
|
|
|
|
document.addEventListener("submit", function (e) {
|
|
if (e.defaultPrevented) return; // ex: confirm() annulé par l'utilisateur
|
|
var form = e.target;
|
|
if (!(form instanceof HTMLFormElement)) return;
|
|
if (form.target === "_blank" || form.hasAttribute("data-no-pjax")) return;
|
|
var url = new URL(form.getAttribute("action") || location.href, location.href);
|
|
if (!isInternalUrl(url)) return;
|
|
var method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
e.preventDefault();
|
|
if (method === "GET") {
|
|
url.search = new URLSearchParams(new FormData(form)).toString();
|
|
navigate(url.href, true);
|
|
} else {
|
|
navigate(url.href, true, { method: "POST", body: new FormData(form) });
|
|
}
|
|
});
|
|
|
|
window.addEventListener("popstate", function () {
|
|
navigate(location.href, false);
|
|
});
|
|
})();
|