// Chargé en defer dans le : 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();