Piloter la frise au clavier, à la vim

Les flèches décalaient déjà une phase, mais rien ne permettait de la choisir :
`selection` n'était posée qu'au clic, si bien qu'il fallait la souris pour
commencer. Elle devient `curseur`, désigne une ligne — celle d'un projet en est
une, d'où l'on plie, renomme et ajoute — et survit à Échap, sans quoi elle ne
pourrait servir de point de départ au déplacement suivant. `dessiner` le
normalise, la ligne du projet servant de refuge quand la sienne disparaît.

Deux couches qui ne se recouvrent pas : les flèches modifient, les lettres
déplacent et commandent. Aucun raccourci documenté ne change de sens. Les
touches se taisent dès que le clavier est à quelqu'un d'autre — une saisie, un
dialogue, le menu, un glisser —, faute de quoi `a` dans un nom créerait une
phase. AltGr est toléré : sur un clavier français `{` et `}` n'existent qu'avec
lui, et Windows le présente comme Ctrl+Alt.

Les phases étant triées par date, `o` et `O` insèrent dans le temps et non dans
un rang. `P` reste hors de cette famille : un projet naît sans position.

Le curseur se peint dans la colonne des libellés, seul endroit qui porte une
ligne pour chaque position atteignable — projet plié, projet masqué, ligne de
projet. Et `?` ouvre la table des raccourcis, qui ne se devinent pas.

Décision 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:36:07 +02:00
parent 7ff1329a86
commit 3092be86ae
7 changed files with 1060 additions and 38 deletions

View File

