Files
drupal-leshed/web/themes/custom/leshed/assets/js/titles.js
T

308 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Chargé en defer dans le <head> : s'exécute avant main.js (defer, fin de body).
import Splitting from "splitting";
import { hyphenateSync } from "hyphen/fr";
// 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).
const TITLE_STYLE = {
// 3 groupes de graisse : thin = majorité (défaut), medium = quelques,
// bold = les pics. Les fines ne sont pas comptées (c'est le reste).
wghtThin: { min: 100, max: 200 }, // majorité (défaut)
wghtMedium: { min: 200, max: 400 }, // quelques
wghtBold: { min: 800, max: 900 }, // pics
boldRatio: 0.28, // proportion de pics gras par mot
mediumRatio: 0.12, // proportion de medium par mot
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 = 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) {
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 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(() => {
measureOverflow(titles.filter((t) => t.classList.contains('is-ready')));
});
}
}
/** 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');
}
/** 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);
}
measureOverflow(batch);
queue.length ? schedule(step) : done();
};
schedule(step);
}
/**
* 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).
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 });
}
}
}
/**
* 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
* de paquets). Les positions déjà prises par un autre groupe sont évitées.
*/
function spreadPositions(n, k, exclude = new Set()) {
const set = new Set();
for (let i = 0; i < k; i++) {
const start = Math.floor((i * n) / k);
const end = Math.floor(((i + 1) * n) / k); // borne exclue
const span = Math.max(1, end - start);
let pos = randomInt(start, start + span);
let guard = 0;
while ((exclude.has(pos) || set.has(pos)) && guard < span) {
pos = start + ((pos - start + 1) % span);
guard++;
}
set.add(pos);
}
return set;
}
/**
* Applique graisse + italique caractère par caractère.
*
* Majorité de fines ; quelques medium et quelques pics gras, chacun réparti
* régulièrement dans le mot. L'italique est corrélé à la graisse.
*/
function styleWordChars(chars) {
const n = chars.length;
if (!n) return;
// Accents répartis régulièrement : d'abord les pics gras, puis les medium
// (en évitant les positions déjà grasses). Le reste sera fin.
const bold = spreadPositions(n, Math.round(n * TITLE_STYLE.boldRatio));
const medium = spreadPositions(n, Math.round(n * TITLE_STYLE.mediumRatio), bold);
// 1) Décide graisse + italique pour chaque caractère.
const decisions = chars.map((char, i) => {
let range, pItal;
if (bold.has(i)) {
range = TITLE_STYLE.wghtBold;
pItal = TITLE_STYLE.italicOnBold;
}
else if (medium.has(i)) {
range = TITLE_STYLE.wghtMedium;
pItal = TITLE_STYLE.italicOnMedium;
}
else {
range = TITLE_STYLE.wghtThin;
pItal = TITLE_STYLE.italicOnThin;
}
return {
char,
wght: randomWght(range),
ital: Math.random() < pItal,
};
});
// 2) Au moins une italique par mot : si aucune, on en force une, de
// préférence sur un pic gras ou un medium, sinon sur une lettre au hasard.
if (!decisions.some((d) => d.ital)) {
const accents = [...bold, ...medium];
const idx = accents.length ? accents[randomInt(0, accents.length)] : randomInt(0, n);
decisions[idx].ital = true;
}
// 3) Pas plus de 2 italiques consécutives : on casse les séries de 3+.
let run = 0;
for (const d of decisions) {
if (d.ital) {
run += 1;
if (run > 2) {
d.ital = false;
run = 0;
}
}
else {
run = 0;
}
}
// 4) Applique.
for (const { char, wght, ital } of decisions) {
char.style.fontVariationSettings = `'wght' ${wght}`;
char.style.fontStyle = ital ? 'italic' : 'normal';
char.classList.toggle('italic', ital);
char.classList.toggle('normal', !ital);
}
}
/**
* Insère des césures logicielles (U+00AD) aux points de coupure valides.
*
* Comme chaque lettre est un span (display:inline) sans « run » de texte
* continu, `hyphens: auto` est inopérant : on calcule donc les points de
* césure (motifs FR) et on insère un soft hyphen — en nœud texte, entre les
* lettres concernées — qui n'autorise une coupure (avec tiret) qu'à ces
* endroits. Les mots trop longs coupent proprement au lieu de casser
* n'importe où.
*/
function hyphenateWord(chars) {
if (chars.length < 5) return;
const SOFT = '\u00AD';
const text = chars.map((c) => c.textContent).join('');
const hyphenated = hyphenateSync(text);
if (!hyphenated.includes(SOFT)) return;
let letter = 0;
for (const ch of hyphenated) {
if (ch === SOFT) {
const target = chars[letter];
if (letter > 0 && target && target.parentNode) {
target.parentNode.insertBefore(document.createTextNode(SOFT), target);
}
}
else {
letter++;
}
}
}
initTitles();