/** * 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, ajouterMois, deplacerPhase, dernierJourDuMois, ecartMois, lundiDeLaSemaine, moisDe, premierJourDuMois, 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"], [data-role="horizon"]'); if (!cible) return; const poignee = evenement.target.closest('[data-role="poignee"]'); const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer'; // La barre d'un projet envisagé se manœuvre au mois, pas au jour : même // mécanique de geste, autre grain, et rien de commun avec une phase — il n'y // en a pas encore (docs/decisions.md, section 27). if (cible.dataset.role === 'horizon') { const projet = rappels.trouverProjet(cible.dataset.projet); if (!projet?.horizon) return; geste = { element: cible, sorte: 'horizon', idProjet: projet.id, horizonInitial: projet.horizon, horizonCourant: projet.horizon, mode: bord, xDepart: evenement.clientX, demarre: false, }; cible.setPointerCapture(evenement.pointerId); evenement.preventDefault(); return; } const { projet: idProjet, phase: idPhase } = cible.dataset; const phase = rappels.trouverPhase(idProjet, idPhase); if (!phase) return; // Un jalon n'a pas de durée : ses poignées n'ont pas de sens. const mode = poignee && !phase.milestone ? bord : 'deplacer'; geste = { element: cible, sorte: 'phase', 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; relacher(acheve, evenement.pointerId); if (acheve.sorte === 'horizon') { const avant = acheve.horizonInitial; const apres = acheve.horizonCourant; // Un appui sans glisser ouvre les paramètres du projet : c'est là que se // saisissent son horizon et le reste, comme un clic sur son nom. if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet); else if (apres.start !== avant.start || apres.end !== avant.end) { rappels.surDeplacementHorizon(acheve.idProjet, apres); } else { rappels.surDeplacementHorizon(acheve.idProjet, null); } return; } 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); }); /** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */ function annuler() { if (!geste) return; const annule = geste; geste = null; relacher(annule); if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null); else rappels.surDeplacement(annule.idProjet, annule.idPhase, null); } // Échap pendant le geste : on annule et on laisse le rendu se refaire. document.addEventListener('keydown', (evenement) => { if (evenement.key === 'Escape') annuler(); }); // 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', annuler); } /** * Rend le pointeur et retire les marques du glisser. * * `releasePointerCapture` lève quand le pointeur a déjà disparu — périphérique * débranché, fenêtre qui perd le focus au mauvais moment. L'exception ne doit * pas empêcher ce qui suit : le geste de l'utilisateur est achevé et son * résultat mérite d'être appliqué, même si le nettoyage n'avait plus rien à * rendre. */ function relacher(geste, pointerId) { if (pointerId !== undefined) { try { geste.element.releasePointerCapture?.(pointerId); } catch { // Le pointeur n'était plus capturé : il n'y a rien à réparer. } } geste.element.classList.remove('barre--glissee'); document.body.classList.remove('glisse-en-cours'); } /** * 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) { if (geste.sorte === 'horizon') return appliquerGesteHorizon(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`; } /** * Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** : * l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire * qu'il en pose. * * Les bornes ne se retournent pas — la borne opposée fait butée, comme pour une * phase —, si bien qu'un horizon fait toujours au moins un mois plein. */ function appliquerGesteHorizon(geste, decalage, echelle) { const { horizonInitial: horizon, mode } = geste; const largeur = ecartMois(horizon.start, horizon.end); let suivant; if (mode === 'deplacer') { const debut = accrocherAuMois(horizon.start, decalage, echelle); suivant = { start: debut, end: ajouterMois(debut, largeur) }; } else if (mode === 'redimensionner-debut') { const debut = accrocherAuMois(horizon.start, decalage, echelle); suivant = { start: debut > horizon.end ? horizon.end : debut, end: horizon.end }; } else { const fin = accrocherAuMois(horizon.end, decalage, echelle); suivant = { start: horizon.start, end: fin < horizon.start ? horizon.start : fin }; } geste.horizonCourant = suivant; const gauche = premierJourDuMois(suivant.start); const droite = dernierJourDuMois(suivant.end); geste.element.style.left = `${echelle.x(gauche)}px`; geste.element.style.width = `${echelle.largeurDe(gauche, droite)}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 un mois du déplacement demandé, puis l'accroche au mois le plus proche. * * L'arrondi se fait à la moitié du mois atteint plutôt qu'à un nombre fixe de * jours : les mois n'ont pas tous la même longueur, et découper en tranches de * trente jours ferait dériver le geste d'un cran au fil de l'année. */ function accrocherAuMois(mois, decalagePixels, echelle) { const jours = Math.round(decalagePixels / echelle.pixelsParJour); const cible = ajouterJours(premierJourDuMois(mois), jours); const moisCible = moisDe(cible); const longueur = Number(dernierJourDuMois(moisCible).slice(8, 10)); return Number(cible.slice(8, 10)) > longueur / 2 ? ajouterMois(moisCible, 1) : moisCible; } /** * 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 }; }