La barre d'un projet envisagé est pâle et hachurée, aux bords fondus. Elle se glisse et se redimensionne comme une phase, mais accrochée au mois : le geste existe, il ne permet pas de poser une date qu'on n'a pas. Le panneau ajoute deux champs de mois et trois crans. Une barre « Afficher » filtre par état, disjonctive là où celle des tags est conjonctive — d'où des boutons carrés et non des pastilles. Les écartés en sont décochés au départ. Engager démarre au premier jour de l'horizon, et clore un projet demande confirmation quand il reste des phases à écraser. Au passage : releasePointerCapture peut lever quand le pointeur a disparu, ce qui sautait l'application du geste. Le nettoyage en est maintenant isolé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
10 KiB
JavaScript
303 lines
10 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|