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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -61,6 +61,11 @@ gsap.registerPlugin(ScrollTrigger);
initFsLightbox();
initPageFull();
// initVues()
// titles.js traite les titres hors écran par lots, après la première
// image : leurs hauteurs peuvent changer après la création des
// ScrollTriggers. Lenis suit seul (ResizeObserver) ; ScrollTrigger non.
document.addEventListener('leshed:titles-ready', () => ScrollTrigger.refresh(), { once: true });
}
@@ -405,5 +410,10 @@ gsap.registerPlugin(ScrollTrigger);
init()
} // end LeShedTheme()
LeShedTheme()
// Démarrage APRÈS la première image : main.js s'exécute juste après
// titles.js (deux defer), et son init (Lenis, GSAP, fslightbox qui injecte
// un <style>) forçait restyle + mise en page avant que les titres prêts ne
// soient peints (~150 ms mesurés sur /projets). rAF → la frame est
// planifiée ; setTimeout → on passe après sa peinture.
requestAnimationFrame(() => setTimeout(LeShedTheme, 0))
})(Drupal, drupalSettings)
+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,
};
});
@@ -41,12 +41,13 @@
// }
}
// La classe `variable-title` est posée par assets/js/titles.js au moment où
// il prend en charge un titre. Le CSS ne masque donc QUE des éléments que le
// JS traite : une désynchronisation de listes de sélecteurs ne peut plus
// rendre de contenu invisible (c'est ce qui cachait les dates des teasers
// pendant 3 s — le garde-fou). Un titre non traité par le JS n'est jamais
// masqué : il reste affiché tel quel.
// La classe `variable-title` est posée côté serveur (leshed_preprocess_node,
// block--system-branding-block.html.twig) : présente dès le premier rendu,
// elle permet de masquer le titre AVANT que titles.js ne s'exécute — pas de
// typo brute à l'écran. titles.js sélectionne cette même classe : le CSS ne
// masque QUE des éléments que le JS traite (pas de liste de sélecteurs à
// tenir synchronisée — c'est ce qui cachait les dates des teasers pendant
// 3 s, le garde-fou).
// Avant le split : même métrique qu'après, pour éviter un saut de mise en page.
.variable-title:not(.splitting){
@@ -11,6 +11,16 @@ global:
assets/dist/main.css: {}
# assets/scss/main.css: {}
# CSS compilée seule. En dev, on l'attache AUX CÔTÉS du JS HMR (voir
# leshed_page_attachments_alter) : servie en <link> render-blocking, elle
# élimine le flash de contenu sans styles pendant que les modules Vite
# chargent (eux injectent ensuite les styles à jour par-dessus — plus tard
# dans la cascade, ils gagnent les égalités).
base_css:
css:
theme:
assets/dist/main.css: {}
# Rendu typographique des titres, au plus tôt (voir assets/js/titles.js).
titles:
header: true
+19
View File
@@ -13,6 +13,12 @@ function leshed_page_attachments_alter(&$page) {
// $page['#attached']['library'][] = 'http://localhost:5173/assets/js/main.js';
$page['#attached']['library'][] = 'leshed/vitehmr';
$page['#attached']['library'][] = 'leshed/titles_hmr';
// CSS compilée en <link> render-blocking : sans elle, la page se peint
// sans styles pendant le chargement des modules Vite (qui portent la CSS
// en dev). Le build --watch tient assets/dist/main.css frais ; les styles
// injectés ensuite par Vite, plus tardifs dans la cascade, prennent le
// dessus sur les égalités.
$page['#attached']['library'][] = 'leshed/base_css';
}else{
$page['#attached']['library'][] = 'leshed/global';
$page['#attached']['library'][] = 'leshed/titles';
@@ -144,6 +150,19 @@ function leshed_preprocess_node(&$variables) {
$variables["attributes"]['class'][] = "view-mode-{$variables['view_mode']}";
// Titres typographiques (assets/js/titles.js) : la classe est posée ICI,
// dans le HTML servi, et non par le JS. Le CSS peut ainsi masquer le titre
// dès le premier rendu (.variable-title:not(.is-ready)) au lieu de laisser
// voir la typo brute le temps que le module s'exécute ; et le JS
// sélectionne cette même classe : une seule liste, aucune désynchro.
$variable_titles = [
'projet' => ['teaser', 'tableau_liste', 'lie', 'full'],
'evenement' => ['teaser', 'tableau_liste', 'calendrier', 'full'],
];
if (in_array($variables['view_mode'], $variable_titles[$node_type] ?? [], TRUE)) {
$variables['title_attributes']['class'][] = 'variable-title';
}
if ($node->hasField('field_images')) {
/** @var \Drupal\file\Plugin\\Field\FieldType\FileFieldItemList $field_images */
$field_images = $node->get('field_images');
@@ -0,0 +1,20 @@
{% extends "block.html.twig" %}
{#
/**
* @file
* Bloc identité du site. Surcharge du gabarit core uniquement pour poser
* `variable-title` sur le nom du site, dans le HTML servi (voir
* leshed_preprocess_node() et assets/js/titles.js).
*/
#}
{% block content %}
{% if site_logo %}
<a href="{{ path('<front>') }}" rel="home">
<img src="{{ site_logo }}" alt="{{ 'Home'|t }}" fetchpriority="high" />
</a>
{% endif %}
{% if site_name %}
<a href="{{ path('<front>') }}" rel="home" class="variable-title">{{ site_name }}</a>
{% endif %}
{{ site_slogan }}
{% endblock %}