diff --git a/js/app.js b/js/app.js
index 999dffc..fa9da04 100644
--- a/js/app.js
+++ b/js/app.js
@@ -10,12 +10,14 @@
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,
@@ -26,6 +28,7 @@ import {
figerReference,
formaterDateLongue,
libererReference,
+ modifierPhase,
normaliserTags,
planningVide,
pourEcriture,
@@ -62,7 +65,20 @@ const refs = {
let planning = planningVide();
let echelle = null;
-let selection = null; // { projet, phase }
+
+/**
+ * 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
@@ -164,8 +180,9 @@ const panneau = creerPanneau(
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() {
- selection = null;
dessiner();
},
}
@@ -195,7 +212,7 @@ const panneauProjet = creerPanneauProjet(
return confirmerSuppressionProjet(projet);
},
surSuppression(idProjet) {
- if (selection?.projet === idProjet) panneau.fermer();
+ if (curseur?.projet === idProjet) panneau.fermer();
majPlanning(supprimerProjet(planning, idProjet));
},
surFermeture() {
@@ -221,6 +238,10 @@ function majPlanning(suivant) {
}
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));
@@ -249,12 +270,12 @@ function dessiner() {
}
elargirPourContenir();
- echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
+ 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 (selection) {
- const trouve = localiser(selection.projet, selection.phase);
+ if (curseur?.phase) {
+ const trouve = localiser(curseur.projet, curseur.phase);
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
}
@@ -741,7 +762,7 @@ function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) {
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
// l'autre.
panneauProjet.fermer();
- selection = { projet: idProjet, phase: idPhase };
+ 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
@@ -879,13 +900,17 @@ function ouvrirMenuProjet(idProjet, ancre) {
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
* bascule reste possible à tout moment ensuite.
*/
-function ajouterUnePhase(projet) {
+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];
- const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
+ if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
@@ -906,6 +931,9 @@ function ajouterUnePhase(projet) {
/** 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();
}
@@ -981,11 +1009,29 @@ function confirmerSuppressionProjet(projet) {
async function supprimerLeProjet(projet) {
if (!(await confirmerSuppressionProjet(projet))) return;
- if (selection?.projet === projet.id) panneau.fermer();
+ 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.
@@ -1018,10 +1064,13 @@ function nouveauProjet() {
// « 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.
-$('aller-aujourdhui').addEventListener('click', () => {
+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);
@@ -1056,9 +1105,10 @@ refs.frise.addEventListener(
{ passive: false }
);
-// Flèches : ajustement fin de la phase sélectionnée, au jour près.
+// Flèches : ajustement fin de la phase du curseur, au jour près.
document.addEventListener('keydown', (evenement) => {
- if (!selection) return;
+ // 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;
@@ -1066,7 +1116,7 @@ document.addEventListener('keydown', (evenement) => {
if (pas === 0) return;
evenement.preventDefault();
- const trouve = localiser(selection.projet, selection.phase);
+ const trouve = localiser(curseur.projet, curseur.phase);
if (!trouve) return;
// Maj + flèche étire la phase au lieu de la déplacer.
@@ -1075,12 +1125,386 @@ document.addEventListener('keydown', (evenement) => {
: decalerAuClavier(trouve.phase, pas);
majPlanning(
- remplacerProjet(planning, selection.projet, (projet) =>
- remplacerPhase(projet, selection.phase, () => suivante)
+ 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
// ---------------------------------------------------------------------------
diff --git a/js/clavier.js b/js/clavier.js
new file mode 100644
index 0000000..9b4c9dd
--- /dev/null
+++ b/js/clavier.js
@@ -0,0 +1,186 @@
+/**
+ * Raccourcis à la vim.
+ *
+ * Ce module ne connaît rien du planning : il traduit des frappes en noms
+ * d'actions, que `app.js` lui fournit. Tout ce qui décide de *quoi* faire vit
+ * donc là-bas, et ce fichier ne garde que la mécanique des touches — la table
+ * des séquences et le préfixe en attente.
+ *
+ * Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
+ * pas : les **flèches modifient** la phase du curseur (app.js, hérité), les
+ * **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a
+ * changé de sens.
+ */
+
+/**
+ * Table unique : une séquence de touches, un nom d'action.
+ *
+ * Les séquences de deux touches reprennent les préfixes de vim et rien d'autre :
+ * `g` pour aller quelque part, `z` pour les plis et la vue, `d` pour détruire.
+ * Les préfixes s'en déduisent (voir `PREFIXES`) au lieu d'être listés à la main,
+ * ce qui évite qu'une entrée ajoutée ici soit oubliée là.
+ */
+const SEQUENCES = {
+ // Déplacements du curseur
+ j: 'ligneSuivante',
+ k: 'lignePrecedente',
+ l: 'phaseSuivante',
+ h: 'phasePrecedente',
+ '}': 'projetSuivant',
+ '{': 'projetPrecedent',
+ gg: 'premiereLigne',
+ G: 'derniereLigne',
+
+ // Plis et cadrage
+ za: 'basculerPli',
+ zR: 'deplierTout',
+ zM: 'plierTout',
+ zz: 'recentrer',
+
+ // Défilement dans le temps
+ w: 'moisSuivant',
+ b: 'moisPrecedent',
+ W: 'anneeSuivante',
+ B: 'anneePrecedente',
+ ga: 'allerAujourdhui',
+ gm: 'basculerVue',
+
+ // Actions sur la ligne du curseur
+ //
+ // `Entrée` et `e` font la même chose, et c'est voulu : l'une est la touche
+ // qu'on essaie sans rien savoir, l'autre celle qu'on garde sous les doigts.
+ Enter: 'editer',
+ e: 'editer',
+ A: 'ajouterFin',
+ a: 'ajouterDebut',
+ o: 'ajouterApres',
+ O: 'ajouterAvant',
+ // Un projet n'est pas une phase d'un cran au-dessus : il naît hors de tout
+ // curseur, d'où une touche à lui et non un membre de la famille `a`/`o`.
+ P: 'nouveauProjet',
+ dd: 'supprimer',
+ ' ': 'statutSuivant',
+
+ // Le reste étant invisible, celui-ci ne peut pas l'être.
+ '?': 'aide',
+};
+
+/** Premières touches d'une séquence à deux temps, déduites de la table. */
+const PREFIXES = new Set(
+ Object.keys(SEQUENCES)
+ .filter((sequence) => sequence.length === 2)
+ .map((sequence) => sequence[0])
+);
+
+/**
+ * Délai au bout duquel un préfixe resté en attente est oublié.
+ *
+ * Vim n'expire pas `d`, mais vim n'est pas posé sur un écran qu'on quitte des
+ * yeux : un `d` frappé puis abandonné, retrouvé par un `d` cinq minutes plus
+ * tard, supprimerait ce que le curseur désigne alors. La confirmation rattrape
+ * le coup, l'oubli évite d'avoir à la lire.
+ */
+const OUBLI_PREFIXE = 2000;
+
+/**
+ * @param {object} actions nom d'action → fonction, cf. `SEQUENCES`
+ * @param {function} actions.aide ouvre le panneau des raccourcis
+ */
+export function installerClavier(actions) {
+ let prefixe = null;
+ let minuterie = null;
+
+ function oublier() {
+ prefixe = null;
+ clearTimeout(minuterie);
+ }
+
+ function retenir(touche) {
+ prefixe = touche;
+ clearTimeout(minuterie);
+ minuterie = setTimeout(oublier, OUBLI_PREFIXE);
+ }
+
+ document.addEventListener('keydown', (evenement) => {
+ // Les combinaisons du navigateur et du système ne nous appartiennent pas.
+ //
+ // `Maj` est tolérée sans réserve : `?`, `A` et `G` en sont faits. `AltGr`
+ // aussi, et il n'y a pas le choix — sur un clavier français, `{` et `}` ne
+ // s'obtiennent qu'avec lui. Windows le présente comme `Ctrl`+`Alt`, si bien
+ // que refuser les deux ensemble rendrait ces deux touches inatteignables sur
+ // la moitié des machines. On ne refuse donc que `Ctrl` seul, `Alt` seul et
+ // `Meta` — aucun raccourci du navigateur n'emploie la paire.
+ const altGr = evenement.getModifierState('AltGraph') || (evenement.ctrlKey && evenement.altKey);
+ if (evenement.metaKey || (!altGr && (evenement.ctrlKey || evenement.altKey))) return;
+
+ if (clavierPris()) {
+ oublier();
+ return;
+ }
+
+ // Échap sort du préfixe avant tout le reste, comme en mode normal. Il garde
+ // ensuite son rôle habituel — fermer le panneau — et n'est donc pas consommé.
+ if (evenement.key === 'Escape') {
+ oublier();
+ return;
+ }
+
+ const touche = evenement.key;
+
+ if (prefixe) {
+ const sequence = prefixe + touche;
+ oublier();
+ // Une touche qui ne complète rien annule la séquence sans rien déclencher
+ // d'autre : `zx` ne doit surtout pas se comporter comme un `x` seul.
+ const action = actions[SEQUENCES[sequence]];
+ if (action) {
+ evenement.preventDefault();
+ action();
+ }
+ return;
+ }
+
+ if (PREFIXES.has(touche)) {
+ evenement.preventDefault();
+ retenir(touche);
+ return;
+ }
+
+ const action = actions[SEQUENCES[touche]];
+ if (!action) return;
+
+ evenement.preventDefault();
+ action();
+ });
+
+ // Un préfixe n'a pas à attendre le retour du focus : partir remplir un champ
+ // ailleurs, ou changer de fenêtre, est un abandon.
+ window.addEventListener('blur', oublier);
+}
+
+/**
+ * Le clavier appartient-il déjà à quelqu'un d'autre ?
+ *
+ * Des raccourcis d'une seule lettre ne peuvent pas s'interposer entre une frappe
+ * et le champ qui l'attend : `a` dans un nom de phase doit écrire un `a`, pas
+ * créer une phase. Quatre cas d'appartenance, du plus évident au moins visible :
+ * une saisie en cours, un dialogue modal, le menu contextuel — qui a sa propre
+ * navigation aux flèches — et le glisser d'une poignée.
+ */
+function clavierPris() {
+ const actif = document.activeElement;
+
+ if (actif) {
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(actif.tagName)) return true;
+ // L'aperçu markdown n'est pas éditable, mais rien ne garantit qu'aucun
+ // champ riche n'apparaîtra un jour dans un panneau.
+ if (actif.isContentEditable) return true;
+ }
+
+ if (document.querySelector('dialog[open]')) return true;
+
+ const menu = document.getElementById('menu');
+ if (menu && !menu.hidden) return true;
+
+ return document.body.classList.contains('glisse-en-cours');
+}
diff --git a/js/timeline.js b/js/timeline.js
index a962d25..9cdd53a 100644
--- a/js/timeline.js
+++ b/js/timeline.js
@@ -93,9 +93,9 @@ export function creerEchelle(fenetre) {
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
- * @param {object} options { fenetre, selection, tagsActifs }
+ * @param {object} options { fenetre, curseur, tagsActifs }
*/
-export function rendre(refs, planning, { fenetre, selection, tagsActifs = [] }) {
+export function rendre(refs, planning, { fenetre, curseur, tagsActifs = [] }) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const visibles = retenus.filter((projet) => !projet.hidden);
@@ -104,10 +104,10 @@ export function rendre(refs, planning, { fenetre, selection, tagsActifs = [] })
refs.couloirs.style.width = `${echelle.largeur}px`;
refs.entete.replaceChildren(...construireEntete(echelle));
- refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs));
+ refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs, curseur));
refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)),
- ...construireCouloirs(visibles, echelle, selection)
+ ...construireCouloirs(visibles, echelle, curseur)
);
return echelle;
@@ -269,21 +269,28 @@ function moisSuivant(annee, mois) {
// Colonne des libellés
// ---------------------------------------------------------------------------
-function construireLibelles(projets, tagsActifs) {
+/**
+ * La colonne est aussi la règle graduée du curseur clavier : c'est le seul
+ * endroit qui porte une ligne pour *chaque* position atteignable, y compris
+ * celles qui n'ont pas de barre en face — un projet plié, un projet masqué, ou
+ * simplement la ligne d'un projet. `app.js` compte donc ses enfants pour savoir
+ * où amener le défilement, et l'ordre produit ici est celui qu'il énumère.
+ */
+function construireLibelles(projets, tagsActifs, curseur) {
const elements = [];
for (const projet of projets) {
- if (projet.hidden) {
- elements.push(libelleProjet(projet, tagsActifs));
- continue;
- }
+ const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
+ elements.push(libelleProjet(projet, tagsActifs, surLeProjet));
- elements.push(libelleProjet(projet, tagsActifs));
- if (projet.collapsed) continue;
+ // Un projet masqué n'a plus que sa ligne : ses phases ne se dessinent nulle
+ // part, et le curseur ne les traverse pas davantage qu'il ne les affiche.
+ if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) {
+ const sousCurseur = curseur && curseur.projet === projet.id && curseur.phase === phase.id;
const ligne = document.createElement('div');
- ligne.className = 'libelle-phase';
+ ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
ligne.textContent = phase.name;
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
ligne.dataset.projet = projet.id;
@@ -295,10 +302,14 @@ function construireLibelles(projets, tagsActifs) {
return elements;
}
-function libelleProjet(projet, tagsActifs = []) {
+function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
const ligne = document.createElement('div');
- ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`;
+ ligne.className =
+ 'libelle-projet' +
+ (projet.hidden ? ' libelle-projet--masque' : '') +
+ (sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
+ ligne.dataset.projet = projet.id;
const plier = document.createElement('button');
plier.className = 'plier';
@@ -388,12 +399,17 @@ function libelleProjet(projet, tagsActifs = []) {
// Couloirs
// ---------------------------------------------------------------------------
-function construireCouloirs(projets, echelle, selection) {
+function construireCouloirs(projets, echelle, curseur) {
const elements = [];
for (const projet of projets) {
+ // Le curseur clavier se pose aussi sur la ligne d'un projet, où il commande
+ // le pli, les paramètres et l'ajout d'une phase. Elle n'a pas de barre à
+ // souligner : c'est le couloir entier qui porte la marque.
+ const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
+
const couloirProjet = document.createElement('div');
- couloirProjet.className = 'couloir couloir--projet';
+ couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
couloirProjet.style.setProperty('--couleur-projet', projet.color);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
@@ -413,7 +429,7 @@ function construireCouloirs(projets, echelle, selection) {
const couloir = document.createElement('div');
couloir.className = 'couloir';
couloir.style.setProperty('--couleur-projet', projet.color);
- couloir.append(...construirePhase(projet, phase, echelle, selection));
+ couloir.append(...construirePhase(projet, phase, echelle, curseur));
elements.push(couloir);
}
}
@@ -421,9 +437,9 @@ function construireCouloirs(projets, echelle, selection) {
return elements;
}
-function construirePhase(projet, phase, echelle, selection) {
+function construirePhase(projet, phase, echelle, curseur) {
const selectionnee =
- selection && selection.projet === projet.id && selection.phase === phase.id;
+ curseur && curseur.projet === projet.id && curseur.phase === phase.id;
const elements = [];