// import { createApp } from 'vue' import '../scss/main.scss' import "splitting/dist/splitting.css"; import "splitting/dist/splitting-cells.css"; import { gsap, Power1 } from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import Lenis from 'lenis'; import 'fslightbox'; gsap.registerPlugin(ScrollTrigger); // import Content from './vuejs/Content.vue' // import REST from './api/rest-axios' // import LocomotiveScroll from 'locomotive-scroll'; // const scroll = new LocomotiveScroll(); // /** // * @file // * https://www.drupal.org/docs/drupal-apis/javascript-api/javascript-api-overview // */ // (function (Drupal) { // 'use strict'; // Drupal.behaviors.leshed = { // attach: function (context, settings) { // console.log('It works!'); // } // }; // } (Drupal)); (function (Drupal, drupalSettings) { const LeShedTheme = function () { const _is_front = drupalSettings.path.isFront console.log('drupalSettings', drupalSettings) const scroller = document.querySelector('main[role="main"]'); const gsap_mm = gsap.matchMedia(); 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; // ___ _ _ // |_ _|_ _ (_) |_ // | || ' \| | _| // |___|_||_|_|\__| function init () { console.log('LeShedTheme init()'); console.log('deploy test to delete'); initBurgerMenu(); initSmoothScroll(); initHomeColumnScrollSync(); initFsLightbox(); initPageFull(); // initVues() } function onWindowResize() { _cardsForms.forEach(({card, forme, formepos}) => { // console.log(`card`, card, 'forme', forme, 'formepos', formepos); forme.style.top = `${card.offsetTop + formepos.top}px`; forme.style.left = `${card.offsetLeft + formepos.left}px`; }) } function initBurgerMenu() { let header_right = document.getElementById('burger-btn'); header_right.parentElement.addEventListener('click', function(e){ // console.log('click header_right', this); this.firstElementChild.toggleAttribute('opened'); this.firstElementChild.nextElementSibling.toggleAttribute('opened'); }) } /** * Scroll lissé (Lenis) sur le conteneur `main` — et non la fenêtre, car * c'est `main[role="main"]` qui porte l'overflow. Lenis est branché sur le * ticker GSAP (une seule boucle RAF) et pousse ses updates à ScrollTrigger * pour que la synchro des colonnes reste calée sur le scroll lissé. */ function initSmoothScroll() { const scroller = document.querySelector('main[role="main"]'); const content = scroller && scroller.querySelector('.layout-content'); if (!scroller || !content) return; lenis = new Lenis({ wrapper: scroller, // conteneur scrollable custom content: content, // contenu réel (le 1er enfant est l'ancre skip-link) duration: 1.2, smoothWheel: true, }); lenis.on('scroll', ScrollTrigger.update); gsap.ticker.add((time) => lenis.raf(time * 1000)); 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; } /** * Synchronise le scroll des deux colonnes du layout 50/50 : la colonne la * plus courte est ralentie (translateY décroissant) pour que son bas soit * atteint en même temps que celui de la colonne la plus haute, qui défile * nativement (flux normal, pas de pin). Le scroll réel a lieu dans * `main[role="main"]`, pas dans la fenêtre : GSAP utilise donc ce conteneur * comme `scroller`. */ function initHomeColumnScrollSync() { console.log('initHomeColumnScrollSync'); const firstColumn = document.querySelector('.layout--twocol-section--50-50 .layout__region--first'); const secondColumn = document.querySelector('.layout--twocol-section--50-50 .layout__region--second'); console.log(scroller, firstColumn, secondColumn); if (!scroller || !firstColumn || !secondColumn) return; gsap_mm.add('(min-width: 768px)', () => { [firstColumn, secondColumn].forEach((column) => { gsap.to(column, { // Distance à parcourir pour que le bas de la colonne arrive en // bas du viewport en même temps que la colonne la plus haute. y: () => { const visibleHeight = scroller.clientHeight; const scrollRange = scroller.scrollHeight - scroller.clientHeight; const columnRevealRange = Math.max(0, column.offsetHeight - visibleHeight); return scrollRange - columnRevealRange; }, ease: 'none', scrollTrigger: { scroller: scroller, start: 0, end: 'max', // scroll maximum du conteneur (robuste au resize) // Valeur numérique = easing : l'animation met ~0.6s à rattraper la // position de scroll au lieu de la suivre au pixel près → lisse les // à-coups de la colonne lente. Monter la valeur = plus d'inertie. scrub: true, invalidateOnRefresh: true, }, }); }); // Nettoyage automatique des tweens/triggers quand on repasse sous 768px. return () => {}; }); } function initPageFull(){ console.log('initPageFull'); const headerScaleTarget = 0.4; 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('\ article.node-type-projet.view-mode-full .cols>.col.left>header,\ article.node-type-evenement.view-mode-full .cols>.col.left>header\ '); console.log('pageHeader', pageHeader); if (!pageHeader && !(cols && leftColumn)) return; // 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(() => { gsap_mm.add('(min-width: 768px)', () => { const viewport = () => scroller.clientHeight; // Amplitude du scale-down du titre, en px de scroll. 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', scrollTrigger: { scroller: scroller, start: leftFreezeStart, end: 'max', 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'); }; }); }); }); } function initFsLightbox(){ console.log('initFsLightbox'); // console.log(window.FsLightbox); let imgs_links = document.querySelectorAll('article.node-type-projet.view-mode-full .field_images.multiple .field-item a'); for (const link of imgs_links) { link.setAttribute("data-fslightbox", "gallery") } // window.FsLightbox window.refreshFsLightbox(); } // function initVues(){ // console.log('initVues'); // // initVueContent(); // } // function initVueContent(){ // createApp(Content).mount('main[role="main"]'); // // processEtapeLinks(); // } // function onClickEtapeLink(e){ // e.preventDefault(); // let a = e.currentTarget; // let nid = a.dataset.nodeNid; // console.log(nid); // getNodeData(nid); // return null; // } // function processEtapeLinks(){ // let etape_link_fields = document.querySelectorAll('#etapes-liste div.views-field-title'); // etape_link_fields.forEach((field, index) => { // let nid = null; // let classList = field.classList; // classList.forEach((classe) => { // let reg = /data-node-(\d+)/; // let result = classe.match(reg); // if (result) { // nid = result[1]; // console.log(nid); // } // }) // if (nid) { // let a = field.querySelector('a'); // a.setAttribute('data-node-nid', nid); // a.addEventListener('click', onClickEtapeLink); // } // }) // } // function getNodeData(nid){ // const params = { // } // REST.get(`/node/${nid}?_format=json`, params) // .then((data) => { // console.log('user REST getUser data', data) // }) // .catch(error => { // console.warn('Issue with getNodedata', error) // Promise.reject(error) // }) // } init() } // end LeShedTheme() LeShedTheme() })(Drupal, drupalSettings)