Dessiner l'horizon sur la frise, l'éditer et le glisser
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>
This commit is contained in:
151
js/drag.js
151
js/drag.js
@@ -14,8 +14,13 @@
|
||||
|
||||
import {
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
ecartMois,
|
||||
lundiDeLaSemaine,
|
||||
moisDe,
|
||||
premierJourDuMois,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
@@ -40,19 +45,45 @@ export function installerGlisser(conteneur, rappels) {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
if (evenement.button !== 0) return;
|
||||
|
||||
const cible = evenement.target.closest('[data-role="barre"]');
|
||||
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;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
|
||||
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
const mode = poignee && !phase.milestone ? bord : 'deplacer';
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
sorte: 'phase',
|
||||
idProjet,
|
||||
idPhase,
|
||||
phaseInitiale: phase,
|
||||
@@ -89,9 +120,21 @@ export function installerGlisser(conteneur, rappels) {
|
||||
const acheve = geste;
|
||||
geste = null;
|
||||
|
||||
acheve.element.releasePointerCapture?.(evenement.pointerId);
|
||||
acheve.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
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);
|
||||
@@ -110,26 +153,46 @@ export function installerGlisser(conteneur, rappels) {
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
||||
});
|
||||
|
||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key !== 'Escape' || !geste) return;
|
||||
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
|
||||
function annuler() {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, 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', () => {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +200,8 @@ export function installerGlisser(conteneur, rappels) {
|
||||
* 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') {
|
||||
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
|
||||
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.
|
||||
@@ -173,6 +269,21 @@ function accrocher(date, decalagePixels, echelle) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user