/** * Glisser et redimensionner les barres. * * Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un * glisser déplace la barre. On les départage au seuil : tant que le pointeur * n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme * un clic. Sans ce seuil, ouvrir le panneau décalerait la phase d'un jour à * chaque fois que la souris frémit. * * Pendant le geste, seule la géométrie de l'élément est modifiée — l'état n'est * touché qu'au relâchement. Cela évite de reconstruire toute la frise à chaque * mouvement de souris, et laisse la possibilité d'annuler avec Échap. */ import { ajouterJours, deplacerPhase, lundiDeLaSemaine, redimensionnerPhase, versUTC, } from './model.js'; /** Déplacement en pixels au-delà duquel l'appui devient un glisser. */ const SEUIL_GLISSER = 4; /** * Installe la gestion du glisser sur le conteneur des couloirs. * * @param {HTMLElement} conteneur * @param {object} rappels * - echelle() : l'échelle courante * - trouverPhase(idProjet, idPhase) * - surDeplacement(idProjet, idPhase, phase) : appelé au relâchement * - surClic(idProjet, idPhase) */ export function installerGlisser(conteneur, rappels) { let geste = null; conteneur.addEventListener('pointerdown', (evenement) => { // Bouton principal uniquement : le clic droit ouvre le menu contextuel. if (evenement.button !== 0) return; const cible = evenement.target.closest('[data-role="barre"]'); if (!cible) return; const { projet: idProjet, phase: idPhase } = cible.dataset; const phase = rappels.trouverPhase(idProjet, idPhase); if (!phase) return; const poignee = evenement.target.closest('[data-role="poignee"]'); // Un jalon n'a pas de durée : ses poignées n'ont pas de sens. const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer'; geste = { element: cible, idProjet, idPhase, phaseInitiale: phase, phaseCourante: phase, mode, xDepart: evenement.clientX, gaucheInitiale: cible.offsetLeft, largeurInitiale: cible.offsetWidth, demarre: false, }; cible.setPointerCapture(evenement.pointerId); // Empêche la sélection de texte et le glisser natif de l'élément. evenement.preventDefault(); }); conteneur.addEventListener('pointermove', (evenement) => { if (!geste) return; const decalage = evenement.clientX - geste.xDepart; if (!geste.demarre) { if (Math.abs(decalage) < SEUIL_GLISSER) return; geste.demarre = true; geste.element.classList.add('barre--glissee'); document.body.classList.add('glisse-en-cours'); } appliquerGeste(geste, decalage, rappels.echelle()); }); conteneur.addEventListener('pointerup', (evenement) => { if (!geste) return; const acheve = geste; geste = null; acheve.element.releasePointerCapture?.(evenement.pointerId); acheve.element.classList.remove('barre--glissee'); document.body.classList.remove('glisse-en-cours'); if (!acheve.demarre) { rappels.surClic(acheve.idProjet, acheve.idPhase); return; } // Le geste n'a rien changé (revenu à son point de départ) : inutile de // déclencher une sauvegarde. const avant = acheve.phaseInitiale; const apres = acheve.phaseCourante; if (apres.start === avant.start && apres.end === avant.end) { rappels.surDeplacement(acheve.idProjet, acheve.idPhase, null); return; } rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres); }); // Échap pendant le geste : on annule et on laisse le rendu se refaire. document.addEventListener('keydown', (evenement) => { if (evenement.key !== 'Escape' || !geste) return; const annule = geste; geste = null; annule.element.classList.remove('barre--glissee'); document.body.classList.remove('glisse-en-cours'); rappels.surDeplacement(annule.idProjet, annule.idPhase, null); }); // Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit // laisser l'interface dans un état propre plutôt qu'en glisser perpétuel. conteneur.addEventListener('pointercancel', () => { if (!geste) return; const annule = geste; geste = null; annule.element.classList.remove('barre--glissee'); document.body.classList.remove('glisse-en-cours'); rappels.surDeplacement(annule.idProjet, annule.idPhase, null); }); } /** * Met à jour la géométrie de l'élément et mémorise les dates correspondantes. * Les dates s'accrochent au lundi (docs/decisions.md, section 9). */ function appliquerGeste(geste, decalage, echelle) { const { phaseInitiale: phase, mode } = geste; if (mode === 'deplacer') { const debut = accrocher(phase.start, decalage, echelle); geste.phaseCourante = deplacerPhase(phase, debut); geste.element.style.left = `${echelle.x(debut)}px`; return; } if (mode === 'redimensionner-debut') { let debut = accrocher(phase.start, decalage, echelle); // La borne opposée fait butée : une phase ne peut pas se retourner. if (versUTC(debut) > versUTC(phase.end)) debut = phase.end; geste.phaseCourante = redimensionnerPhase(phase, 'debut', debut); geste.element.style.left = `${echelle.x(debut)}px`; geste.element.style.width = `${echelle.largeurDe(debut, phase.end)}px`; return; } let fin = accrocher(phase.end, decalage, echelle); if (versUTC(fin) < versUTC(phase.start)) fin = phase.start; geste.phaseCourante = redimensionnerPhase(phase, 'fin', fin); geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`; } /** * Décale une date du nombre de jours correspondant au déplacement, puis * l'accroche au lundi de sa semaine. */ function accrocher(date, decalagePixels, echelle) { const jours = Math.round(decalagePixels / echelle.pixelsParJour); return lundiDeLaSemaine(ajouterJours(date, jours)); } /** * Décale une phase au clavier, pour un ajustement fin sans souris. * Utilisé par app.js sur les flèches gauche/droite quand une phase est * sélectionnée. */ export function decalerAuClavier(phase, jours) { return deplacerPhase(phase, ajouterJours(phase.start, jours)); } /** Allonge ou raccourcit une phase au clavier, sans jamais la retourner. */ export function etirerAuClavier(phase, jours) { if (phase.milestone) return phase; const fin = ajouterJours(phase.end, jours); if (versUTC(fin) < versUTC(phase.start)) return phase; return { ...phase, end: fin }; }