loading and splitted titles render speed fix

This commit is contained in:
2026-10-13 17:00:22 +02:00
parent 2d3842c702
commit 36c5b1e380
9 changed files with 208 additions and 81 deletions
+137 -70
View File
@@ -2,20 +2,12 @@
import Splitting from "splitting";
import { hyphenateSync } from "hyphen/fr";
// La classe `variable-title` (posée dans initTitles) masque l'élément via le CSS
// (.variable-title:not(.is-ready)) jusqu'à ce qu'il soit splitté et marqué
// `is-ready`. Le CSS ne masque rien que ce fichier ne traite pas : cette liste
// peut évoluer librement, aucun contenu ne peut rester invisible par désync.
const TITLE_SELECTORS = '\
#block-leshed-identitedusite>a, \
article.node-type-projet>header>h2,\
article.node-type-projet>.cols>.left>h2,\
article.node-type-projet.view-mode-full h2.node-title,\
article.node-type-evenement>header>h2,\
article.node-type-evenement.view-mode-full h2.node-title,\
article.node-type-evenement.view-mode-calendrier h2.node-title\
';
// div.views-archives-evenements > h3 > time\
// Titres traités : la classe `variable-title` est posée côté serveur
// (leshed_preprocess_node, block--system-branding-block.html.twig), donc
// présente dès le premier rendu. Le CSS les masque (.variable-title:not(.is-ready))
// jusqu'au split : la typo brute n'apparaît jamais. Même classe ici et dans le
// CSS : aucune désynchro possible entre ce qui est masqué et ce qui est traité.
const TITLE_SELECTOR = '.variable-title';
// Police Epilogue = axe variable `wght` (100–900) uniquement ; l'italique est
// un fichier séparé (font-style).
@@ -30,91 +22,166 @@ const TITLE_STYLE = {
italicOnBold: 0.6, // proba d'italique sur un pic
italicOnMedium: 0.4, // proba d'italique sur un medium
italicOnThin: 0.30, // proba d'italique sur une fine
// Pas des graisses tirées. Chaque valeur distincte de 'wght' coûte une
// instance de la police variable (mesuré : ~300 ms de blocage au chargement
// avec des entiers 100–900 au hasard, ~30 ms avec quelques paliers). Au pas
// de 25 : 18 graisses distinctes (100–400 et 800–900), invisible à l’œil.
wghtStep: 25,
};
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
// Marge (en hauteurs de viewport) sous l'écran dans laquelle un titre est
// traité tout de suite ; au-delà, il l'est par lots pendant les temps morts.
const EAGER_VIEWPORTS = 1.5;
// Budget d'un lot différé (ms) : on rend la main avant de dépasser.
const IDLE_BUDGET = 8;
/**
* Traite d'abord les titres à l'écran (ou juste dessous) pour qu'ils soient
* peints au plus tôt, puis le reste par lots pendant les temps morts.
*
* Sur une liste longue (/projets : 115 titres, ~1900 lettres), tout traiter
* d'un bloc bloquait le thread ~300 ms avant la première image. Les titres
* hors écran restent masqués (.variable-title:not(.is-ready)) le temps de
* leur lot — personne ne les voit — et le garde-fou CSS de 3 s couvre le
* pire cas (requestIdleCallback a un timeout de 1 s).
*
* Fin du traitement : événement `leshed:titles-ready` sur document (main.js
* s'en sert pour recaler ScrollTrigger, les hauteurs ayant pu changer).
*/
function initTitles() {
const titles = document.querySelectorAll(TITLE_SELECTORS);
const titles = Array.from(document.querySelectorAll(TITLE_SELECTOR));
const limit = window.innerHeight * EAGER_VIEWPORTS;
// Lectures seules : une seule mise en page pour tout le tri.
const eager = [];
const deferred = [];
for (const txt of titles) {
txt.classList.add("variable-title");
const splitted = Splitting({ target: txt, by: 'chars' });
for (const word of splitted[0].words) {
const chars = Array.from(word.getElementsByClassName('char'));
styleWordChars(chars);
hyphenateWord(chars);
}
txt.classList.add("is-ready");
measureOverflow(txt);
const r = txt.getBoundingClientRect();
(r.bottom >= 0 && r.top <= limit ? eager : deferred).push(txt);
}
eager.forEach(processTitle);
measureOverflow(eager);
processDeferred(deferred, () => {
document.dispatchEvent(new CustomEvent('leshed:titles-ready'));
});
// La police Epilogue (variable, font-display: block) arrive APRÈS le
// premier rendu. Au rechargement, les images sortent du cache instantanément
// et la mesure ci-dessus se fait avec les métriques de la police de
// substitution : elle conclut « pas de débordement » et plus rien ne la
// relance quand la vraie police charge (symptôme : le titre chevauche le
// texte sous l'image). On re-mesure tout une fois les polices prêtes —
// measureOverflow est idempotent, il écrase juste l'attribut.
// texte sous l'image). On re-mesure les titres déjà traités une fois les
// polices prêtes ; ceux des lots suivants sont mesurés à leur traitement.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
for (const txt of titles) measureOverflow(txt);
measureOverflow(titles.filter((t) => t.classList.contains('is-ready')));
});
}
}
/**
* Mesure le débordement du titre sous l'image et pose data-overflow-lines.
*
* Si le titre déborde de N lignes sous l'image, l'attribut data-overflow-lines
* permet au CSS d'augmenter max-height de N lignes pour absorber le débordement.
*/
function measureOverflow(h2) {
const article = h2.closest('article.has-image');
if (!article) return;
/** Split + graisses + césures d'un titre, puis le rend visible. */
function processTitle(txt) {
const splitted = Splitting({ target: txt, by: 'chars' });
for (const word of splitted[0].words) {
const chars = Array.from(word.getElementsByClassName('char'));
styleWordChars(chars);
hyphenateWord(chars);
}
txt.classList.add('is-ready');
}
const img = article.querySelector('header .field_images img');
if (!img) return;
const measure = () => {
const lineHeight = parseFloat(getComputedStyle(h2).lineHeight);
if (!lineHeight) return;
// Hauteur réelle de l'image rendue (width:100%, height:auto).
let imgHeight;
if (img.naturalWidth && img.complete) {
imgHeight = img.clientWidth * (img.naturalHeight / img.naturalWidth);
} else {
imgHeight = img.clientHeight;
/** Traite `queue` par lots de IDLE_BUDGET ms, puis appelle `done`. */
function processDeferred(queue, done) {
if (!queue.length) {
done();
return;
}
const schedule = window.requestIdleCallback
? (fn) => window.requestIdleCallback(fn, { timeout: 1000 })
: (fn) => setTimeout(fn, 16);
const step = () => {
const end = performance.now() + IDLE_BUDGET;
const batch = [];
while (queue.length && performance.now() < end) {
const txt = queue.shift();
processTitle(txt);
batch.push(txt);
}
if (!imgHeight) return;
measureOverflow(batch);
queue.length ? schedule(step) : done();
};
schedule(step);
}
// scrollHeight = hauteur totale du titre sans le max-height.
h2.style.maxHeight = 'none';
const titleHeight = h2.scrollHeight;
/**
* Mesure le débordement des titres sous leur image et pose data-overflow-lines.
*
* Si un titre déborde de N lignes sous l'image, l'attribut data-overflow-lines
* permet au CSS d'augmenter max-height de N lignes pour absorber le débordement.
*
* Mesures GROUPÉES : toutes les écritures, puis toutes les lectures, puis les
* écritures finales — une seule mise en page pour le lot au lieu d'une par
* titre (mesuré : ~65 ms de reflows forcés sur /projets en mode intercalé).
*/
const awaitingLoad = new WeakSet();
function measureOverflow(h2s) {
const items = [];
for (const h2 of h2s) {
const article = h2.closest('article.has-image');
const img = article && article.querySelector('header .field_images img');
if (img) items.push({ h2, img });
}
if (!items.length) return;
// 1) Écritures : hauteur totale du titre sans le max-height.
for (const { h2 } of items) h2.style.maxHeight = 'none';
// 2) Lectures (une seule mise en page).
for (const item of items) {
const { h2, img } = item;
item.lineHeight = parseFloat(getComputedStyle(h2).lineHeight);
// Hauteur réelle de l'image rendue (width:100%, height:auto).
item.imgHeight = img.naturalWidth && img.complete
? img.clientWidth * (img.naturalHeight / img.naturalWidth)
: img.clientHeight;
item.titleHeight = h2.scrollHeight;
}
// 3) Écritures finales.
for (const { h2, img, lineHeight, imgHeight, titleHeight } of items) {
h2.style.maxHeight = '';
// Lignes du titre qui dépassent sous l'image.
// 1 ligne est déjà dans le flux (max-height: 1em par défaut).
const overflowPx = titleHeight - imgHeight;
if (overflowPx <= 0) return;
const overflowLines = Math.ceil(overflowPx / lineHeight);
if (overflowLines > 0) {
h2.setAttribute('data-overflow-lines', overflowLines);
if (lineHeight && imgHeight) {
const overflowPx = titleHeight - imgHeight;
if (overflowPx > 0) {
h2.setAttribute('data-overflow-lines', Math.ceil(overflowPx / lineHeight));
}
}
// Hors écran, une image lazy ne déclenche jamais 'load', mais sa boîte de
// layout existe dès le départ (attributs width/height de Drupal →
// aspect-ratio) : la mesure ci-dessus vaut. Au 'load' réel, on re-mesure.
if ((!img.complete || !img.naturalWidth) && !awaitingLoad.has(img)) {
awaitingLoad.add(img);
img.addEventListener('load', () => measureOverflow([h2]), { once: true });
}
};
// Toujours mesurer immédiatement : hors écran, une image lazy ne déclenche
// jamais 'load', mais sa boîte de layout existe dès le départ (attributs
// width/height de Drupal → aspect-ratio). Les re-mesures ultérieures
// (load réel ci-dessous, fonts.ready dans initTitles) écrasent l'attribut.
measure();
if (!img.complete || !img.naturalWidth) {
img.addEventListener('load', measure, { once: true });
}
}
/**
* Graisse au hasard dans [min, max], sur les paliers de TITLE_STYLE.wghtStep
* (bornes incluses, tirage uniforme entre paliers).
*/
function randomWght({ min, max }) {
const step = TITLE_STYLE.wghtStep;
return min + step * randomInt(0, Math.floor((max - min) / step) + 1);
}
/**
* Tire k positions réparties régulièrement dans [0, n[ : le mot est découpé
* en k cases contiguës, une position au hasard par case (aléa contrôlé, pas
@@ -169,7 +236,7 @@ function styleWordChars(chars) {
}
return {
char,
wght: randomInt(range.min, range.max + 1),
wght: randomWght(range),
ital: Math.random() < pItal,
};
});