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>
406 lines
15 KiB
JavaScript
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 };
|
|
}
|