Files
gestion_projets/js/drag.js
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:07:31 +02:00

406 lines
15 KiB
JavaScript

/**
* 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 };
}