Les flèches décalaient déjà une phase, mais rien ne permettait de la choisir :
`selection` n'était posée qu'au clic, si bien qu'il fallait la souris pour
commencer. Elle devient `curseur`, désigne une ligne — celle d'un projet en est
une, d'où l'on plie, renomme et ajoute — et survit à Échap, sans quoi elle ne
pourrait servir de point de départ au déplacement suivant. `dessiner` le
normalise, la ligne du projet servant de refuge quand la sienne disparaît.
Deux couches qui ne se recouvrent pas : les flèches modifient, les lettres
déplacent et commandent. Aucun raccourci documenté ne change de sens. Les
touches se taisent dès que le clavier est à quelqu'un d'autre — une saisie, un
dialogue, le menu, un glisser —, faute de quoi `a` dans un nom créerait une
phase. AltGr est toléré : sur un clavier français `{` et `}` n'existent qu'avec
lui, et Windows le présente comme Ctrl+Alt.
Les phases étant triées par date, `o` et `O` insèrent dans le temps et non dans
un rang. `P` reste hors de cette famille : un projet naît sans position.
Le curseur se peint dans la colonne des libellés, seul endroit qui porte une
ligne pour chaque position atteignable — projet plié, projet masqué, ligne de
projet. Et `?` ouvre la table des raccourcis, qui ne se devinent pas.
Décision 26.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1542 lines
55 KiB
JavaScript
1542 lines
55 KiB
JavaScript
/**
|
||
* Amorçage et câblage.
|
||
*
|
||
* L'état vit ici, dans une seule variable `planning`. Toute modification passe
|
||
* par `majPlanning`, qui rend la frise et déclenche la sauvegarde : il n'y a
|
||
* qu'un seul chemin pour changer quelque chose, ce qui rend le flux facile à
|
||
* suivre.
|
||
*/
|
||
|
||
import { creerPanneau } from './detail.js';
|
||
import { creerPanneauProjet } from './projet.js';
|
||
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
||
import { installerClavier } from './clavier.js';
|
||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||
import { PIXELS_PAR_JOUR, rendre } from './timeline.js';
|
||
import { rendreMois, revelerMoisCourant } from './mois.js';
|
||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||
import {
|
||
COULEURS,
|
||
STATUTS,
|
||
ajouterJours,
|
||
ajouterPhase,
|
||
aujourdhui,
|
||
bornesPlanning,
|
||
cleTag,
|
||
creerPhase,
|
||
creerProjet,
|
||
figerReference,
|
||
formaterDateLongue,
|
||
libererReference,
|
||
modifierPhase,
|
||
normaliserTags,
|
||
planningVide,
|
||
pourEcriture,
|
||
projetFiltre,
|
||
remplacerPhase,
|
||
remplacerProjet,
|
||
supprimerPhase,
|
||
supprimerProjet,
|
||
teinteTag,
|
||
tousLesTags,
|
||
validerPlanning,
|
||
versUTC,
|
||
} from './model.js';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const refs = {
|
||
libelles: $('libelles'),
|
||
entete: $('entete'),
|
||
couloirs: $('couloirs'),
|
||
frise: $('frise'),
|
||
vueMois: $('vue-mois'),
|
||
onglets: $('onglets'),
|
||
etat: $('etat-sauvegarde'),
|
||
messageVide: $('message-vide'),
|
||
messageFiltre: $('message-filtre'),
|
||
filtres: $('filtres'),
|
||
filtresListe: $('filtres-liste'),
|
||
effacerFiltres: $('effacer-filtres'),
|
||
alerte: $('alerte'),
|
||
alerteMessage: $('alerte-message'),
|
||
panneau: $('panneau'),
|
||
};
|
||
|
||
let planning = planningVide();
|
||
let echelle = null;
|
||
|
||
/**
|
||
* Curseur clavier : la ligne de la frise sur laquelle portent les raccourcis.
|
||
*
|
||
* `{ projet, phase }`, où `phase` vaut `null` quand le curseur est posé sur la
|
||
* ligne d'un projet — position à part entière, d'où l'on plie, on renomme et on
|
||
* ajoute une phase.
|
||
*
|
||
* Il ne se confond plus avec « le panneau est ouvert sur cette phase », comme
|
||
* c'était le cas de l'ancienne `selection` : un curseur qui s'effaçait à la
|
||
* fermeture du panneau ne pouvait pas servir de point de départ aux
|
||
* déplacements. Il survit donc à `Échap`, et c'est le panneau qui le suit.
|
||
*/
|
||
let curseur = null;
|
||
|
||
/**
|
||
* Vue affichée : 'frise' ou 'mois'. État de vue, comme le filtre et la fenêtre
|
||
* temporelle — jamais enregistré.
|
||
*
|
||
* Contrairement à la largeur de la colonne, qui est un réglage d'écran, la vue
|
||
* choisie est un geste de consultation : on passe par la liste pour faire le
|
||
* point, on revient à la frise pour travailler. La retrouver au rechargement
|
||
* imposerait à la séance suivante le regard de la précédente ; l'outil s'ouvre
|
||
* donc toujours sur la frise, qui est celle où l'on modifie.
|
||
*/
|
||
let vue = 'frise';
|
||
|
||
/**
|
||
* Défilement horizontal de la frise au moment où on l'a quittée.
|
||
*
|
||
* Masquer un élément lui fait perdre son `scrollLeft` : sans cette mémoire, un
|
||
* aller-retour par la liste ramènerait la frise à son extrême gauche, soit des
|
||
* années avant ce qu'on regardait.
|
||
*/
|
||
let defilementFrise = 0;
|
||
|
||
/**
|
||
* Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle,
|
||
* c'est un état de **vue** : jamais enregistré, jamais partagé.
|
||
*
|
||
* Un filtre qui survivrait au rechargement rouvrirait le planning amputé sans
|
||
* qu'on se souvienne pourquoi. `hidden`, lui, est bien enregistré — c'est une
|
||
* décision durable sur un projet, pas un coup d'œil.
|
||
*
|
||
* La sélection est conjonctive : un projet doit porter *tous* les tags cochés.
|
||
*/
|
||
let tagsActifs = [];
|
||
|
||
/**
|
||
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
|
||
*
|
||
* Elle démarre autour des phases existantes puis s'élargit toute seule quand le
|
||
* défilement approche d'un bord, ce qui permet d'aller poser une phase dans un
|
||
* futur qu'aucune donnée n'occupe encore.
|
||
*/
|
||
let fenetre = null;
|
||
|
||
/** Marge conservée autour des phases : on peut toujours glisser au-delà. */
|
||
const MARGE_JOURS = 120;
|
||
|
||
/** Longueur ajoutée à chaque élargissement, environ six mois. */
|
||
const PAS_ELARGISSEMENT = 182;
|
||
|
||
/** Distance au bord, en pixels, à partir de laquelle on élargit. */
|
||
const SEUIL_ELARGISSEMENT = 400;
|
||
|
||
/**
|
||
* Garde-fou : l'élargissement s'arrête à dix ans de part et d'autre des
|
||
* données. « Infini » au sens de l'usage — on ne planifie pas au-delà — sans
|
||
* laisser un défilement prolongé faire enfler le DOM sans limite.
|
||
*/
|
||
const PORTEE_MAX_ANNEES = 10;
|
||
|
||
const stockage = creerStockage(afficherEtat);
|
||
const menu = creerMenu($('menu'));
|
||
|
||
const demanderConfirmation = creerDialogueConfirmation({
|
||
dialogue: $('dialogue-confirmation'),
|
||
titre: $('dialogue-confirmation-titre'),
|
||
message: $('dialogue-confirmation-message'),
|
||
valider: $('dialogue-confirmation-ok'),
|
||
});
|
||
|
||
const panneau = creerPanneau(
|
||
{
|
||
panneau: $('panneau'),
|
||
formulaire: $('formulaire-phase'),
|
||
titre: $('panneau-titre'),
|
||
erreur: $('erreur-phase'),
|
||
champFin: $('champ-fin'),
|
||
notes: $('notes-phase'),
|
||
supprimer: $('supprimer-phase'),
|
||
fermer: $('fermer-panneau'),
|
||
},
|
||
{
|
||
surModification(idProjet, idPhase, phase) {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) =>
|
||
remplacerPhase(projet, idPhase, () => phase)
|
||
)
|
||
);
|
||
},
|
||
demanderSuppression(_idProjet, _idPhase, nomPhase) {
|
||
return demanderConfirmation({
|
||
titreTexte: 'Supprimer la phase',
|
||
messageTexte: `« ${nomPhase} » sera définitivement supprimée du planning.`,
|
||
libelleValider: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
},
|
||
surSuppression(idProjet, idPhase) {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase))
|
||
);
|
||
},
|
||
// Le curseur n'est pas effacé : `Échap` referme le volet et laisse la
|
||
// frappe suivante repartir de la phase qu'on regardait.
|
||
surFermeture() {
|
||
dessiner();
|
||
},
|
||
}
|
||
);
|
||
|
||
const panneauProjet = creerPanneauProjet(
|
||
{
|
||
panneau: $('panneau-projet'),
|
||
formulaire: $('formulaire-projet'),
|
||
titre: $('panneau-projet-titre'),
|
||
erreur: $('erreur-projet'),
|
||
conteneurCouleurs: $('panneau-projet-couleurs'),
|
||
suggestions: $('panneau-projet-suggestions'),
|
||
blocSuggestions: $('panneau-projet-suggestions-bloc'),
|
||
notes: $('notes-projet'),
|
||
supprimer: $('supprimer-projet'),
|
||
fermer: $('fermer-panneau-projet'),
|
||
},
|
||
{ couleurs: COULEURS, normaliserTags, cleTag, teinteTag },
|
||
{
|
||
tagsConnus: () => tousLesTags(planning),
|
||
surModification(idProjet, projet) {
|
||
// L'identifiant ne change pas : il identifie, il ne décrit pas.
|
||
majPlanning(remplacerProjet(planning, idProjet, () => projet));
|
||
},
|
||
demanderSuppression(_idProjet, projet) {
|
||
return confirmerSuppressionProjet(projet);
|
||
},
|
||
surSuppression(idProjet) {
|
||
if (curseur?.projet === idProjet) panneau.fermer();
|
||
majPlanning(supprimerProjet(planning, idProjet));
|
||
},
|
||
surFermeture() {
|
||
dessiner();
|
||
},
|
||
}
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// État
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Unique point d'entrée des modifications : rend puis sauvegarde. */
|
||
function majPlanning(suivant) {
|
||
planning = suivant;
|
||
// Retirer son dernier tag à un projet peut vider le filtre de sa substance :
|
||
// sans cet élagage, un tag que plus personne ne porte masquerait tout.
|
||
tagsActifs = tagsActifs.filter((tag) =>
|
||
planning.projects.some((projet) => projet.tags.some((pose) => cleTag(pose) === cleTag(tag)))
|
||
);
|
||
dessiner();
|
||
stockage.sauvegarder(pourEcriture(planning));
|
||
}
|
||
|
||
function dessiner() {
|
||
// Avant tout rendu : le curseur peut désigner une ligne que le changement
|
||
// vient d'emporter — phase supprimée, projet replié, filtre resserré.
|
||
normaliserCurseur();
|
||
|
||
const aDesProjets = planning.projects.length > 0;
|
||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||
|
||
refs.messageVide.hidden = aDesProjets;
|
||
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
|
||
// message, la frise vide laisserait croire que les projets ont disparu.
|
||
refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0;
|
||
|
||
// Les deux vues partagent le même sort face aux messages : ce qui n'a aucun
|
||
// projet à montrer n'en a pas davantage à lister.
|
||
const aQuelqueChoseAMontrer = aDesProjets && retenus.length > 0;
|
||
refs.frise.hidden = !aQuelqueChoseAMontrer || vue !== 'frise';
|
||
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
|
||
|
||
peindreFiltres();
|
||
peindreOnglets();
|
||
|
||
if (!aDesProjets) {
|
||
echelle = null;
|
||
return;
|
||
}
|
||
|
||
if (vue === 'mois') {
|
||
rendreMois(refs.vueMois, planning, { tagsActifs });
|
||
return;
|
||
}
|
||
|
||
elargirPourContenir();
|
||
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
|
||
|
||
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
|
||
// sur la frise doit se voir dans les champs.
|
||
if (curseur?.phase) {
|
||
const trouve = localiser(curseur.projet, curseur.phase);
|
||
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
|
||
}
|
||
|
||
const idProjetOuvert = panneauProjet.projetOuvert();
|
||
if (idProjetOuvert) {
|
||
const projet = planning.projects.find((p) => p.id === idProjetOuvert);
|
||
if (projet) panneauProjet.rafraichir(projet);
|
||
}
|
||
}
|
||
|
||
function localiser(idProjet, idPhase) {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (!projet) return null;
|
||
const phase = projet.phases.find((p) => p.id === idPhase);
|
||
return phase ? { projet, phase } : null;
|
||
}
|
||
|
||
function afficherEtat(etat, detail) {
|
||
const messages = {
|
||
inactif: '',
|
||
'en-cours': 'Enregistrement…',
|
||
enregistre: 'Enregistré',
|
||
erreur: detail || 'Erreur d’enregistrement',
|
||
};
|
||
refs.etat.textContent = messages[etat] ?? '';
|
||
refs.etat.dataset.etat = etat;
|
||
refs.etat.title = etat === 'erreur' ? detail || '' : '';
|
||
}
|
||
|
||
function afficherAlerte(message) {
|
||
refs.alerteMessage.textContent = message;
|
||
refs.alerte.hidden = false;
|
||
// L'alerte recouvre déjà la page, mais laisser une frise vide « visible »
|
||
// derrière elle brouillerait le diagnostic si le style venait à changer.
|
||
refs.frise.hidden = true;
|
||
refs.messageVide.hidden = true;
|
||
refs.messageFiltre.hidden = true;
|
||
refs.filtres.hidden = true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filtre par tags
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Reconstruit la barre de filtres depuis les tags réellement employés.
|
||
*
|
||
* La liste n'est pas un référentiel à tenir à jour : elle se déduit des projets,
|
||
* si bien qu'un tag apparaît dès qu'on le pose et disparaît dès qu'il n'orne
|
||
* plus rien.
|
||
*/
|
||
function peindreFiltres() {
|
||
const tags = tousLesTags(planning);
|
||
refs.filtres.hidden = tags.length === 0;
|
||
refs.effacerFiltres.hidden = tagsActifs.length === 0;
|
||
|
||
const clesActives = new Set(tagsActifs.map(cleTag));
|
||
refs.filtresListe.replaceChildren(
|
||
...tags.map((tag) => {
|
||
const bouton = document.createElement('button');
|
||
bouton.type = 'button';
|
||
bouton.className = 'tag';
|
||
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
|
||
bouton.textContent = tag;
|
||
|
||
const actif = clesActives.has(cleTag(tag));
|
||
bouton.setAttribute('aria-pressed', String(actif));
|
||
bouton.title = actif ? `Retirer « ${tag} » du filtre` : `N'afficher que les projets « ${tag} »`;
|
||
bouton.addEventListener('click', () => basculerTag(tag));
|
||
|
||
return bouton;
|
||
})
|
||
);
|
||
}
|
||
|
||
/** Ajoute le tag au filtre, ou l'en retire s'il y était déjà. */
|
||
function basculerTag(tag) {
|
||
const cle = cleTag(tag);
|
||
const restants = tagsActifs.filter((actif) => cleTag(actif) !== cle);
|
||
tagsActifs =
|
||
restants.length === tagsActifs.length ? normaliserTags([...tagsActifs, tag]) : restants;
|
||
dessiner();
|
||
}
|
||
|
||
function effacerFiltres() {
|
||
tagsActifs = [];
|
||
dessiner();
|
||
}
|
||
|
||
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
||
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bascule entre la frise et la vue par mois
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function peindreOnglets() {
|
||
for (const onglet of refs.onglets.querySelectorAll('[data-vue]')) {
|
||
onglet.setAttribute('aria-pressed', String(onglet.dataset.vue === vue));
|
||
}
|
||
}
|
||
|
||
function basculerVue(suivante) {
|
||
if (suivante === vue || !suivante) return;
|
||
|
||
if (vue === 'frise') defilementFrise = refs.frise.scrollLeft;
|
||
vue = suivante;
|
||
|
||
// Les deux panneaux modifient une phase ou un projet ; la vue par mois se
|
||
// lit, elle ne s'édite pas. Les laisser ouverts par-dessus une liste où plus
|
||
// rien ne désigne leur objet en ferait des fenêtres orphelines.
|
||
panneau.fermer();
|
||
panneauProjet.fermer();
|
||
|
||
dessiner();
|
||
|
||
// Les deux restaurations sont synchrones : l'élément vient d'être démasqué,
|
||
// et lire ou écrire `scroll*` force le calcul de mise en page dont elles ont
|
||
// besoin.
|
||
if (vue === 'frise') refs.frise.scrollLeft = defilementFrise;
|
||
else revelerMoisCourant(refs.vueMois);
|
||
}
|
||
|
||
refs.onglets.addEventListener('click', (evenement) => {
|
||
const onglet = evenement.target.closest('[data-vue]');
|
||
if (onglet) basculerVue(onglet.dataset.vue);
|
||
});
|
||
|
||
// La vue par mois n'a ni barres ni poignées. Ses deux seuls gestes ne touchent
|
||
// pas au planning : filtrer sur un tag, et déplier les notes d'une phase.
|
||
refs.vueMois.addEventListener('click', (evenement) => {
|
||
const cible = evenement.target.closest('[data-action]');
|
||
if (!cible) return;
|
||
|
||
if (cible.dataset.action === 'tag') {
|
||
basculerTag(cible.dataset.tag);
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'notes') basculerNotes(cible.closest('.mois-entree'));
|
||
});
|
||
|
||
// Toute la ligne déplie, pas seulement son triangle : viser une cible de dix
|
||
// pixels pour lire un commentaire est une exigence inutile, et il n'y a rien
|
||
// d'autre à faire d'un clic sur une ligne d'une vue qui ne s'édite pas. Le
|
||
// gestionnaire ci-dessus s'occupe des tags, qui eux ont leur propre effet.
|
||
refs.vueMois.addEventListener('click', (evenement) => {
|
||
if (evenement.target.closest('[data-action]')) return;
|
||
const ligne = evenement.target.closest('.mois-entree--commentee');
|
||
if (ligne) basculerNotes(ligne);
|
||
});
|
||
|
||
/**
|
||
* Ouvre ou referme les notes d'une ligne.
|
||
*
|
||
* La bascule est appliquée directement au DOM, sans repasser par `dessiner` :
|
||
* ce qui est déplié relève de l'écran, pas du planning. Un rendu complet
|
||
* replierait d'ailleurs tout le reste au passage.
|
||
*/
|
||
function basculerNotes(ligne) {
|
||
const bouton = ligne.querySelector('.mois-entree__plier');
|
||
const deplie = bouton.getAttribute('aria-expanded') !== 'true';
|
||
bouton.setAttribute('aria-expanded', String(deplie));
|
||
bouton.title = deplie ? 'Masquer les notes' : 'Afficher les notes';
|
||
ligne.querySelector('.mois-entree__notes').hidden = !deplie;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Largeur de la colonne des libellés
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* La colonne héberge le nom du projet, ses tags, sa dérive et trois commandes.
|
||
* Aucune largeur fixe ne convient à la fois à « Site web » et à « Refonte du
|
||
* portail fournisseurs — lot 2 » : c'est donc à l'utilisateur de trancher.
|
||
*
|
||
* Contrairement à la fenêtre temporelle et au filtre par tags, cette largeur
|
||
* **est** conservée — dans `localStorage`, pas dans le planning. C'est un
|
||
* réglage d'affichage propre à un écran, non une donnée à synchroniser entre
|
||
* machines : le stocker dans le fichier imposerait à un portable la largeur
|
||
* choisie sur un 27 pouces. Et sans persistance, une poignée qu'il faut
|
||
* réajuster à chaque ouverture ne sert à rien.
|
||
*/
|
||
// `defaut` doit rester en accord avec `--largeur-libelles` dans style.css.
|
||
const LARGEUR_LIBELLES = { min: 150, max: 560, defaut: 300 };
|
||
const CLE_LARGEUR_LIBELLES = 'frise.largeurLibelles';
|
||
|
||
/**
|
||
* Largeur courante, tenue ici plutôt que lue sur le DOM : `offsetWidth` vaut
|
||
* zéro tant que la frise est masquée — planning vide, ou filtre qui ne retient
|
||
* rien —, et le premier glisser repartirait alors de zéro.
|
||
*/
|
||
let largeurLibelles = LARGEUR_LIBELLES.defaut;
|
||
|
||
function appliquerLargeurLibelles(largeur) {
|
||
const borne = Math.min(LARGEUR_LIBELLES.max, Math.max(LARGEUR_LIBELLES.min, Math.round(largeur)));
|
||
largeurLibelles = borne;
|
||
document.documentElement.style.setProperty('--largeur-libelles', `${borne}px`);
|
||
try {
|
||
localStorage.setItem(CLE_LARGEUR_LIBELLES, String(borne));
|
||
} catch {
|
||
// Mode privé, quota saturé : la largeur vaut pour la session, sans plus.
|
||
}
|
||
return borne;
|
||
}
|
||
|
||
function restaurerLargeurLibelles() {
|
||
let memorisee = null;
|
||
try {
|
||
memorisee = localStorage.getItem(CLE_LARGEUR_LIBELLES);
|
||
} catch {
|
||
// Lecture refusée : on garde la valeur de la feuille de style.
|
||
}
|
||
const largeur = Number(memorisee);
|
||
if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurLibelles(largeur);
|
||
}
|
||
|
||
restaurerLargeurLibelles();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Largeur du panneau
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Le panneau porte désormais les notes, qui prennent toute sa hauteur restante
|
||
* et se rédigent en markdown. Trois cent quarante pixels suffisaient à des
|
||
* champs de date et à quatre lignes de texte ; ils ne suffisent plus à une liste
|
||
* à puces imbriquée, où chaque niveau mange une indentation.
|
||
*
|
||
* D'où la même poignée qu'à la colonne des libellés (décision 18) : même geste,
|
||
* même double-clic de remise à zéro, même mémorisation dans `localStorage`. Une
|
||
* largeur unique pour les deux panneaux, parce que ce qu'on élargit est le
|
||
* volet — l'espace où l'on écrit —, pas l'un ou l'autre de ses contenus.
|
||
*/
|
||
// `defaut` doit rester en accord avec `--largeur-panneau` dans style.css.
|
||
const LARGEUR_PANNEAU = { min: 300, max: 900, defaut: 380 };
|
||
const CLE_LARGEUR_PANNEAU = 'frise.largeurPanneau';
|
||
|
||
/**
|
||
* Deux largeurs plutôt qu'une : celle que l'utilisateur a demandée, et celle
|
||
* qui tient réellement dans la fenêtre.
|
||
*
|
||
* Le panneau ne peut pas couvrir tout l'écran — il laisse la frise vivante
|
||
* derrière lui (décision 17) —, et une largeur choisie sur un 27 pouces doit
|
||
* donc être rabotée sur un portable. Mais seulement rabotée : sans cette
|
||
* distinction, réduire un instant la fenêtre écraserait la largeur mémorisée
|
||
* par sa valeur contrainte, et l'agrandir de nouveau ne la rendrait pas.
|
||
*/
|
||
let largeurPanneauVoulue = LARGEUR_PANNEAU.defaut;
|
||
let largeurPanneau = LARGEUR_PANNEAU.defaut;
|
||
|
||
function appliquerLargeurPanneau(largeur, { memoriser = true } = {}) {
|
||
if (memoriser) {
|
||
largeurPanneauVoulue = Math.min(
|
||
LARGEUR_PANNEAU.max,
|
||
Math.max(LARGEUR_PANNEAU.min, Math.round(largeur))
|
||
);
|
||
try {
|
||
localStorage.setItem(CLE_LARGEUR_PANNEAU, String(largeurPanneauVoulue));
|
||
} catch {
|
||
// Mode privé, quota saturé : la largeur vaut pour la séance, sans plus.
|
||
}
|
||
}
|
||
|
||
const plafond = Math.max(LARGEUR_PANNEAU.min, window.innerWidth - 80);
|
||
largeurPanneau = Math.min(largeurPanneauVoulue, plafond);
|
||
document.documentElement.style.setProperty('--largeur-panneau', `${largeurPanneau}px`);
|
||
return largeurPanneau;
|
||
}
|
||
|
||
function restaurerLargeurPanneau() {
|
||
let memorisee = null;
|
||
try {
|
||
memorisee = localStorage.getItem(CLE_LARGEUR_PANNEAU);
|
||
} catch {
|
||
// Lecture refusée : on garde la valeur de la feuille de style.
|
||
}
|
||
const largeur = Number(memorisee);
|
||
if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurPanneau(largeur);
|
||
}
|
||
|
||
restaurerLargeurPanneau();
|
||
|
||
/**
|
||
* Installe une poignée de redimensionnement.
|
||
*
|
||
* Deux réglages emploient le même geste, à deux détails près : le sens dans
|
||
* lequel tirer élargit, et l'endroit où l'on range le résultat. Le reste — la
|
||
* capture du pointeur, la classe qui fige le curseur pendant le glisser, le
|
||
* double-clic qui remet à la valeur d'usine, les flèches au clavier — est
|
||
* strictement le même, et n'a pas à être écrit deux fois.
|
||
*
|
||
* @param {object} options
|
||
* - sens : +1 si tirer vers la droite élargit (la colonne, ancrée à gauche),
|
||
* −1 sinon (le panneau, ancré à droite).
|
||
*/
|
||
function installerPoignee({ poignees, sens, largeur, appliquer, defaut }) {
|
||
for (const poignee of poignees) {
|
||
let depart = null;
|
||
|
||
poignee.addEventListener('pointerdown', (evenement) => {
|
||
if (evenement.button !== 0) return;
|
||
depart = { x: evenement.clientX, largeur: largeur() };
|
||
poignee.setPointerCapture(evenement.pointerId);
|
||
poignee.dataset.glisse = 'true';
|
||
document.body.classList.add('glisse-en-cours');
|
||
evenement.preventDefault();
|
||
});
|
||
|
||
poignee.addEventListener('pointermove', (evenement) => {
|
||
if (!depart) return;
|
||
appliquer(depart.largeur + sens * (evenement.clientX - depart.x));
|
||
});
|
||
|
||
const terminer = (evenement) => {
|
||
if (!depart) return;
|
||
depart = null;
|
||
poignee.releasePointerCapture?.(evenement.pointerId);
|
||
delete poignee.dataset.glisse;
|
||
document.body.classList.remove('glisse-en-cours');
|
||
};
|
||
|
||
poignee.addEventListener('pointerup', terminer);
|
||
poignee.addEventListener('pointercancel', terminer);
|
||
|
||
poignee.addEventListener('dblclick', () => appliquer(defaut));
|
||
|
||
// Les flèches ajustent aussi la largeur quand la poignée a le focus.
|
||
// `stopPropagation` est indispensable : sans lui, le gestionnaire posé plus
|
||
// bas sur `document` décalerait en même temps la phase sélectionnée.
|
||
poignee.addEventListener('keydown', (evenement) => {
|
||
const pas = evenement.key === 'ArrowLeft' ? -10 : evenement.key === 'ArrowRight' ? 10 : 0;
|
||
if (pas === 0) return;
|
||
evenement.preventDefault();
|
||
evenement.stopPropagation();
|
||
appliquer(largeur() + sens * pas);
|
||
});
|
||
}
|
||
}
|
||
|
||
installerPoignee({
|
||
poignees: [$('poignee-colonne')],
|
||
sens: 1,
|
||
largeur: () => largeurLibelles,
|
||
appliquer: appliquerLargeurLibelles,
|
||
defaut: LARGEUR_LIBELLES.defaut,
|
||
});
|
||
|
||
installerPoignee({
|
||
poignees: document.querySelectorAll('[data-poignee-panneau]'),
|
||
sens: -1,
|
||
largeur: () => largeurPanneau,
|
||
appliquer: appliquerLargeurPanneau,
|
||
defaut: LARGEUR_PANNEAU.defaut,
|
||
});
|
||
|
||
// Rétrécir la fenêtre sous la largeur du panneau le laisserait déborder : on
|
||
// rejoue le plafond sans toucher à la largeur mémorisée, qu'on retrouvera
|
||
// intacte en rouvrant la fenêtre en grand.
|
||
window.addEventListener('resize', () =>
|
||
appliquerLargeurPanneau(largeurPanneauVoulue, { memoriser: false })
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fenêtre temporelle et défilement sans fin
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Fenêtre de départ : les phases existantes, avec une marge de chaque côté. */
|
||
function fenetreInitiale() {
|
||
const bornes = bornesPlanning(planning);
|
||
if (!bornes) {
|
||
const jour = aujourdhui();
|
||
return { debut: ajouterJours(jour, -MARGE_JOURS), fin: ajouterJours(jour, MARGE_JOURS * 2) };
|
||
}
|
||
return {
|
||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Élargit la fenêtre si une phase est sortie de son cadre, sans jamais la
|
||
* rétrécir : le terrain déjà exploré au défilement doit rester accessible.
|
||
*/
|
||
function elargirPourContenir() {
|
||
if (!fenetre) fenetre = fenetreInitiale();
|
||
|
||
const bornes = bornesPlanning(planning);
|
||
if (!bornes) return;
|
||
|
||
const voulu = {
|
||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||
};
|
||
if (versUTC(voulu.debut) < versUTC(fenetre.debut)) fenetre.debut = voulu.debut;
|
||
if (versUTC(voulu.fin) > versUTC(fenetre.fin)) fenetre.fin = voulu.fin;
|
||
}
|
||
|
||
/** Bornes au-delà desquelles on cesse d'élargir. */
|
||
function porteeMaximale() {
|
||
const bornes = bornesPlanning(planning) ?? { start: aujourdhui(), end: aujourdhui() };
|
||
const jours = 365 * PORTEE_MAX_ANNEES;
|
||
return {
|
||
min: ajouterJours(bornes.start, -jours),
|
||
max: ajouterJours(bornes.end, jours),
|
||
};
|
||
}
|
||
|
||
// Vrai pendant qu'on élargit, pour que l'ajustement de `scrollLeft` ne relance
|
||
// pas immédiatement ce même gestionnaire.
|
||
let elargissementEnCours = false;
|
||
|
||
/**
|
||
* Élargit la fenêtre quand le défilement approche d'un bord, ce qui donne
|
||
* l'impression d'une frise sans fin.
|
||
*
|
||
* Élargir vers la gauche déplace l'origine de l'échelle : tout le contenu se
|
||
* décale vers la droite. On compense `scrollLeft` de la même quantité, sinon la
|
||
* vue sauterait en arrière à chaque élargissement.
|
||
*/
|
||
refs.frise.addEventListener('scroll', () => {
|
||
if (elargissementEnCours || !echelle || refs.frise.hidden) return;
|
||
|
||
const zone = refs.frise;
|
||
const portee = porteeMaximale();
|
||
const resteADroite = zone.scrollWidth - zone.scrollLeft - zone.clientWidth;
|
||
|
||
if (zone.scrollLeft < SEUIL_ELARGISSEMENT && versUTC(fenetre.debut) > versUTC(portee.min)) {
|
||
elargissementEnCours = true;
|
||
fenetre.debut = ajouterJours(fenetre.debut, -PAS_ELARGISSEMENT);
|
||
dessiner();
|
||
zone.scrollLeft += PAS_ELARGISSEMENT * PIXELS_PAR_JOUR;
|
||
elargissementEnCours = false;
|
||
return;
|
||
}
|
||
|
||
if (resteADroite < SEUIL_ELARGISSEMENT && versUTC(fenetre.fin) < versUTC(portee.max)) {
|
||
elargissementEnCours = true;
|
||
fenetre.fin = ajouterJours(fenetre.fin, PAS_ELARGISSEMENT);
|
||
dessiner();
|
||
elargissementEnCours = false;
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interactions de la frise
|
||
// ---------------------------------------------------------------------------
|
||
|
||
installerGlisser(refs.couloirs, {
|
||
echelle: () => echelle,
|
||
trouverPhase(idProjet, idPhase) {
|
||
return localiser(idProjet, idPhase)?.phase ?? null;
|
||
},
|
||
surDeplacement(idProjet, idPhase, phase) {
|
||
// `phase` vaut null quand le geste a été annulé ou n'a rien changé : on
|
||
// redessine pour remettre la barre à sa place, sans rien sauvegarder.
|
||
if (!phase) {
|
||
dessiner();
|
||
return;
|
||
}
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) =>
|
||
remplacerPhase(projet, idPhase, () => phase)
|
||
)
|
||
);
|
||
},
|
||
surClic(idProjet, idPhase) {
|
||
ouvrirPhase(idProjet, idPhase);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* @param {object} options
|
||
* - nomAEcraser : transmis au panneau, pour une phase fraîchement créée.
|
||
* - reveler : recentre la frise si la barre n'est pas dans le champ de
|
||
* vision. Utile depuis la colonne des libellés, où l'on peut cliquer une
|
||
* phase dont la barre est à des mois de la vue courante — sans cela, le
|
||
* panneau s'ouvre sur une phase qu'on ne voit pas, et rien n'indique de
|
||
* quel côté elle se trouve.
|
||
*/
|
||
function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) {
|
||
const trouve = localiser(idProjet, idPhase);
|
||
if (!trouve) return;
|
||
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
|
||
// l'autre.
|
||
panneauProjet.fermer();
|
||
curseur = { projet: idProjet, phase: idPhase };
|
||
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||
dessiner();
|
||
// Après l'ouverture du panneau : il recouvre le bord droit de la frise, et
|
||
// `phaseVisible` doit en tenir compte.
|
||
if (reveler && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
|
||
}
|
||
|
||
/**
|
||
* La barre de la phase est-elle dans la portion réellement visible de la frise ?
|
||
*
|
||
* Deux occlusions à retrancher : la colonne des libellés, `sticky` à gauche, et
|
||
* le panneau de détail quand il est ouvert, posé sur le bord droit.
|
||
*/
|
||
function phaseVisible(phase) {
|
||
if (!echelle || refs.frise.hidden) return true;
|
||
|
||
const gauche = refs.frise.scrollLeft + largeurLibelles;
|
||
const droite =
|
||
refs.frise.scrollLeft +
|
||
refs.frise.clientWidth -
|
||
(refs.panneau.hidden ? 0 : refs.panneau.offsetWidth);
|
||
|
||
const debut = largeurLibelles + echelle.x(phase.start);
|
||
const fin = largeurLibelles + echelle.x(phase.end) + PIXELS_PAR_JOUR;
|
||
|
||
return fin > gauche && debut < droite;
|
||
}
|
||
|
||
// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase.
|
||
refs.libelles.addEventListener('click', (evenement) => {
|
||
const cible = evenement.target.closest('[data-action]');
|
||
|
||
if (!cible) {
|
||
const ligne = evenement.target.closest('.libelle-phase');
|
||
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase, { reveler: true });
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'tag') {
|
||
basculerTag(cible.dataset.tag);
|
||
return;
|
||
}
|
||
|
||
const idProjet = cible.dataset.projet;
|
||
|
||
if (cible.dataset.action === 'parametres') {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (projet) ouvrirProjet(projet);
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'ajouter') {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (projet) ajouterUnePhase(projet);
|
||
return;
|
||
}
|
||
|
||
if (cible.dataset.action === 'plier') {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => ({
|
||
...projet,
|
||
collapsed: !projet.collapsed,
|
||
}))
|
||
);
|
||
} else if (cible.dataset.action === 'masquer') {
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (projet) => ({ ...projet, hidden: !projet.hidden }))
|
||
);
|
||
} else if (cible.dataset.action === 'menu') {
|
||
ouvrirMenuProjet(idProjet, cible);
|
||
}
|
||
});
|
||
|
||
// Clic droit sur un libellé de projet : même menu, ancré sur le nom.
|
||
refs.libelles.addEventListener('contextmenu', (evenement) => {
|
||
const ligne = evenement.target.closest('.libelle-projet');
|
||
if (!ligne) return;
|
||
evenement.preventDefault();
|
||
const bouton = ligne.querySelector('[data-action="menu"]');
|
||
ouvrirMenuProjet(bouton.dataset.projet, bouton);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Menu d'un projet
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function ouvrirMenuProjet(idProjet, ancre) {
|
||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||
if (!projet) return;
|
||
|
||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||
|
||
menu.ouvrir(ancre, [
|
||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||
{
|
||
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
||
separateurAvant: true,
|
||
action: () =>
|
||
majPlanning(
|
||
remplacerProjet(planning, idProjet, (p) => ({ ...p, collapsed: !p.collapsed }))
|
||
),
|
||
},
|
||
{
|
||
libelle: projet.hidden ? 'Afficher sur la frise' : 'Masquer de la frise',
|
||
action: () =>
|
||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||
},
|
||
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
|
||
{
|
||
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||
separateurAvant: true,
|
||
desactive: projet.phases.length === 0,
|
||
action: () => figerLaReference(projet, aUneReference),
|
||
},
|
||
{
|
||
libelle: 'Retirer la référence',
|
||
desactive: !aUneReference,
|
||
action: () => retirerLaReference(projet),
|
||
},
|
||
{
|
||
libelle: 'Supprimer le projet',
|
||
separateurAvant: true,
|
||
danger: true,
|
||
action: () => supprimerLeProjet(projet),
|
||
},
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Ajoute une phase et ouvre son panneau.
|
||
*
|
||
* Il n'y a **pas** de commande distincte pour créer un jalon : un jalon n'est
|
||
* pas un autre objet, c'est une phase dont la fin rejoint le début — une case à
|
||
* cocher du panneau, au même titre que son statut. Proposer deux entrées
|
||
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
|
||
* bascule reste possible à tout moment ensuite.
|
||
*/
|
||
function ajouterUnePhase(projet, { debut = null } = {}) {
|
||
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
|
||
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
|
||
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||
// présélectionné : l'utilisateur tape directement par-dessus.
|
||
//
|
||
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
|
||
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
|
||
// « après » ne s'expriment que par une date, jamais par un rang.
|
||
const derniere = projet.phases[projet.phases.length - 1];
|
||
if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||
|
||
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||
|
||
majPlanning(
|
||
remplacerProjet(planning, projet.id, (p) => ({
|
||
...ajouterPhase(p, phase),
|
||
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
|
||
}))
|
||
);
|
||
ouvrirPhase(projet.id, phase.id, { nomAEcraser: true });
|
||
|
||
// La frise défile librement bien au-delà des phases existantes : sans ce
|
||
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
|
||
// écran, et l'ajout semblerait n'avoir rien fait.
|
||
centrerSur(phase.start);
|
||
}
|
||
|
||
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||
function ouvrirProjet(projet, options = {}) {
|
||
panneau.fermer();
|
||
// Ouvrir les paramètres pose le curseur sur la ligne du projet, à la souris
|
||
// comme au clavier : le curseur désigne toujours ce que le volet montre.
|
||
curseur = { projet: projet.id, phase: null };
|
||
panneauProjet.ouvrir(projet, options);
|
||
dessiner();
|
||
}
|
||
|
||
/**
|
||
* Éléments dont le propre gestionnaire ouvre ou rouvre un panneau. Un clic
|
||
* dessus ne doit surtout pas passer par la fermeture générale ci-dessous : elle
|
||
* redessine la frise, ce qui détacherait du DOM l'élément visé avant que son
|
||
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
|
||
*/
|
||
const CIBLES_OUVRANTES =
|
||
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
|
||
|
||
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
|
||
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
|
||
|
||
/**
|
||
* Un clic dans le vide referme le panneau ouvert.
|
||
*
|
||
* C'est ce que remplace le voile supprimé en décision 17 : celui-ci fermait au
|
||
* clic à côté, mais au prix d'une frise inerte. En filtrant les cibles plutôt
|
||
* qu'en interposant un calque, on garde les deux — la frise reste manipulable,
|
||
* et cliquer à côté ferme quand même.
|
||
*/
|
||
document.addEventListener('pointerdown', (evenement) => {
|
||
if (evenement.button !== 0) return;
|
||
if (evenement.target.closest(CIBLES_PROTEGEES)) return;
|
||
if (evenement.target.closest(CIBLES_OUVRANTES)) return;
|
||
|
||
panneau.fermer();
|
||
panneauProjet.fermer();
|
||
});
|
||
|
||
async function figerLaReference(projet, dejaFigee) {
|
||
const ok = await demanderConfirmation({
|
||
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
|
||
messageTexte: dejaFigee
|
||
? `Le planning actuel de « ${projet.name} » deviendra la nouvelle référence. ` +
|
||
'La dérive affichée repartira de zéro.'
|
||
: `Le planning actuel de « ${projet.name} » sera mémorisé comme référence. ` +
|
||
'La frise affichera ensuite son écart avec cette référence.',
|
||
libelleValider: dejaFigee ? 'Refiger' : 'Figer',
|
||
});
|
||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => figerReference(p)));
|
||
}
|
||
|
||
async function retirerLaReference(projet) {
|
||
const ok = await demanderConfirmation({
|
||
titreTexte: 'Retirer la référence',
|
||
messageTexte:
|
||
`Le planning de référence de « ${projet.name} » sera oublié, ` +
|
||
'et la frise cessera d’afficher sa dérive.',
|
||
libelleValider: 'Retirer',
|
||
danger: true,
|
||
});
|
||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p)));
|
||
}
|
||
|
||
/** Confirmation partagée par le menu du projet et son panneau. */
|
||
function confirmerSuppressionProjet(projet) {
|
||
const nombre = projet.phases.length;
|
||
return demanderConfirmation({
|
||
titreTexte: 'Supprimer le projet',
|
||
messageTexte:
|
||
`« ${projet.name} »` +
|
||
(nombre ? ` et ses ${nombre} phase${nombre > 1 ? 's' : ''}` : '') +
|
||
' seront définitivement supprimés du planning.',
|
||
libelleValider: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
}
|
||
|
||
async function supprimerLeProjet(projet) {
|
||
if (!(await confirmerSuppressionProjet(projet))) return;
|
||
|
||
if (curseur?.projet === projet.id) panneau.fermer();
|
||
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
|
||
|
||
// Le curseur va se retrouver sur une ligne qui n'existe plus : on le pose sur
|
||
// le projet suivant — ou le précédent s'il n'y en a pas — plutôt que de
|
||
// l'effacer, pour pouvoir enchaîner les `dd` sans repasser par la souris.
|
||
//
|
||
// Avant `majPlanning`, pas après : le voisin survit à la suppression, donc
|
||
// `normaliserCurseur` le laissera en place, et un seul rendu suffit.
|
||
if (curseur?.projet === projet.id) curseur = projetVoisin(projet.id);
|
||
|
||
majPlanning(supprimerProjet(planning, projet.id));
|
||
}
|
||
|
||
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
|
||
function projetVoisin(idProjet) {
|
||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||
const index = retenus.findIndex((p) => p.id === idProjet);
|
||
if (index === -1) return null;
|
||
const voisin = retenus[index + 1] ?? retenus[index - 1];
|
||
return voisin ? { projet: voisin.id, phase: null } : null;
|
||
}
|
||
|
||
/**
|
||
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||
* valider.
|
||
*
|
||
* Même parti pris que pour l'ajout d'une phase (docs/decisions.md, section 13) :
|
||
* le projet est créé tout de suite avec un nom provisoire, et le panneau s'ouvre
|
||
* avec ce nom présélectionné — on tape simplement par-dessus. Un dialogue de
|
||
* plus n'apportait rien, et il présentait les mêmes champs sous une autre forme
|
||
* que celle qui sert ensuite à les modifier.
|
||
*/
|
||
function nouveauProjet() {
|
||
// Un projet naît sans phase : il n'apparaîtrait nulle part dans une liste qui
|
||
// ne montre que des tâches et des jalons, et le bouton semblerait sans effet.
|
||
// On revient donc à la frise, où le nouveau couloir se voit — et où l'on
|
||
// pourra lui ajouter ses phases.
|
||
basculerVue('frise');
|
||
|
||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
||
// regardant que les projets « client », c'en est presque toujours un — et
|
||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||
ouvrirProjet(projet, { nomAEcraser: true });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Barre d'outils
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// « Aujourd'hui » veut dire la même chose dans les deux vues — ramène-moi à la
|
||
// date du jour — même si le geste diffère : un défilement horizontal sur la
|
||
// frise, vertical dans la liste.
|
||
function allerAujourdhui() {
|
||
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
|
||
else centrerSur(aujourdhui());
|
||
}
|
||
|
||
$('aller-aujourdhui').addEventListener('click', allerAujourdhui);
|
||
$('ouvrir-aide').addEventListener('click', () => $('dialogue-aide').showModal());
|
||
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
||
$('premier-projet').addEventListener('click', nouveauProjet);
|
||
|
||
/**
|
||
* Amène une date au centre de la vue, instantanément.
|
||
*
|
||
* Volontairement pas de défilement fluide : la frise s'élargit au défilement et
|
||
* peut atteindre plusieurs dizaines de milliers de pixels. Une animation sur une
|
||
* telle distance est lente, et le gestionnaire de défilement l'interromprait en
|
||
* élargissant la fenêtre au passage près d'un bord.
|
||
*/
|
||
function centrerSur(date) {
|
||
if (!echelle) return;
|
||
|
||
// `scrollLeft` porte sur toute la grille, dont la première colonne est celle
|
||
// des libellés. Comme elle est `sticky`, elle recouvre en permanence les
|
||
// premiers pixels de la vue : le centre utile des couloirs est donc décalé
|
||
// d'une demi-largeur de colonne par rapport au centre de la frise.
|
||
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
|
||
|
||
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
||
}
|
||
|
||
// Molette + Maj : défilement horizontal, geste habituel sur une frise.
|
||
refs.frise.addEventListener(
|
||
'wheel',
|
||
(evenement) => {
|
||
if (!evenement.shiftKey || evenement.ctrlKey) return;
|
||
evenement.preventDefault();
|
||
refs.frise.scrollLeft += evenement.deltaY;
|
||
},
|
||
{ passive: false }
|
||
);
|
||
|
||
// Flèches : ajustement fin de la phase du curseur, au jour près.
|
||
document.addEventListener('keydown', (evenement) => {
|
||
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
|
||
if (!curseur?.phase) return;
|
||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||
|
||
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
||
if (pas === 0) return;
|
||
|
||
evenement.preventDefault();
|
||
const trouve = localiser(curseur.projet, curseur.phase);
|
||
if (!trouve) return;
|
||
|
||
// Maj + flèche étire la phase au lieu de la déplacer.
|
||
const suivante = evenement.shiftKey
|
||
? etirerAuClavier(trouve.phase, pas)
|
||
: decalerAuClavier(trouve.phase, pas);
|
||
|
||
majPlanning(
|
||
remplacerProjet(planning, curseur.projet, (projet) =>
|
||
remplacerPhase(projet, curseur.phase, () => suivante)
|
||
)
|
||
);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Curseur clavier
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Lignes de la frise, dans l'ordre où elles s'affichent.
|
||
*
|
||
* Même énumération que `construireLibelles` (timeline.js) : une ligne par projet
|
||
* retenu par le filtre, suivie de ses phases quand il est déplié et non masqué.
|
||
* Les deux doivent rester d'accord, le rang d'une ligne ici servant d'indice
|
||
* dans les enfants de la colonne pour amener le défilement dessus.
|
||
*/
|
||
function lignesFrise() {
|
||
const lignes = [];
|
||
|
||
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
|
||
lignes.push({ projet: projet.id, phase: null });
|
||
if (projet.hidden || projet.collapsed) continue;
|
||
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
|
||
}
|
||
|
||
return lignes;
|
||
}
|
||
|
||
function memeLigne(a, b) {
|
||
return Boolean(a && b && a.projet === b.projet && a.phase === b.phase);
|
||
}
|
||
|
||
/**
|
||
* Ramène le curseur sur une ligne qui existe encore.
|
||
*
|
||
* Replier un projet, resserrer le filtre ou supprimer une phase peut faire
|
||
* disparaître la ligne sous le curseur. On retombe alors sur la ligne du projet,
|
||
* qui survit à ces trois cas, et on n'efface qu'en dernier recours : perdre sa
|
||
* place est plus coûteux que la voir remonter d'un cran.
|
||
*/
|
||
function normaliserCurseur() {
|
||
if (!curseur) return;
|
||
|
||
const lignes = lignesFrise();
|
||
if (lignes.some((ligne) => memeLigne(ligne, curseur))) return;
|
||
|
||
const surLeProjet = { projet: curseur.projet, phase: null };
|
||
curseur = lignes.some((ligne) => memeLigne(ligne, surLeProjet)) ? surLeProjet : null;
|
||
}
|
||
|
||
/**
|
||
* Pose le curseur sur une ligne, amène la vue dessus, et fait suivre le panneau
|
||
* s'il est ouvert.
|
||
*
|
||
* Ce suivi n'est pas un ornement : les raccourcis agissent sur le curseur, et un
|
||
* volet resté sur une autre phase donnerait deux cibles concurrentes à l'écran —
|
||
* on éditerait dans les champs une phase que `Espace` ou `dd` n'atteindraient
|
||
* pas. Ouvert, le panneau est donc la vue détaillée du curseur, et `j` `k`
|
||
* deviennent le moyen de passer les phases en revue une à une.
|
||
*/
|
||
function poserCurseur(ligne) {
|
||
if (!ligne) return;
|
||
curseur = ligne;
|
||
|
||
if (refs.panneau.hidden && !panneauProjet.projetOuvert()) dessiner();
|
||
else ouvrirSousCurseur();
|
||
|
||
revelerCurseur();
|
||
}
|
||
|
||
/**
|
||
* Ouvre le volet qui correspond à la ligne du curseur : le détail d'une phase,
|
||
* les paramètres d'un projet.
|
||
*
|
||
* @param {boolean} saisir place le curseur de frappe dans le champ « Nom ».
|
||
* Vrai quand la frappe le demande (`Entrée`, `e`), faux quand le volet ne fait
|
||
* que suivre un déplacement du curseur — et la nuance porte tout le clavier :
|
||
* prendre le focus au passage de `j` enverrait la frappe suivante dans le
|
||
* champ, où les raccourcis se taisent. On ne saisirait plus jamais qu'un nom.
|
||
*/
|
||
function ouvrirSousCurseur({ saisir = false } = {}) {
|
||
if (!curseur) return;
|
||
|
||
if (curseur.phase) {
|
||
ouvrirPhase(curseur.projet, curseur.phase, { reveler: true, nomAEcraser: saisir });
|
||
return;
|
||
}
|
||
|
||
const projet = planning.projects.find((p) => p.id === curseur.projet);
|
||
if (projet) ouvrirProjet(projet, { nomAEcraser: saisir });
|
||
}
|
||
|
||
/** Le projet auquel appartient la ligne du curseur, ou `null`. */
|
||
function projetDuCurseur() {
|
||
if (!curseur) return null;
|
||
return planning.projects.find((p) => p.id === curseur.projet) ?? null;
|
||
}
|
||
|
||
/**
|
||
* Amène la ligne du curseur dans le champ de vision.
|
||
*
|
||
* Verticalement toujours : sans cela `j` sortirait de l'écran au bout de quinze
|
||
* lignes. Horizontalement seulement si la barre est entièrement hors cadre —
|
||
* recentrer à chaque pas ferait sauter la frise sous les yeux alors qu'on ne
|
||
* fait que descendre une colonne.
|
||
*/
|
||
function revelerCurseur() {
|
||
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
|
||
|
||
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
|
||
const element = refs.libelles.children[index];
|
||
if (element) revelerLigne(element);
|
||
|
||
if (!curseur.phase) return;
|
||
const trouve = localiser(curseur.projet, curseur.phase);
|
||
if (trouve && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
|
||
}
|
||
|
||
/**
|
||
* Défile le minimum pour qu'une ligne de la colonne soit entièrement visible.
|
||
*
|
||
* Les positions se lisent à l'écran plutôt que dans `offsetTop` : la colonne est
|
||
* `sticky` dans une grille, et son parent de référence n'est pas celui qu'on
|
||
* croirait. La hauteur de l'en-tête est retranchée du haut utile — il est collé
|
||
* par-dessus la frise, et une ligne amenée au ras du bord se glisserait dessous.
|
||
*/
|
||
function revelerLigne(element) {
|
||
const ligne = element.getBoundingClientRect();
|
||
const zone = refs.frise.getBoundingClientRect();
|
||
const hautUtile = zone.top + refs.entete.offsetHeight;
|
||
|
||
if (ligne.top < hautUtile) refs.frise.scrollTop -= hautUtile - ligne.top;
|
||
else if (ligne.bottom > zone.bottom) refs.frise.scrollTop += ligne.bottom - zone.bottom;
|
||
}
|
||
|
||
/**
|
||
* Déplace le curseur de `pas` lignes, sans boucler d'un bout à l'autre.
|
||
*
|
||
* Pas de curseur encore posé : la première frappe le fait apparaître sur la
|
||
* première ligne, plutôt que de ne rien faire et de laisser croire que les
|
||
* raccourcis ne marchent pas.
|
||
*/
|
||
function deplacerCurseur(pas) {
|
||
// La vue par mois n'a pas de curseur : elle se lit, elle ne s'édite pas. `j`
|
||
// et `k` y font donc ce qu'ils font dans un document — ils déroulent la liste.
|
||
if (vue === 'mois') {
|
||
refs.vueMois.scrollTop += pas * PAS_DEFILEMENT_MOIS;
|
||
return;
|
||
}
|
||
|
||
const lignes = lignesFrise();
|
||
if (!lignes.length) return;
|
||
|
||
if (!curseur) {
|
||
poserCurseur(pas < 0 ? lignes[lignes.length - 1] : lignes[0]);
|
||
return;
|
||
}
|
||
|
||
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
|
||
const suivant = Math.min(lignes.length - 1, Math.max(0, index + pas));
|
||
poserCurseur(lignes[suivant]);
|
||
}
|
||
|
||
/**
|
||
* `h` et `l` : la phase précédente ou suivante **du même projet**, sans jamais
|
||
* déborder sur le voisin — c'est ce qui les distingue de `k` et `j`. La ligne du
|
||
* projet est la butée haute : `h` depuis sa première phase y remonte, et `l`
|
||
* depuis elle redescend sur cette première phase.
|
||
*/
|
||
function deplacerDansLeProjet(pas) {
|
||
const projet = projetDuCurseur();
|
||
if (!projet) {
|
||
deplacerCurseur(pas);
|
||
return;
|
||
}
|
||
|
||
const lignes = lignesFrise().filter((ligne) => ligne.projet === projet.id);
|
||
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
|
||
const suivant = index + pas;
|
||
if (suivant < 0 || suivant >= lignes.length) return;
|
||
poserCurseur(lignes[suivant]);
|
||
}
|
||
|
||
/** `{` et `}` : la ligne du projet précédent ou suivant. */
|
||
function deplacerDeProjet(pas) {
|
||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||
if (!retenus.length) return;
|
||
|
||
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
|
||
// Sans curseur, `}` doit atteindre le premier projet et non le second.
|
||
const index = depart === -1 ? (pas > 0 ? 0 : retenus.length - 1) : depart + pas;
|
||
const projet = retenus[Math.min(retenus.length - 1, Math.max(0, index))];
|
||
poserCurseur({ projet: projet.id, phase: null });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Raccourcis à la vim
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Décalage horizontal de la frise, en jours. */
|
||
function defilerDe(jours) {
|
||
if (vue !== 'frise' || refs.frise.hidden) return;
|
||
refs.frise.scrollLeft += jours * PIXELS_PAR_JOUR;
|
||
}
|
||
|
||
/**
|
||
* Pas de `j` et `k` dans la vue par mois, où ils déroulent la liste faute de
|
||
* curseur à déplacer. Trois lignes environ : assez pour avancer, assez peu pour
|
||
* ne pas perdre le fil de ce qu'on lisait.
|
||
*/
|
||
const PAS_DEFILEMENT_MOIS = 96;
|
||
|
||
/**
|
||
* Plie ou déplie le projet du curseur.
|
||
*
|
||
* Depuis une phase, le pli emporte la ligne où l'on se trouve : le curseur monte
|
||
* sur celle du projet, seule position qui reste. Un second `za` déplie, et un
|
||
* `l` ramène dans le projet — l'aller-retour se fait sans la souris.
|
||
*/
|
||
function basculerPli(projet, replie) {
|
||
if (replie) curseur = { projet: projet.id, phase: null };
|
||
majPlanning(remplacerProjet(planning, projet.id, (p) => ({ ...p, collapsed: replie })));
|
||
revelerCurseur();
|
||
}
|
||
|
||
/** `zR` et `zM` : déplie ou plie tous les projets d'un coup. */
|
||
function plierTous(replie) {
|
||
majPlanning({
|
||
...planning,
|
||
projects: planning.projects.map((projet) => ({ ...projet, collapsed: replie })),
|
||
});
|
||
revelerCurseur();
|
||
}
|
||
|
||
/** `Espace` : fait tourner le statut de la phase du curseur. */
|
||
function statutSuivant() {
|
||
if (!curseur?.phase) return;
|
||
const trouve = localiser(curseur.projet, curseur.phase);
|
||
if (!trouve) return;
|
||
|
||
const suivant = STATUTS[(STATUTS.indexOf(trouve.phase.status) + 1) % STATUTS.length];
|
||
majPlanning(
|
||
remplacerProjet(planning, curseur.projet, (projet) =>
|
||
remplacerPhase(projet, curseur.phase, (phase) => modifierPhase(phase, { status: suivant }))
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `a`, `A`, `o`, `O` : quatre façons d'ajouter une phase, qui ne diffèrent que
|
||
* par la date de départ.
|
||
*
|
||
* Les phases sont triées par date (`model.js`) : insérer « avant » ou « après »
|
||
* ne peut donc se dire qu'en dates, pas en rangs. Une nouvelle phase dure vingt
|
||
* jours, comme celle du bouton `+`, et se pose bord à bord avec sa voisine.
|
||
*
|
||
* Depuis la ligne d'un projet, `o` et `O` n'ont pas de phase de référence : ils
|
||
* retombent sur la fin et le début du projet, ce que `A` et `a` font déjà.
|
||
*/
|
||
function ajouterAuClavier(ou) {
|
||
const projet = projetDuCurseur();
|
||
if (!projet) return;
|
||
|
||
const phase = curseur.phase ? localiser(projet.id, curseur.phase)?.phase : null;
|
||
const premiere = projet.phases[0];
|
||
|
||
let debut = null;
|
||
if (ou === 'apres' && phase) debut = ajouterJours(phase.end, 1);
|
||
else if (ou === 'avant' && phase) debut = ajouterJours(phase.start, -21);
|
||
else if (ou === 'debut' && premiere) debut = ajouterJours(premiere.start, -21);
|
||
|
||
ajouterUnePhase(projet, { debut });
|
||
}
|
||
|
||
/** `dd` : supprime la phase du curseur, ou son projet depuis la ligne du projet. */
|
||
async function supprimerAuClavier() {
|
||
const projet = projetDuCurseur();
|
||
if (!projet) return;
|
||
|
||
if (!curseur.phase) {
|
||
await supprimerLeProjet(projet);
|
||
return;
|
||
}
|
||
|
||
const trouve = localiser(projet.id, curseur.phase);
|
||
if (!trouve) return;
|
||
const cible = curseur;
|
||
|
||
const ok = await demanderConfirmation({
|
||
titreTexte: 'Supprimer la phase',
|
||
messageTexte: `« ${trouve.phase.name} » sera définitivement supprimée du planning.`,
|
||
libelleValider: 'Supprimer',
|
||
danger: true,
|
||
});
|
||
if (!ok || !memeLigne(curseur, cible)) return;
|
||
|
||
// Le curseur se pose sur la phase suivante du même projet, ce qui permet
|
||
// d'enchaîner les suppressions sans repasser par la souris. En bout de liste
|
||
// il n'y en a pas, et `normaliserCurseur` le rattrapera sur la ligne du
|
||
// projet. Le déplacement précède la modification : la ligne visée survit, un
|
||
// seul rendu suffit donc à faire les deux.
|
||
const lignes = lignesFrise();
|
||
const suivante = lignes[lignes.findIndex((ligne) => memeLigne(ligne, cible)) + 1];
|
||
if (suivante?.projet === projet.id) curseur = suivante;
|
||
|
||
panneau.fermer();
|
||
majPlanning(remplacerProjet(planning, projet.id, (p) => supprimerPhase(p, cible.phase)));
|
||
revelerCurseur();
|
||
}
|
||
|
||
installerClavier({
|
||
// Déplacements
|
||
ligneSuivante: () => deplacerCurseur(1),
|
||
lignePrecedente: () => deplacerCurseur(-1),
|
||
phaseSuivante: () => deplacerDansLeProjet(1),
|
||
phasePrecedente: () => deplacerDansLeProjet(-1),
|
||
projetSuivant: () => deplacerDeProjet(1),
|
||
projetPrecedent: () => deplacerDeProjet(-1),
|
||
premiereLigne: () => {
|
||
if (vue === 'mois') refs.vueMois.scrollTop = 0;
|
||
else poserCurseur(lignesFrise()[0]);
|
||
},
|
||
derniereLigne: () => {
|
||
if (vue === 'mois') refs.vueMois.scrollTop = refs.vueMois.scrollHeight;
|
||
else poserCurseur(lignesFrise().at(-1));
|
||
},
|
||
|
||
// Plis et cadrage
|
||
basculerPli: () => {
|
||
const projet = projetDuCurseur();
|
||
if (projet) basculerPli(projet, !projet.collapsed);
|
||
},
|
||
deplierTout: () => plierTous(false),
|
||
plierTout: () => plierTous(true),
|
||
recentrer: () => {
|
||
if (!curseur) return;
|
||
const trouve = curseur.phase ? localiser(curseur.projet, curseur.phase) : null;
|
||
if (trouve) centrerSur(trouve.phase.start);
|
||
centrerLigne();
|
||
},
|
||
|
||
// Temps et vues
|
||
moisSuivant: () => defilerDe(30),
|
||
moisPrecedent: () => defilerDe(-30),
|
||
anneeSuivante: () => defilerDe(365),
|
||
anneePrecedente: () => defilerDe(-365),
|
||
allerAujourdhui: () => allerAujourdhui(),
|
||
basculerVue: () => basculerVue(vue === 'frise' ? 'mois' : 'frise'),
|
||
|
||
// Actions sur la ligne du curseur
|
||
editer: () => ouvrirSousCurseur({ saisir: true }),
|
||
ajouterFin: () => ajouterAuClavier('fin'),
|
||
ajouterDebut: () => ajouterAuClavier('debut'),
|
||
ajouterApres: () => ajouterAuClavier('apres'),
|
||
ajouterAvant: () => ajouterAuClavier('avant'),
|
||
nouveauProjet,
|
||
supprimer: () => supprimerAuClavier(),
|
||
statutSuivant,
|
||
|
||
aide: () => $('dialogue-aide').showModal(),
|
||
});
|
||
|
||
/** `zz` : amène la ligne du curseur au milieu de la hauteur visible. */
|
||
function centrerLigne() {
|
||
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
|
||
|
||
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
|
||
const element = refs.libelles.children[index];
|
||
if (!element) return;
|
||
|
||
const ligne = element.getBoundingClientRect();
|
||
const zone = refs.frise.getBoundingClientRect();
|
||
const entete = refs.entete.offsetHeight;
|
||
const centre = zone.top + entete + (zone.height - entete) / 2;
|
||
|
||
refs.frise.scrollTop += ligne.top + ligne.height / 2 - centre;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Démarrage
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function demarrer() {
|
||
try {
|
||
const brut = await stockage.charger();
|
||
planning = validerPlanning(brut);
|
||
} catch (err) {
|
||
afficherAlerte(
|
||
`${err.message}\n\n` +
|
||
'Le planning n’a pas été chargé, et rien ne sera enregistré tant que le ' +
|
||
'problème persiste — les données existantes sont donc intactes.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
dessiner();
|
||
viderAvantFermeture(stockage);
|
||
|
||
if (planning.projects.length) {
|
||
// Sans ce report, la largeur de la zone n'est pas encore connue et le
|
||
// centrage tombe à côté.
|
||
requestAnimationFrame(() => centrerSur(aujourdhui()));
|
||
}
|
||
|
||
console.info(
|
||
`Frise chargée : ${planning.projects.length} projet(s), ` +
|
||
`${planning.projects.reduce((n, p) => n + p.phases.length, 0)} phase(s). ` +
|
||
`Aujourd'hui : ${formaterDateLongue(aujourdhui())}.`
|
||
);
|
||
}
|
||
|
||
demarrer();
|