/** * Glisser et redimensionner les barres. * * Trois sortes de barres se saisissent, et le même appui les sert toutes : * celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet * envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui * repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle * déplace par son `data-role`. * * 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, ancreDecalage, decalerProjet, deplacerPhase, dernierJourDuMois, ecartJours, ecartMois, lundiDeLaSemaine, moisDe, phasesDecalables, premierJourDuMois, redimensionnerPhase, versUTC, } from './model.js'; import { poserBarreCumulative } from './timeline.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"], [data-role="cumul"]' ); if (!cible) return; const poignee = evenement.target.closest('[data-role="poignee"]'); const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer'; // La barre cumulative repousse le projet entier. Elle n'a pas de poignées : // étirer un projet supposerait de dilater chaque phase au prorata, ce qui // n'est pas le même besoin (docs/decisions.md, section 8). if (cible.dataset.role === 'cumul') { const projet = rappels.trouverProjet(cible.dataset.projet); const ancre = projet && ancreDecalage(projet); if (!ancre) return; geste = { element: cible, sorte: 'cumul', idProjet: projet.id, projetInitial: projet, ancre, jours: 0, mode: 'deplacer', xDepart: evenement.clientX, // Les barres de phase suivent le résumé pendant le geste : sans elles, // on repousserait plusieurs mois de travail sans voir ce qui bouge — // l'objection qui avait d'abord fait écarter ce geste. suiveurs: suiveursDuProjet(conteneur, projet), demarre: false, }; cible.setPointerCapture(evenement.pointerId); evenement.preventDefault(); return; } // 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 === 'cumul') { // Un appui sans glisser ouvre les paramètres du projet, comme un clic sur // son nom ou sur sa barre d'horizon. if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet); else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null); return; } 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 === 'cumul') rappels.surDecalageProjet(annule.idProjet, null); else 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 === 'cumul') return appliquerGesteCumul(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`; } /** * Éléments de la frise qu'un décalage de projet emporte, avec leur position de * départ. * * La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom * d'un jalon porte une marge, que le second compte et le premier non. Repartir * de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage * sans en décaler deux fois. * * Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase, * il tient la place du plan figé, et le voir rester en arrière pendant qu'on * repousse le projet *est* la dérive qui s'affiche. */ function suiveursDuProjet(conteneur, projet) { const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id)); const suiveurs = []; for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) { if (element.dataset.projet !== projet.id) continue; if (!decalables.has(element.dataset.phase)) continue; suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) }); } return suiveurs; } /** * Repousse un projet entier : le même nombre de jours sur toutes ses phases non * terminées, accroché au lundi comme une phase seule (décision 9). * * L'accroche porte sur la première phase qui bouge, et non sur le début du * projet — sur un projet dont les premières phases sont closes, ce début reste * en place et ne dirait rien du geste en cours. * * La barre cumulative est reposée depuis le modèle plutôt que translatée : elle * se déforme quand une partie du projet ne suit pas, et son bord gauche tient * pendant que le reste s'en va. */ function appliquerGesteCumul(geste, decalage, echelle) { const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle)); if (jours === geste.jours) return; geste.jours = jours; poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle); const pixels = jours * echelle.pixelsParJour; for (const suiveur of geste.suiveurs) { suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}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 }; }