@@ -101,7 +101,7 @@ Une fois un projet en place :
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
| Clic sur un nom de phase hors écran | Ramène la frise sur elle |
| `←` `→` | Décale d'un jour la phase sélectionnée |
| `←` `→` | Décale d'un jour la phase du curseur |
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
| Clic dans une zone vide | Ferme le panneau ouvert |
@@ -111,6 +111,49 @@ Une fois un projet en place :
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
### Au clavier, à la vim
La frise a un **curseur** : la ligne sur laquelle portent les raccourcis. Il se pose en cliquant une
barre ou un nom, ou à la première frappe de `j`, et il se voit à la ligne bleutée dans la colonne de
gauche. Il tient aussi sur la ligne d'un **projet**, position d'où l'on plie, renomme et ajoute.
Le bouton **`?`** de la barre d'outils, ou la touche `?`, ouvre la liste complète. En résumé :
| Touche | Effet |
|---|---|
| `j` `k` | Ligne suivante, précédente — projets traversés |
| `h` `l` | Phase précédente, suivante **du même projet**, sans déborder sur le voisin |
| `{` `}` | Projet précédent, suivant |
| `gg` `G` | Première, dernière ligne |
| `Entrée` `e` | Ouvre le volet sur la ligne du curseur et saisit le nom |
| `Espace` | Statut suivant : à venir, en cours, terminé, bloqué |
| `A` `a` | Ajoute une phase à la fin, au début du projet |
| `o` `O` | En ajoute une juste après, juste avant celle du curseur |
| `P` | Ajoute un projet |
| `dd` | Supprime la phase — ou le projet depuis sa ligne |
| `za` | Plie ou déplie le projet |
| `zR` `zM` | Déplie tout, plie tout |
| `zz` | Recentre la frise sur le curseur |
| `w` `b` | Un mois plus tard, plus tôt |
| `W` `B` | Un an plus tard, plus tôt |
| `ga` | Revient à aujourd'hui |
| `gm` | Bascule entre la frise et la vue par mois |
Les phases étant rangées par date, `o` et `O` insèrent **dans le temps** et non dans une liste : la
nouvelle phase se pose bord à bord avec sa voisine. `P`, lui, ne dépend pas du curseur : un projet
naît hors de toute ligne, et la vue revient à la frise si on était dans la liste par mois.
Deux garde-fous. Ces touches **se taisent pendant une saisie** — dans un champ, une note, un
dialogue ou le menu contextuel, elles écrivent la lettre qu'elles portent. Et `dd` demande toujours
confirmation, comme le bouton **Supprimer**.
Le volet ouvert **suit le curseur** : `j` et `k` font alors défiler les phases une à une dans le
panneau de détail, ce qui est le moyen le plus rapide de relire tout un projet. Pour cela, ouvrir le
volet d'un **clic sur une barre**`Entrée` et `e`, eux, placent la frappe dans le champ « Nom », où
les raccourcis se taisent.
`Échap` referme le volet sans déplacer le curseur, et la frappe suivante repart de là.
### Les notes
Un projet **et** une phase portent chacun des notes, en bas de leur panneau. Elles y prennent toute
@@ -278,10 +321,11 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` |
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags, regroupement par mois |
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état |
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli |
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes, ancrage sur le mois courant |
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
| `js/drag.js` | Glisser et redimensionner les barres |
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
| `js/detail.js` | Panneau de détail d'une phase |

View File

@@ -577,6 +577,49 @@ button[aria-pressed="true"] {
opacity: 0.45;
}
/* --- curseur clavier ------------------------------------------------------ */
/* Le curseur des raccourcis vim se marque ici, et pas seulement sur la barre :
il se pose aussi sur des lignes qui n'ont aucune barre en face — celle d'un
projet, un projet plié, un projet masqué. Sans cette marque, `j` et `k`
déplaceraient quelque chose d'invisible.
Un trait sur le bord gauche, posé en `box-shadow` : une vraie bordure
pousserait le contenu de trois pixels à chaque déplacement du curseur.
Les deux sortes de ligne prennent aussi une teinte d'accent, mais pas sur le
même fond : celui d'un projet est déjà coloré à ses couleurs, et le mélange
part donc de là. Le trait seul ne suffisait pas sur cette ligne-là, où son
fond propre et le triangle de pli le noyaient. */
.libelle-projet--curseur,
.libelle-phase--curseur {
color: var(--texte);
box-shadow: inset 3px 0 0 var(--accent);
}
.libelle-phase--curseur {
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
/* On redéfinit `--fond-libelle` au lieu de peindre le fond : le dégradé qui
donne un fond aux commandes s'appuie sur cette même variable, et un fond posé
en direct laisserait une couture visible sous elles. La valeur reprend donc
celle de `.libelle-projet`, teintée d'accent — on ne peut pas se citer
soi-même dans une variable. */
.libelle-projet--curseur {
--fond-libelle: color-mix(
in srgb,
var(--accent) 16%,
color-mix(in srgb, var(--couleur-projet) 8%, var(--surface))
);
}
/* Le curseur désigne une ligne de projet : ses commandes doivent se voir, sans
quoi rien ne dirait ce que cette ligne sait faire. */
.libelle-projet--curseur .libelle-projet__commandes {
opacity: 1;
}
/* --- couloirs et barres -------------------------------------------------- */
.couloir {
@@ -589,6 +632,13 @@ button[aria-pressed="true"] {
background: color-mix(in srgb, var(--couleur-projet) 5%, transparent);
}
/* Curseur clavier posé sur la ligne d'un projet. Elle n'a que sa barre
cumulative, trop fine pour porter un contour : c'est le couloir entier qui
prend la teinte. Après `.couloir--projet`, dont elle remplace le fond. */
.couloir--curseur {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.barre {
position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
@@ -1392,6 +1442,85 @@ button[aria-pressed="true"] {
padding: 0;
}
/* --- aide des raccourcis -------------------------------------------------- */
/* Le dialogue de confirmation tient en 400 px parce qu'il pose une question ;
celui-ci est une table de référence qu'on parcourt du regard. Il prend donc
la largeur qu'il faut, plafonnée par la fenêtre, et ses sections se rangent
en colonnes tant qu'elles y tiennent. */
.dialogue--aide {
max-width: min(920px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
}
.aide__colonnes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 4px 28px;
}
.aide__colonnes h3 {
margin: 10px 0 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--texte-doux);
}
/* Deux colonnes dans un `<dl>` : la colonne des touches se cale sur la plus
large de la section — fixée en `em`, elle creusait un couloir vide devant les
raccourcis d'une seule lettre ; laissée libre, elle mettrait les libellés en
escalier d'une ligne à l'autre. */
dl.aide {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 16px;
margin: 0;
font-size: 13px;
}
dl.aide dt {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: baseline;
}
dl.aide dd {
margin: 0;
color: var(--texte-doux);
line-height: 1.45;
}
kbd {
padding: 1px 5px;
border: 1px solid var(--bordure-forte);
border-bottom-width: 2px;
border-radius: 4px;
background: var(--fond);
color: var(--texte);
font-family: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.aide__note {
padding-top: 4px;
border-top: 1px solid var(--bordure);
font-size: 12px;
}
/* Le point d'interrogation de la barre d'outils. Rond et discret : c'est un
recours, pas une commande qu'on utilise en travaillant. */
.outils__aide {
width: 28px;
padding: 0;
border-radius: 50%;
font-weight: 700;
}
/* --- vue par mois -------------------------------------------------------- */
/* Rien n'est positionné en absolu ici, contrairement à la frise : la liste

View File

@@ -718,3 +718,112 @@ reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
incrémenter, un binaire antérieur ouvrirait un fichier plus riche que lui sans broncher et en
effacerait toutes les notes de projet à la première sauvegarde. Le numéro ne sert qu'à cela : faire
échouer bruyamment ce qui échouerait silencieusement.
## 26. Un curseur clavier, et des raccourcis à la vim
L'outil savait déjà décaler une phase aux flèches, mais pas la **choisir** : `selection` n'était
posée que par `ouvrirPhase`, appelée depuis un clic. Il fallait donc la souris pour commencer, et
tout raccourci ajouté par-dessus aurait hérité de cette dépendance.
### `selection` devient `curseur`, et survit à la fermeture
L'ancienne variable disait deux choses à la fois : « voici la phase que je manipule » et « le
panneau est ouvert dessus ». Les deux se séparent.
Le curseur désigne une **ligne de la frise**, et la ligne d'un projet en est une : c'est de là qu'on
plie, qu'on renomme, qu'on ajoute une phase et qu'on supprime le projet. D'où `{ projet, phase }`
avec `phase` à `null`, plutôt que deux curseurs concurrents dont l'un serait toujours à ignorer.
Il **survit à `Échap`**, alors que `selection` s'effaçait : un curseur qui disparaît à la fermeture
du volet ne peut pas servir de point de départ au déplacement suivant. En contrepartie, la marque
reste visible après un clic dans le vide — c'est le prix d'un curseur, et il se paie une fois.
Comme il peut désigner une ligne qu'un changement vient d'emporter — phase supprimée, projet replié,
filtre resserré —, `dessiner` commence par le **normaliser** : la ligne du projet sert de refuge,
puisqu'elle survit à ces trois cas, et l'effacement n'est que le dernier recours. Placer ce contrôle
dans `dessiner` plutôt qu'à chaque appelant garantit qu'aucun chemin ne l'oublie.
### Le volet suit le curseur
Ce n'est pas un agrément mais une nécessité : les raccourcis agissent sur le curseur, et un panneau
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 — ce qui fait de `j` et `k` le moyen le plus rapide de relire tout un projet.
Ce parcours passe par un **clic** sur une barre, qui ouvre le volet sans prendre le champ. Ouvrir au
clavier place la frappe dans le nom, où les raccourcis se taisent, et `Échap` referme tout : les deux
usages ne se rejoignent pas, et c'est un compromis assumé plutôt qu'un oubli. Le rendre continu
demanderait de faire d'`Échap` une sortie de champ avant d'être une fermeture — deux appuis là où il
en faut un aujourd'hui, y compris à la souris.
### Deux couches qui ne se recouvrent pas
Les **flèches modifient** la phase du curseur, les **lettres déplacent** et commandent. Aucun
raccourci documenté n'a changé de sens.
La décision 17 tient, mais son partage se déplace. Elle laissait le focus sur le panneau et non dans
un champ, pour que les flèches restent atteignables juste après avoir sélectionné une phase — au
clic, c'est toujours le cas. Ouvrir le volet **au clavier**, en revanche, est une demande explicite
d'écrire : `Entrée` et `e` saisissent donc le nom tous les deux. Ce qui protège les flèches n'est
plus le focus mais `Échap`, qui referme le volet sans déplacer le curseur — « `e`, taper, `Échap`,
flèches » enchaîne sans la souris.
Ce qui ne doit **jamais** prendre le focus, c'est le volet qui suit un déplacement du curseur : la
frappe suivante partirait dans le champ, où les raccourcis se taisent, et on ne saisirait plus jamais
qu'un nom. D'où le paramètre `saisir` de `ouvrirSousCurseur`, vrai sur commande, faux au passage.
`clavier.js` ne connaît rien du planning : il traduit des frappes en noms d'actions, que `app.js`
lui fournit. Les séquences à deux temps se déclarent dans la même table que les autres, et les
préfixes — `g` aller, `z` plis, `d` détruire — s'en déduisent au lieu d'être listés à part. Un
préfixe resté en attente s'oublie au bout de deux secondes : vim ne le fait pas, mais vim n'est pas
posé sur un écran qu'on quitte des yeux, et un `d` abandonné puis retrouvé cinq minutes plus tard
viserait une autre ligne.
Les touches **se taisent** dès que le clavier appartient à quelqu'un d'autre : une saisie en cours,
un dialogue modal, le menu contextuel — qui navigue déjà aux flèches — ou un glisser en cours. Sans
cela, `a` dans un nom de phase créerait une phase au lieu d'écrire un `a`.
`AltGr` est la seule combinaison tolérée avec `Maj`, 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 les binaires Windows.
### Insérer se dit en dates
`model.js` trie les phases par date de début. « Avant » et « après » ne peuvent donc pas s'exprimer
en rangs : `o` et `O` calculent une date et laissent le tri faire le reste. Les quatre points
d'insertion — `A` à la fin du projet, `a` au début, `o` après la phase du curseur, `O` avant — ne
diffèrent que par ce calcul, et passent tous par `ajouterUnePhase`, qui garde son comportement
d'origine quand aucune date ne lui est donnée.
`P` reste à part : un projet naît **hors de tout curseur**, sans date et sans ligne de référence.
L'agréger à la famille `a`/`o` aurait suggéré une position qu'il n'a pas.
### Le curseur se peint dans la colonne, pas seulement sur la barre
La barre portait déjà un contour. Il ne suffit pas : le curseur se pose sur des lignes qui n'ont
aucune barre en face — celle d'un projet, un projet plié, un projet masqué. La colonne des libellés
est le seul endroit qui porte une ligne pour *chaque* position atteignable, et c'est aussi ce qui
permet à `app.js` d'y compter les enfants pour amener le défilement au bon endroit. L'énumération de
`lignesFrise` et celle de `construireLibelles` doivent donc rester d'accord.
Sur la ligne d'un projet, le trait d'accent seul se noyait dans son fond coloré et son triangle de
pli : elle prend aussi une teinte, mélangée à sa couleur propre plutôt qu'au fond de la surface. La
teinte passe par une redéfinition de `--fond-libelle` et non par `background`, parce que le dégradé
qui donne un fond aux commandes du projet s'appuie sur cette même variable — un fond posé en direct
y laissait une couture.
### Le `?` n'est pas facultatif
Des raccourcis d'une lettre ne se devinent pas. Un bouton dans la barre d'outils et la touche `?`
ouvrent la même table, dans un `<dialog>` **modal** — contrairement aux deux panneaux : celui-ci ne
sert qu'à lire, et laisser `j` déplacer le curseur derrière la table qui explique `j` serait absurde.
La table est écrite à la main plutôt que bâtie depuis `SEQUENCES`, qui associe une touche à un nom
d'action : cela ne dit ni ce que l'action fait, ni dans quel ordre la présenter, ni quelles touches
vont par paires. La génération aurait produit une liste exacte et illisible.
### La vue par mois n'a pas de curseur
Elle se lit, elle ne s'édite pas (décision 24) : rien n'y aurait de sens à désigner. `j`, `k`, `gg`
et `G` y font donc ce qu'ils font dans un document — ils déroulent la liste. `gm` et `ga` marchent
dans les deux vues, le reste attend le retour à la frise.

View File

@@ -35,6 +35,20 @@
<button type="button" id="nouveau-projet" class="bouton--principal">
Nouveau projet
</button>
<!--
Des raccourcis d'une lettre ne se devinent pas. Ce bouton est le seul
endroit de l'écran qui dit qu'ils existent : sans lui, la moitié de ce
que l'outil sait faire au clavier resterait dans le README.
-->
<button
type="button"
id="ouvrir-aide"
class="outils__aide"
title="Raccourcis clavier (?)"
aria-label="Raccourcis clavier"
>
?
</button>
</div>
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
@@ -253,6 +267,106 @@
</form>
</dialog>
<!--
Aide des raccourcis, ouverte par `?`.
Un `<dialog>` modal, contrairement aux deux panneaux : celui-ci ne sert
qu'à lire, et le clavier doit y être *rendu* — laisser `j` déplacer le
curseur derrière la table qui explique `j` serait absurde. La modalité
apporte aussi le voile, le piège à focus et la fermeture par Échap.
La table est écrite à la main plutôt que bâtie depuis la table des
séquences de clavier.js : celle-ci associe une touche à un nom d'action, ce
qui ne dit ni ce que l'action fait, ni dans quel ordre la présenter, ni
quelles touches vont par paires. Une génération automatique produirait une
liste exacte et illisible.
-->
<dialog class="dialogue dialogue--aide" id="dialogue-aide" aria-labelledby="aide-titre">
<form method="dialog">
<h2 id="aide-titre">Raccourcis clavier</h2>
<div class="aide__colonnes">
<section>
<h3>Déplacer le curseur</h3>
<dl class="aide">
<dt><kbd>j</kbd> <kbd>k</kbd></dt>
<dd>Ligne suivante, précédente</dd>
<dt><kbd>h</kbd> <kbd>l</kbd></dt>
<dd>Phase précédente, suivante du même projet</dd>
<dt><kbd>{</kbd> <kbd>}</kbd></dt>
<dd>Projet précédent, suivant</dd>
<dt><kbd>gg</kbd> <kbd>G</kbd></dt>
<dd>Première, dernière ligne</dd>
</dl>
</section>
<section>
<h3>Modifier la phase du curseur</h3>
<dl class="aide">
<dt><kbd></kbd> <kbd></kbd></dt>
<dd>Décale d'un jour</dd>
<dt><kbd>Maj</kbd>+<kbd></kbd> <kbd></kbd></dt>
<dd>Allonge, raccourcit d'un jour</dd>
<dt><kbd>Espace</kbd></dt>
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>
<dd>Ouvre le volet et saisit le nom</dd>
<dt><kbd>dd</kbd></dt>
<dd>Supprime la phase, ou le projet depuis sa ligne</dd>
</dl>
</section>
<section>
<h3>Ajouter</h3>
<dl class="aide">
<dt><kbd>A</kbd></dt>
<dd>Une phase à la fin du projet</dd>
<dt><kbd>a</kbd></dt>
<dd>Une phase au début du projet</dd>
<dt><kbd>o</kbd></dt>
<dd>Une phase juste après celle du curseur</dd>
<dt><kbd>O</kbd></dt>
<dd>Une phase juste avant elle</dd>
<dt><kbd>P</kbd></dt>
<dd>Un projet</dd>
</dl>
</section>
<section>
<h3>Plier, cadrer, naviguer</h3>
<dl class="aide">
<dt><kbd>za</kbd></dt>
<dd>Plie ou déplie le projet</dd>
<dt><kbd>zR</kbd> <kbd>zM</kbd></dt>
<dd>Déplie tout, plie tout</dd>
<dt><kbd>zz</kbd></dt>
<dd>Recentre la frise sur le curseur</dd>
<dt><kbd>w</kbd> <kbd>b</kbd></dt>
<dd>Un mois plus tard, plus tôt</dd>
<dt><kbd>W</kbd> <kbd>B</kbd></dt>
<dd>Un an plus tard, plus tôt</dd>
<dt><kbd>ga</kbd></dt>
<dd>Revient à aujourd'hui</dd>
<dt><kbd>gm</kbd></dt>
<dd>Bascule frise et vue par mois</dd>
<dt><kbd>Maj</kbd>+molette</dt>
<dd>Fait défiler la frise horizontalement</dd>
</dl>
</section>
</div>
<p class="aide__note">
Toutes ces touches se taisent pendant une saisie : dans un champ, une
note ou un dialogue, elles écrivent la lettre qu'elles portent.
<kbd>Échap</kbd> referme le volet ouvert sans déplacer le curseur.
</p>
<menu class="dialogue__actions">
<button type="submit" value="ok" class="bouton--principal">Fermer</button>
</menu>
</form>
</dialog>
<!-- Bandeau d'erreur bloquante : fichier corrompu, serveur injoignable. -->
<div class="alerte" id="alerte" hidden role="alert">
<p id="alerte-message"></p>

458
js/app.js
View File

@@ -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
// ---------------------------------------------------------------------------

186
js/clavier.js Normal file
View File

@@ -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');
}

View File

@@ -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 = [];