scroll sync on projet et evenement page bug fix

This commit is contained in:
2026-09-15 21:02:47 +02:00
parent af7d4192e6
commit 22c0750e14
5 changed files with 284 additions and 77 deletions
+119
View File
@@ -0,0 +1,119 @@
# Rapport : corrections scroll colonnes gauche/droite et scale-down du titre
## Contexte
Sur les pages full des projets et evenements, deux animations GSAP coexistent :
1. **Scale-down du titre** : le header (titre) retrecit de `scale 1.0 -> 0.4` pendant le scroll, avec une translation `y` qui compense pour garder le bord visuel fixe.
2. **Gel de la colonne gauche** : la colonne gauche defile naturellement puis se fige (contre-translation 1:1 du scroll) pendant que la colonne droite (images en `position: sticky`) continue.
Les images de la colonne droite sont en `position: sticky; top: 0` et s'empilent les unes sur les autres.
## Problemes identifies
### 1. node/573 — image remonte en fin de scroll (FIXE)
**Cause** : le `:after` de `.field_images` avait `height: 100vh` statique. Quand la colonne gauche est tres haute (texte long), le contexte sticky s'epuisait avant la fin du scroll, et l'image remontait.
**Fix** :
- CSS : `height: var(--sticky-extent, 100vh)` sur le `:after` (fallback 100vh si pas de JS).
- JS : `adjustStickyContext()` calcule `scrollRange - imgHeight + clientHeight` et pose la valeur en CSS custom property `--sticky-extent` sur `.field_images`. Recalcule au load de chaque image.
**Status** : fonctionne.
### 2. node/576 — ecart entre le titre et le contenu (FIXE puis REGRESSIF)
**Cause** : `initHeaderH` etait mesure une fois, de maniere synchrone, avant les `requestAnimationFrame`. Si la police Epilogue ou Splitting changeait la hauteur du header apres cette mesure, `end` et `y` etaient faux, creant un ecart visuel entre le bas du header et le contenu.
**Fix applique** :
- `y` et `end` transformes en fonctions qui mesurent `pageHeader.offsetHeight` au moment ou GSAP en a besoin.
- `initHeaderH` supprime, remplace par `pageHeader.offsetHeight` partout (y compris dans `leftFreezeStart`).
**Status** : l'ecart est corrige, mais le fix a introduit un nouveau probleme (voir point 4).
### 3. node/549 et 570 — colonne gauche redescend en fin de scroll (REGRESSIF puis REFIXE)
**Cause** : `leftFreezeStart` utilisait `Math.max(overflow, titleEnd)`. Sur 549/570, `titleEnd` (proportionnel a la hauteur du titre a 12em) peut depasser `overflow` (hauteur naturelle de la colonne - viewport), ce qui decale le freeze trop loin et la colonne redescend.
**Fix applique** : retour a la logique d'origine pour `leftFreezeStart` :
```js
const overflow = leftColumn.offsetHeight - scroller.clientHeight;
if (overflow > 0) return overflow;
return pageHeader ? pageHeader.offsetHeight * (1 - headerScaleTarget) : 0;
```
**Status** : fonctionne pour 549/570, mais casse 576 (voir point 4).
### 4. node/576 — colonne se fige trop tot, titre diminue en descendant (REGRESSIF)
**Cause** : `ensureScrollForTitle` agrandit le spacer de la colonne gauche pour garantir assez de scroll pour le scale-down du titre. Le spacer rend `overflow > 0`, donc `leftFreezeStart` retourne `overflow`. Mais `overflow` peut etre inferieur a `titleEnd` (le spacer donne juste assez de scroll pour `end`, pas assez pour que `overflow = end`). La colonne se fige avant la fin du scale-down, le `y` pousse le titre vers le bas.
**Tentative de fix avec `Math.max(overflow, titleEnd)`** : corrige 576 mais casse 549/570 (voir point 3).
**Fix actuel** : `ensureScrollForTitle` est appele **avant** la creation des ScrollTriggers. La formule du spacer est `spacerAdd = end + viewport - colHeight`, ce qui garantit que `overflow = end = titleEnd` apres l'agrandissement. La logique d'origine de `leftFreezeStart` (`if overflow > 0 return overflow`) retourne alors une valeur >= `titleEnd`.
**Status** : a tester. Le succes depend du timing : si le spacer est bien en place avant le calcul de `leftFreezeStart`, tout est coherent.
### 5. node/586 — pas d'image, scroll insuffisant pour le scale-down (NON FIXE)
**Cause** : sans image, la colonne droite est vide. Le contenu de la colonne gauche est court. Le scroll naturel est insuffisant pour que le titre termine son scale-down.
**Fix tente** : `ensureScrollForTitle` agrandit le spacer avec la formule `spacerAdd = end + viewport - colHeight`. Le scroll resultant devrait etre exactement `end`.
**Probleme** : le spacer etait calcule avec l'ancienne formule `end - scrollRange` qui ne tenait pas compte du viewport. La nouvelle formule corrige le calcul, mais le `lenis.resize()` au `window.load` est necessaire pour que Lenis recalcule sa plage de scroll apres le chargement de la police.
**Status** : partiellement corrige. Le scroll est cree mais peut etre insuffisant si Lenis ne refresh pas correctement. Le `lenis.resize()` au `load` a ete ajoute dans `initSmoothScroll`.
### 6. node/545 et 583 — pas d'image, texte court, pas de scroll (OK)
Ces pages n'ont pas d'image et le contenu est court. Il n'y a pas de scroll, le titre ne diminue pas. C'est le comportement attendu et accepte.
## Fichiers modifies
### `assets/js/main.js`
1. **`initSmoothScroll()`** :
- Ajout de `lenis.resize()` + `ScrollTrigger.refresh()` au `window.load`
- `return lenis` pour permettre un acces futur
2. **`initPageFull()`** :
- `cols`, `leftColumn`, `rightColumn`, `spacer`, `spacerBaseHeight` declares en haut de la fonction
- `adjustStickyContext()` defini avant `ensureScrollForTitle`, calcule `--sticky-extent` base sur `scrollRange - imgHeight + clientHeight`
- `ensureScrollForTitle()` : agrandit le spacer si `scrollRange < end`. Formule : `spacerAdd = end + viewport - colHeight`. Appele avant le `gsap.to()` et au `window.load`.
- `y` et `end` du ScrollTrigger du titre : transformes en fonctions `() => pageHeader.offsetHeight * (1 - headerScaleTarget)`
- `initHeaderH` supprime
- `leftFreezeStart` : logique d'origine (`overflow` si > 0, sinon `titleEnd`), mais avec `pageHeader.offsetHeight` au lieu de `initHeaderH`
### `assets/scss/main.scss`
1. **`:after` de `.field_images`** : `height: 100vh` remplace par `height: var(--sticky-extent, 100vh)`
### `assets/js/titles.js`
- Ajout de `measureOverflow()` : mesure le debordement du titre sous l'image et pose `data-overflow-lines` sur le h2. Utilise au scroll pour les pages liste (home, archives, projets lies).
- Appele dans `initTitles()` apres le traitement Splitting de chaque titre.
## Points non resolus
1. **586** : le scroll cree par le spacer peut etre insuffisant si Lenis ne refresh pas correctement. Le `lenis.resize()` au `load` devrait aider mais n'a pas ete confirme.
2. **576** : depend du timing de `ensureScrollForTitle` par rapport aux ScrollTriggers. Si le spacer n'est pas en place avant le calcul de `leftFreezeStart`, la colonne se fige trop tot.
3. **Interaction `ensureScrollForTitle` / `adjustStickyContext`** : les deux se recalculent mutuellement mais peuvent entrer en conflit si les hauteurs changent apres le chargement.
4. **549/570** : la logique d'origine de `leftFreezeStart` fonctionne, mais toute modification qui change `overflow` (comme le spacer) peut casser le comportement.
## Approches envisagees mais abandonnees
1. **`Math.max(overflow, titleEnd)` dans `leftFreezeStart`** : corrige 576/586 mais casse 549/570.
2. **`format: 'iife'` dans Vite** : incompatible avec multiple entry points.
3. **`format: 'es'` dans Vite** : ne change pas le output (deja en format classique).
4. **`type: module` dans `leshed.libraries.yml`** : pour resoudre la redeclaration `const ct` entre `titles.js` et `main.js`.applique mais non teste en prod.
## Tests a faire
- [ ] 549 : colonne gauche ne redescend pas en fin de scroll
- [ ] 570 : colonne gauche ne redescend pas en fin de scroll
- [ ] 576 : titre diminue sans descendre, contenu colle au header
- [ ] 573 : image ne remonte pas en fin de scroll
- [ ] 586 : titre peut terminer son scale-down
- [ ] 545 : pas de scroll, titre ne diminue pas (OK)
- [ ] 583 : pas de scroll, titre ne diminue pas (OK)
- [ ] Pages liste (home, archives) : `data-overflow-lines` fonctionne
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+155 -70
View File
@@ -43,6 +43,10 @@ gsap.registerPlugin(ScrollTrigger);
const gsap_mm = gsap.matchMedia(); const gsap_mm = gsap.matchMedia();
console.log('gsap_mm', gsap_mm); console.log('gsap_mm', gsap_mm);
// Instance Lenis partagée : initPageFull en a besoin pour resynchroniser la
// plage de scroll après avoir gonflé le spacer (voir ensureScrollForTitle).
let lenis = null;
// ___ _ _ // ___ _ _
// |_ _|_ _ (_) |_ // |_ _|_ _ (_) |_
// | || ' \| | _| // | || ' \| | _|
@@ -88,7 +92,7 @@ gsap.registerPlugin(ScrollTrigger);
const content = scroller && scroller.querySelector('.layout-content'); const content = scroller && scroller.querySelector('.layout-content');
if (!scroller || !content) return; if (!scroller || !content) return;
const lenis = new Lenis({ lenis = new Lenis({
wrapper: scroller, // conteneur scrollable custom wrapper: scroller, // conteneur scrollable custom
content: content, // contenu réel (le 1er enfant est l'ancre skip-link) content: content, // contenu réel (le 1er enfant est l'ancre skip-link)
duration: 1.2, duration: 1.2,
@@ -98,6 +102,13 @@ gsap.registerPlugin(ScrollTrigger);
lenis.on('scroll', ScrollTrigger.update); lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000)); gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0); gsap.ticker.lagSmoothing(0);
// Le recalcul post-load (lenis.resize + ScrollTrigger.refresh) est
// centralisé dans initPageFull pour garantir l'ordre : gonflage du spacer
// → lenis.resize → refresh. Le faire ici (avant le gonflage) donnerait à
// Lenis une plage de scroll périmée sur les pages courtes (cf. node/586).
return lenis;
} }
/** /**
@@ -146,9 +157,6 @@ gsap.registerPlugin(ScrollTrigger);
// Nettoyage automatique des tweens/triggers quand on repasse sous 768px. // Nettoyage automatique des tweens/triggers quand on repasse sous 768px.
return () => {}; return () => {};
}); });
// Les images chargent après coup et changent la hauteur des colonnes.
window.addEventListener('load', () => ScrollTrigger.refresh());
} }
function initPageFull(){ function initPageFull(){
@@ -157,86 +165,163 @@ gsap.registerPlugin(ScrollTrigger);
const headerScaleTarget = 0.4; const headerScaleTarget = 0.4;
// PAGE TITLE SCALING DOWN const cols = document.querySelector('article.node-type-projet.view-mode-full .cols, article.node-type-evenement.view-mode-full .cols');
const leftColumn = cols && cols.querySelector('.col.left');
const rightColumn = cols && cols.querySelector('.col.right .field_images');
const spacer = leftColumn && leftColumn.querySelector('.spacer');
// Hauteur CSS de base du spacer (5em), mesurée AVANT tout gonflage JS.
const spacerBaseHeight = spacer ? (parseFloat(getComputedStyle(spacer).height) || 0) : 0;
const pageHeader = document.querySelector('\ const pageHeader = document.querySelector('\
article.node-type-projet.view-mode-full .cols>.col.left>header,\ article.node-type-projet.view-mode-full .cols>.col.left>header,\
article.node-type-evenement.view-mode-full .cols>.col.left>header\ article.node-type-evenement.view-mode-full .cols>.col.left>header\
'); ');
console.log('pageHeader', pageHeader); console.log('pageHeader', pageHeader);
const initHeaderH = pageHeader ? pageHeader.offsetHeight : 0; if (!pageHeader && !(cols && leftColumn)) return;
if (pageHeader) {
// needs requestAnimationFrame to get the real titleHeight after splitting
// console.log(`initHeaderH: ${initHeaderH}`);
const end = initHeaderH - initHeaderH * headerScaleTarget;
// console.log(`end: ${end}`);
// Un seul contexte de timing pour TOUT le comportement de la page full
// (scale-down du titre + gel de la colonne gauche + contexte sticky des
// images). Le double rAF attend la hauteur réelle du titre après Splitting ;
// layoutSync() dimensionne les colonnes AVANT la création des ScrollTriggers.
requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { gsap_mm.add('(min-width: 768px)', () => {
gsap_mm.add('(min-width: 768px)', () => { const viewport = () => scroller.clientHeight;
gsap.to(pageHeader, {
scale: headerScaleTarget, // Amplitude du scale-down du titre, en px de scroll.
y: end, const titleEnd = () => pageHeader ? pageHeader.offsetHeight * (1 - headerScaleTarget) : 0;
// Portion du spacer ajoutée par le JS (hors hauteur CSS de base 5em).
const spacerAdded = () => spacer
? Math.max(0, (parseFloat(getComputedStyle(spacer).height) || 0) - spacerBaseHeight)
: 0;
// Hauteur du contenu RÉEL de la colonne gauche (sans le gonflage JS).
const leftContentHeight = () => leftColumn ? leftColumn.offsetHeight - spacerAdded() : 0;
// Hauteur en flux de la pile d'images (les .field-item sont sticky,
// donc toujours dans le flux) et hauteur de la dernière image.
const imagesFlow = () => {
if (!rightColumn) return 0;
let sum = 0;
rightColumn.querySelectorAll('.field-item').forEach(item => { sum += item.offsetHeight; });
return sum;
};
const lastImageHeight = () => {
if (!rightColumn) return 0;
const last = rightColumn.querySelector('.field-item:last-child');
return last ? last.offsetHeight : 0;
};
// Spacer gauche : garantit SEULEMENT assez de scroll pour dérouler le
// scale-down du titre quand le contenu est trop court (peu/pas d'image
// + texte bref → node/586). Minimal, pour NE PAS sur-scroller (sinon en
// fin de scroll tout est parti en haut → node/572).
const ensureScrollForTitle = () => {
if (!spacer || !pageHeader) return;
const end = titleEnd();
const scrollRange = scroller.scrollHeight - scroller.clientHeight;
if (scrollRange < end) {
const add = Math.max(0, end + viewport() - leftContentHeight());
spacer.style.height = (spacerBaseHeight + add) + 'px';
void scroller.offsetHeight;
}
};
// Contexte sticky de la colonne droite : le `:after` étend .field_images
// sous la dernière image. Hauteur = MAX de deux besoins (aucun ne
// référence scrollRange → pas de boucle de rétroaction) :
// - `leftH - imagesFlow` : couvrir tout le scroll d'une gauche haute
// (texte long) pour que la pile reste figée jusqu'au bout (node/573) ;
// - `viewport - dernièreImage` : le « rab » de scroll pour que la
// DERNIÈRE image atteigne le haut de la pile et y termine le scroll.
// Sans lui, sur une gauche courte + images, le scroll s'arrête à
// `imagesFlow - viewport` alors que la dernière image n'atteint le
// haut qu'à `imagesFlow - dernièreImage` → elle n'y arrive jamais.
const adjustStickyContext = () => {
if (!rightColumn) return;
const extent = Math.max(
0,
leftColumn.offsetHeight - imagesFlow(),
viewport() - lastImageHeight()
);
rightColumn.style.setProperty('--sticky-extent', extent + 'px');
};
// Dimensionne les colonnes (spacer gauche puis :after droit, dans cet
// ordre car l'extent lit leftColumn.offsetHeight), puis resynchronise
// Lenis sur la nouvelle plage de scroll.
const layoutSync = () => {
ensureScrollForTitle();
adjustStickyContext();
void scroller.offsetHeight; // force le reflow
if (lenis) lenis.resize(); // Lenis voit la nouvelle hauteur
};
// Seuil de scroll (px) où la colonne gauche se fige : MAX de
// - le débordement du contenu réel (bas du texte au bas du viewport) ;
// - titleEnd (fin du scale-down).
// Le max sur titleEnd est essentiel : pendant que le titre rétrécit,
// l'en-tête est épinglé en haut et le contenu remonte pour suivre son
// bord bas ; un gel démarrant avant la fin du scale-down pousserait
// l'en-tête vers le bas → « le titre diminue en descendant » (576, 584).
const leftFreezeStart = () => Math.max(0, leftContentHeight() - viewport(), titleEnd());
// 1) Colonnes dimensionnées AVANT la création des ScrollTriggers.
layoutSync();
// 2) SCALE-DOWN DU TITRE
if (pageHeader) {
gsap.to(pageHeader, {
scale: headerScaleTarget,
y: titleEnd, // compense la réduction pour garder le bord haut fixe
ease: 'none',
scrollTrigger: {
scroller: scroller, start: 0, end: titleEnd, scrub: true, invalidateOnRefresh: true,
},
});
}
// 3) GEL DE LA COLONNE GAUCHE
// On NE pin PAS (le pin sort l'élément du flux et casse le flex .cols).
// On translate : défilement natif jusqu'à `leftFreezeStart`, puis
// contre-translation 1:1 du scroll → elle se fige en restant un
// flex-item normal. `.cols` est en `align-items: flex-start`.
if (cols && leftColumn) {
gsap.fromTo(leftColumn,
{ y: 0 },
{
y: () => (scroller.scrollHeight - scroller.clientHeight) - leftFreezeStart(),
ease: 'none', ease: 'none',
scrollTrigger: { scrollTrigger: {
scroller: scroller, scroller: scroller, start: leftFreezeStart, end: 'max', scrub: true, invalidateOnRefresh: true,
start: 0,
end: end, // scroll maximum du conteneur (robuste au resize)
scrub: true,
invalidateOnRefresh: true,
}, },
}); }
}) );
}
// 4) Recalage quand les hauteurs changent (images/police) : dans
// l'ordre layoutSync (→ lenis.resize) puis refresh des ScrollTriggers.
const recalc = () => { layoutSync(); ScrollTrigger.refresh(); };
const imgs = rightColumn ? Array.from(rightColumn.querySelectorAll('img')) : [];
imgs.forEach(img => { if (!img.complete) img.addEventListener('load', recalc); });
window.addEventListener('load', recalc);
// La police Epilogue peut charger après `load` et changer la hauteur /
// le nombre de lignes du titre → titleEnd faux (node/584 : titre coupé,
// parfois sur 3 lignes). On recale quand les polices sont prêtes.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(recalc);
// 5) Nettoyage au repassage < 768px.
return () => {
imgs.forEach(img => img.removeEventListener('load', recalc));
window.removeEventListener('load', recalc);
if (spacer) spacer.style.height = '';
if (rightColumn) rightColumn.style.removeProperty('--sticky-extent');
};
}); });
}); });
} });
// BLOCAGE DE LA COLONNE GAUCHE
// La gauche défile normalement puis se fige (pin) quand le bas de son
// contenu atteint le bas du viewport, pendant que la colonne droite (plus
// haute) continue. Piloté en GSAP plutôt qu'en `position: sticky` (qui ne
// prend pas dans ce contexte scroller custom + GSAP). `.cols` est en
// `align-items: flex-start`, donc .col.left a bien la hauteur de son
// contenu (sinon `bottom bottom` tomberait à la fin du scroll).
const cols = document.querySelector('article.node-type-projet.view-mode-full .cols');
const leftColumn = cols && cols.querySelector('.col.left');
if (cols && leftColumn) {
// Doit rester identique au scale-down du titre (voir plus haut).
const titleScaleTarget = 0.4;
// Seuil de scroll (px) après lequel la colonne gauche se fige :
// - gauche plus haute que le viewport : quand son bas atteint le bas ;
// - gauche plus courte : quand le scale-down du titre est terminé, pour
// qu'elle remonte AVEC le titre puis s'arrête (sans disparaître en haut).
const leftFreezeStart = () => {
const overflow = leftColumn.offsetHeight - scroller.clientHeight;
if (overflow > 0) return overflow;
return pageHeader ? initHeaderH * (1 - headerScaleTarget) : 0;
};
gsap_mm.add('(min-width: 768px)', () => {
// On NE pin PAS (le pin sort l'élément du flux et casse le flex .cols).
// On translate la colonne : défilement natif jusqu'à `leftFreezeStart`,
// puis contre-translation 1:1 du scroll → elle se fige, tout en restant
// un flex-item normal (la colonne droite garde sa place).
gsap.fromTo(leftColumn,
{ y: 0 },
{
y: () => (scroller.scrollHeight - scroller.clientHeight) - leftFreezeStart(),
ease: 'none',
scrollTrigger: {
scroller: scroller,
start: leftFreezeStart, // px de scroll où la gauche se fige
end: 'max',
scrub: true,
invalidateOnRefresh: true,
},
}
);
});
}
} }
@@ -471,7 +471,10 @@ div.layout.layout--twocol-section--50-50{
content: ""; content: "";
// border: 1px solid red; // border: 1px solid red;
width: 100%; width: 100%;
height: 100vh; // Étend le contexte sticky au-delà de la dernière image pour
// couvrir le scroll restant de la colonne gauche. Hauteur calculée
// en JS (voir main.js, adjustStickyContext) via --sticky-extent.
height: var(--sticky-extent, 100vh);
} }
// } // }
.field-item{ .field-item{