224 lines
7.3 KiB
JavaScript
224 lines
7.3 KiB
JavaScript
// 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";
|
||
|
||
// À garder identique à $variable-title-selectors (main.scss), qui masque ces
|
||
// titres jusqu'à `is-ready`.
|
||
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>header>.field_dates>time,\
|
||
article.node-type-evenement.view-mode-full h2.node-title,\
|
||
article.node-type-evenement.view-mode-full .field_dates.digit-dates span.date-part\
|
||
';
|
||
|
||
// 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
|
||
};
|
||
|
||
function randomInt(min, max) {
|
||
return Math.floor(Math.random() * (max - min)) + min;
|
||
}
|
||
|
||
function initTitles() {
|
||
const titles = document.querySelectorAll(TITLE_SELECTORS);
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
|
||
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;
|
||
}
|
||
if (!imgHeight) return;
|
||
|
||
// scrollHeight = hauteur totale du titre sans le max-height.
|
||
h2.style.maxHeight = 'none';
|
||
const titleHeight = h2.scrollHeight;
|
||
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 (img.complete && img.naturalWidth) {
|
||
measure();
|
||
} else {
|
||
img.addEventListener('load', measure, { once: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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: randomInt(range.min, range.max + 1),
|
||
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();
|