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(); initFsLightbox();
initPageFull(); initPageFull();
// initVues() // 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() init()
} // end LeShedTheme() } // 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) })(Drupal, drupalSettings)
+137 -70
View File
@@ -2,20 +2,12 @@
import Splitting from "splitting"; import Splitting from "splitting";
import { hyphenateSync } from "hyphen/fr"; import { hyphenateSync } from "hyphen/fr";
// La classe `variable-title` (posée dans initTitles) masque l'élément via le CSS // Titres traités : la classe `variable-title` est posée côté serveur
// (.variable-title:not(.is-ready)) jusqu'à ce qu'il soit splitté et marqué // (leshed_preprocess_node, block--system-branding-block.html.twig), donc
// `is-ready`. Le CSS ne masque rien que ce fichier ne traite pas : cette liste // présente dès le premier rendu. Le CSS les masque (.variable-title:not(.is-ready))
// peut évoluer librement, aucun contenu ne peut rester invisible par désync. // jusqu'au split : la typo brute n'apparaît jamais. Même classe ici et dans le
const TITLE_SELECTORS = '\ // CSS : aucune désynchro possible entre ce qui est masqué et ce qui est traité.
#block-leshed-identitedusite>a, \ const TITLE_SELECTOR = '.variable-title';
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\
// Police Epilogue = axe variable `wght` (100–900) uniquement ; l'italique est // Police Epilogue = axe variable `wght` (100–900) uniquement ; l'italique est
// un fichier séparé (font-style). // un fichier séparé (font-style).
@@ -30,91 +22,166 @@ const TITLE_STYLE = {
italicOnBold: 0.6, // proba d'italique sur un pic italicOnBold: 0.6, // proba d'italique sur un pic
italicOnMedium: 0.4, // proba d'italique sur un medium italicOnMedium: 0.4, // proba d'italique sur un medium
italicOnThin: 0.30, // proba d'italique sur une fine 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) { function randomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min; 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() { 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) { for (const txt of titles) {
txt.classList.add("variable-title"); const r = txt.getBoundingClientRect();
const splitted = Splitting({ target: txt, by: 'chars' }); (r.bottom >= 0 && r.top <= limit ? eager : deferred).push(txt);
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);
} }
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 // La police Epilogue (variable, font-display: block) arrive APRÈS le
// premier rendu. Au rechargement, les images sortent du cache instantanément // 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 // 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 // substitution : elle conclut « pas de débordement » et plus rien ne la
// relance quand la vraie police charge (symptôme : le titre chevauche le // 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 — // texte sous l'image). On re-mesure les titres déjà traités une fois les
// measureOverflow est idempotent, il écrase juste l'attribut. // polices prêtes ; ceux des lots suivants sont mesurés à leur traitement.
if (document.fonts && document.fonts.ready) { if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
for (const txt of titles) measureOverflow(txt); measureOverflow(titles.filter((t) => t.classList.contains('is-ready')));
}); });
} }
} }
/** /** Split + graisses + césures d'un titre, puis le rend visible. */
* Mesure le débordement du titre sous l'image et pose data-overflow-lines. function processTitle(txt) {
* const splitted = Splitting({ target: txt, by: 'chars' });
* Si le titre déborde de N lignes sous l'image, l'attribut data-overflow-lines for (const word of splitted[0].words) {
* permet au CSS d'augmenter max-height de N lignes pour absorber le débordement. const chars = Array.from(word.getElementsByClassName('char'));
*/ styleWordChars(chars);
function measureOverflow(h2) { hyphenateWord(chars);
const article = h2.closest('article.has-image'); }
if (!article) return; txt.classList.add('is-ready');
}
const img = article.querySelector('header .field_images img'); /** Traite `queue` par lots de IDLE_BUDGET ms, puis appelle `done`. */
if (!img) return; function processDeferred(queue, done) {
if (!queue.length) {
const measure = () => { done();
const lineHeight = parseFloat(getComputedStyle(h2).lineHeight); return;
if (!lineHeight) return; }
const schedule = window.requestIdleCallback
// Hauteur réelle de l'image rendue (width:100%, height:auto). ? (fn) => window.requestIdleCallback(fn, { timeout: 1000 })
let imgHeight; : (fn) => setTimeout(fn, 16);
if (img.naturalWidth && img.complete) { const step = () => {
imgHeight = img.clientWidth * (img.naturalHeight / img.naturalWidth); const end = performance.now() + IDLE_BUDGET;
} else { const batch = [];
imgHeight = img.clientHeight; 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'; * Mesure le débordement des titres sous leur image et pose data-overflow-lines.
const titleHeight = h2.scrollHeight; *
* 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 = ''; h2.style.maxHeight = '';
// Lignes du titre qui dépassent sous l'image. // Lignes du titre qui dépassent sous l'image.
// 1 ligne est déjà dans le flux (max-height: 1em par défaut). // 1 ligne est déjà dans le flux (max-height: 1em par défaut).
const overflowPx = titleHeight - imgHeight; if (lineHeight && imgHeight) {
if (overflowPx <= 0) return; const overflowPx = titleHeight - imgHeight;
if (overflowPx > 0) {
const overflowLines = Math.ceil(overflowPx / lineHeight); h2.setAttribute('data-overflow-lines', Math.ceil(overflowPx / lineHeight));
if (overflowLines > 0) { }
h2.setAttribute('data-overflow-lines', overflowLines); }
// 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é * 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 * en k cases contiguës, une position au hasard par case (aléa contrôlé, pas
@@ -169,7 +236,7 @@ function styleWordChars(chars) {
} }
return { return {
char, char,
wght: randomInt(range.min, range.max + 1), wght: randomWght(range),
ital: Math.random() < pItal, ital: Math.random() < pItal,
}; };
}); });
@@ -41,12 +41,13 @@
// } // }
} }
// La classe `variable-title` est posée par assets/js/titles.js au moment où // La classe `variable-title` est posée côté serveur (leshed_preprocess_node,
// il prend en charge un titre. Le CSS ne masque donc QUE des éléments que le // block--system-branding-block.html.twig) : présente dès le premier rendu,
// JS traite : une désynchronisation de listes de sélecteurs ne peut plus // elle permet de masquer le titre AVANT que titles.js ne s'exécute — pas de
// rendre de contenu invisible (c'est ce qui cachait les dates des teasers // typo brute à l'écran. titles.js sélectionne cette même classe : le CSS ne
// pendant 3 s — le garde-fou). Un titre non traité par le JS n'est jamais // masque QUE des éléments que le JS traite (pas de liste de sélecteurs à
// masqué : il reste affiché tel quel. // 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. // Avant le split : même métrique qu'après, pour éviter un saut de mise en page.
.variable-title:not(.splitting){ .variable-title:not(.splitting){
@@ -11,6 +11,16 @@ global:
assets/dist/main.css: {} assets/dist/main.css: {}
# assets/scss/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). # Rendu typographique des titres, au plus tôt (voir assets/js/titles.js).
titles: titles:
header: true 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'][] = 'http://localhost:5173/assets/js/main.js';
$page['#attached']['library'][] = 'leshed/vitehmr'; $page['#attached']['library'][] = 'leshed/vitehmr';
$page['#attached']['library'][] = 'leshed/titles_hmr'; $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{ }else{
$page['#attached']['library'][] = 'leshed/global'; $page['#attached']['library'][] = 'leshed/global';
$page['#attached']['library'][] = 'leshed/titles'; $page['#attached']['library'][] = 'leshed/titles';
@@ -144,6 +150,19 @@ function leshed_preprocess_node(&$variables) {
$variables["attributes"]['class'][] = "view-mode-{$variables['view_mode']}"; $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')) { if ($node->hasField('field_images')) {
/** @var \Drupal\file\Plugin\\Field\FieldType\FileFieldItemList $field_images */ /** @var \Drupal\file\Plugin\\Field\FieldType\FileFieldItemList $field_images */
$field_images = $node->get('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 %}