/** * 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, ETATS, LIBELLES_ETAT, STATUTS, ajouterJours, ajouterPhase, ancreDecalage, aujourdhui, bornesPlanning, cleTag, clotureEcraseDesStatuts, creerPhase, creerProjet, decalerProjet, ecarterProjet, etatProjet, figerReference, formaterDateLongue, libererReference, modifierPhase, normaliserTags, planningVide, pourEcriture, premierJourDuMois, projetFiltre, projetFiltreEtat, reanimerProjet, remplacerPhase, remplacerProjet, rouvrirProjet, supprimerPhase, supprimerProjet, terminerProjet, 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'), etats: $('etats'), etatsListe: $('etats-liste'), 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 = []; /** * États affichés. État de vue lui aussi, jamais enregistré — un réglage qui * survivrait au rechargement rouvrirait le planning amputé sans qu'on se * souvienne pourquoi. * * La sélection est **disjonctive**, à l'inverse de celle des tags : un projet * n'a qu'un état, cocher en élargit donc toujours. Et le défaut n'est pas la * liste vide mais les trois états vivants : le cimetière ne s'ouvre que si on le * demande, sans quoi il encombrerait la colonne dès la première année. */ let etatsActifs = ['considered', 'engaged', 'completed']; /** * 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'), champDates: $('champ-dates'), 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'), etat: $('panneau-projet-etat'), blocHorizon: $('panneau-projet-horizon'), crans: $('panneau-projet-crans'), aideHorizon: $('panneau-projet-horizon-aide'), terminer: $('terminer-projet'), ecarter: $('ecarter-projet'), 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); }, demanderCloture(projet) { return confirmerCloture(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 = projetsRetenus(); 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(); peindreEtats(); peindreOnglets(); if (!aDesProjets) { echelle = null; return; } if (vue === 'mois') { rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs }); return; } elargirPourContenir(); echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs, etatsActifs }); // 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); } } /** * Projets que la frise montre : ceux que les deux filtres laissent passer. * * Centralisé parce que quatre parcours doivent en dire autant — le rendu, mais * aussi les déplacements du curseur clavier, qui sauteraient sur des lignes * invisibles si leur énumération divergeait de celle de `construireLibelles` * (timeline.js). L'œil (`hidden`) n'entre pas ici : un projet masqué garde sa * ligne de libellé, donc sa place dans le curseur. */ function projetsRetenus() { return planning.projects.filter( (projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs) ); } 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(); } // --------------------------------------------------------------------------- // Filtre par état // --------------------------------------------------------------------------- /** * Reconstruit la barre des états. * * Elle ne paraît que si le planning en compte plus d'un : tant que tous les * projets sont engagés — le cas d'un fichier hérité de la version 4 —, elle * n'aurait rien à trier. * * Chaque état affiche son compte. C'est ce qui rend le cimetière consultable * sans lui construire d'écran : « écartés (7) » dit à la fois qu'ils existent et * combien, et un clic les ramène dans la colonne. */ function peindreEtats() { const comptes = new Map(ETATS.map((nom) => [nom, 0])); for (const projet of planning.projects) { comptes.set(etatProjet(projet), comptes.get(etatProjet(projet)) + 1); } const presents = ETATS.filter((nom) => comptes.get(nom) > 0); refs.etats.hidden = presents.length < 2; refs.etatsListe.replaceChildren( ...presents.map((nom) => { const actif = etatsActifs.includes(nom); const bouton = document.createElement('button'); bouton.type = 'button'; bouton.className = 'etat-filtre'; bouton.dataset.etat = nom; bouton.textContent = `${LIBELLES_ETAT[nom]}s (${comptes.get(nom)})`; bouton.setAttribute('aria-pressed', String(actif)); // Tout décocher afficherait le planning entier — l'inverse de ce que // demande le geste. On bloque plutôt le dernier actif, ce qui se voit. bouton.disabled = actif && etatsActifs.length === 1; bouton.title = actif ? bouton.disabled ? 'Au moins un état doit rester affiché.' : `Masquer les projets ${LIBELLES_ETAT[nom].toLowerCase()}s` : `Afficher aussi les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`; bouton.addEventListener('click', () => basculerEtat(nom)); return bouton; }) ); } /** Ajoute l'état à l'affichage, ou l'en retire s'il y était déjà. */ function basculerEtat(nom) { const restants = etatsActifs.filter((actif) => actif !== nom); if (!restants.length) return; etatsActifs = restants.length === etatsActifs.length ? [...etatsActifs, nom] : restants; dessiner(); } refs.effacerFiltres.addEventListener('click', effacerFiltres); // Depuis la barre des tags, « Tout afficher » ne touche qu'aux tags : ouvrir le // cimetière au passage serait une surprise. Depuis l'écran vide, en revanche, on // ne sait pas lequel des deux tamis a tout emporté — on relâche donc les deux. $('effacer-filtres-vide').addEventListener('click', () => { tagsActifs = []; etatsActifs = [...ETATS]; dessiner(); }); // --------------------------------------------------------------------------- // 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); }, trouverProjet(idProjet) { return planning.projects.find((p) => p.id === idProjet) ?? null; }, surDeplacementHorizon(idProjet, horizon) { // Même convention que pour une phase : null veut dire « geste annulé ou sans // effet », on redessine sans rien sauvegarder. if (!horizon) { dessiner(); return; } majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon }))); }, surDecalageProjet(idProjet, jours) { // Même convention que pour une phase : null veut dire « geste annulé ou sans // effet », on redessine sans rien sauvegarder. if (!jours) { dessiner(); return; } majPlanning( remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours)) ); }, surClicProjet(idProjet) { const projet = planning.projects.find((p) => p.id === idProjet); if (projet) ouvrirProjet(projet); }, }); /** * @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); const etat = etatProjet(projet); // Deux actes, et deux seulement : les autres états se déduisent. Chacun se // présente dans un sens ou dans l'autre selon où en est le projet, comme le // font déjà « plier » et « masquer » — une entrée qui bascule plutôt que deux // dont une serait toujours grise. const acter = (transformation) => majPlanning(remplacerProjet(planning, idProjet, transformation)); 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: etat === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé', separateurAvant: true, // Un projet envisagé n'a rien à clore : la clôture est le bout du chemin // qui commence avec la première phase. Un écarté non plus — il sort du // cimetière par « Remettre en jeu », et retrouve alors l'état que ses // données commandent ; le clore d'ici court-circuiterait le cycle. desactive: etat === 'considered' || etat === 'discarded', action: () => etat === 'completed' ? acter((p) => rouvrirProjet(p)) : terminerLeProjet(projet), }, { // Écarter n'est pas supprimer, et la manœuvre se défait : pas de dialogue // de confirmation, réservé à ce qui ne se rattrape pas (décision 21). Le // projet garde tout, notes comprises, et se retrouve en cochant // « écartés » dans le filtre par état. libelle: etat === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet', action: () => acter(etat === 'discarded' ? (p) => reanimerProjet(p) : (p) => ecarterProjet(p)), }, { 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. // // La toute première phase d'un projet envisagé démarre à son horizon, et non // aujourd'hui : c'est le moment où l'on engage, et engager veut dire préciser // ce qu'on avait déclaré au mois près. La faire naître ailleurs donnerait à // croire que l'horizon n'était pas pris au sérieux — et laisserait la barre // pâle et la barre ferme à deux endroits sans rapport. const derniere = projet.phases[projet.phases.length - 1]; if (!debut) { debut = derniere ? ajouterJours(derniere.end, 1) : projet.horizon ? premierJourDuMois(projet.horizon.start) : 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="horizon"], [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, }); } /** * Clore un projet **termine toutes ses phases avec lui**, et rien ne rétablira * ensuite celle qui était bloquée ou celle qui n'avait pas commencé : rouvrir le * projet ne peut pas deviner ce qu'elles valaient. C'est donc une action * irréversible au sens de la décision 21, et elle s'annonce comme telle. * * On ne demande rien quand plus aucune phase n'est ouverte : il n'y a alors rien * à écraser, et un dialogue qui ne prévient de rien apprend à cliquer sans lire. */ function confirmerCloture(projet) { if (!clotureEcraseDesStatuts(projet)) return Promise.resolve(true); const restantes = projet.phases.filter((phase) => phase.status !== 'done').length; // Deux phrases écrites en entier plutôt qu'une suite de ternaires accordés : // le texte d'un dialogue se relit, et une accumulation de « s » conditionnels // finit toujours par produire une conjugaison de travers. const message = restantes > 1 ? `${restantes} phases de « ${projet.name} » ne sont pas encore terminées. Elles passeront ` + 'au statut « Terminé », jalons compris, et rouvrir le projet ensuite ne leur rendra pas ' + 'leur statut précédent.' : `Une phase de « ${projet.name} » n'est pas encore terminée. Elle passera au statut ` + '« Terminé », et rouvrir le projet ensuite ne lui rendra pas son statut précédent.'; return demanderConfirmation({ titreTexte: 'Déclarer le projet terminé', messageTexte: message, libelleValider: 'Déclarer terminé', }); } async function terminerLeProjet(projet) { if (!(await confirmerCloture(projet))) return; majPlanning(remplacerProjet(planning, projet.id, (p) => terminerProjet(p))); } 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 = projetsRetenus(); 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. // // Même raison pour l'état : un projet naît envisagé, et le masquer aussitôt // parce qu'on avait décoché « envisagés » ferait paraître le bouton sans // effet. On rouvre donc l'état plutôt que de créer dans l'ombre. if (!etatsActifs.includes('considered')) etatsActifs = [...etatsActifs, 'considered']; 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, au jour près, de ce que porte le curseur. document.addEventListener('keydown', (evenement) => { if (!curseur) 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; // Sur la ligne d'un projet, les flèches repoussent le projet entier — le même // geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute // rien : un projet ne s'étire pas (docs/decisions.md, section 8). if (!curseur.phase) { const projet = planning.projects.find((p) => p.id === curseur.projet); if (!projet || !ancreDecalage(projet)) return; evenement.preventDefault(); majPlanning( remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas)) ); 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 projetsRetenus()) { 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 = projetsRetenus(); 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();