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:
48
README.md
48
README.md
@@ -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 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 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 |
|
| 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 |
|
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
|
||||||
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
||||||
| Clic dans une zone vide | Ferme le panneau ouvert |
|
| 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
|
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.
|
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
|
### Les notes
|
||||||
|
|
||||||
Un projet **et** une phase portent chacun des notes, en bas de leur panneau. Elles y prennent toute
|
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/` |
|
| `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/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/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/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/tags.js` | Pastilles de tags, partagées par les deux vues |
|
||||||
| `js/drag.js` | Glisser et redimensionner les barres |
|
| `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/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/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 |
|
| `js/detail.js` | Panneau de détail d'une phase |
|
||||||
|
|||||||
129
css/style.css
129
css/style.css
@@ -577,6 +577,49 @@ button[aria-pressed="true"] {
|
|||||||
opacity: 0.45;
|
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 -------------------------------------------------- */
|
/* --- couloirs et barres -------------------------------------------------- */
|
||||||
|
|
||||||
.couloir {
|
.couloir {
|
||||||
@@ -589,6 +632,13 @@ button[aria-pressed="true"] {
|
|||||||
background: color-mix(in srgb, var(--couleur-projet) 5%, transparent);
|
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 {
|
.barre {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
|
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
|
||||||
@@ -1392,6 +1442,85 @@ button[aria-pressed="true"] {
|
|||||||
padding: 0;
|
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 -------------------------------------------------------- */
|
/* --- vue par mois -------------------------------------------------------- */
|
||||||
|
|
||||||
/* Rien n'est positionné en absolu ici, contrairement à la frise : la liste
|
/* Rien n'est positionné en absolu ici, contrairement à la frise : la liste
|
||||||
|
|||||||
@@ -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
|
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
|
effacerait toutes les notes de projet à la première sauvegarde. Le numéro ne sert qu'à cela : faire
|
||||||
échouer bruyamment ce qui échouerait silencieusement.
|
é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.
|
||||||
|
|||||||
114
index.html
114
index.html
@@ -35,6 +35,20 @@
|
|||||||
<button type="button" id="nouveau-projet" class="bouton--principal">
|
<button type="button" id="nouveau-projet" class="bouton--principal">
|
||||||
Nouveau projet
|
Nouveau projet
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
|
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
|
||||||
@@ -253,6 +267,106 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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. -->
|
<!-- Bandeau d'erreur bloquante : fichier corrompu, serveur injoignable. -->
|
||||||
<div class="alerte" id="alerte" hidden role="alert">
|
<div class="alerte" id="alerte" hidden role="alert">
|
||||||
<p id="alerte-message"></p>
|
<p id="alerte-message"></p>
|
||||||
|
|||||||
458
js/app.js
458
js/app.js
@@ -10,12 +10,14 @@
|
|||||||
import { creerPanneau } from './detail.js';
|
import { creerPanneau } from './detail.js';
|
||||||
import { creerPanneauProjet } from './projet.js';
|
import { creerPanneauProjet } from './projet.js';
|
||||||
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
||||||
|
import { installerClavier } from './clavier.js';
|
||||||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||||||
import { PIXELS_PAR_JOUR, rendre } from './timeline.js';
|
import { PIXELS_PAR_JOUR, rendre } from './timeline.js';
|
||||||
import { rendreMois, revelerMoisCourant } from './mois.js';
|
import { rendreMois, revelerMoisCourant } from './mois.js';
|
||||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||||
import {
|
import {
|
||||||
COULEURS,
|
COULEURS,
|
||||||
|
STATUTS,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
ajouterPhase,
|
ajouterPhase,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
figerReference,
|
figerReference,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
libererReference,
|
libererReference,
|
||||||
|
modifierPhase,
|
||||||
normaliserTags,
|
normaliserTags,
|
||||||
planningVide,
|
planningVide,
|
||||||
pourEcriture,
|
pourEcriture,
|
||||||
@@ -62,7 +65,20 @@ const refs = {
|
|||||||
|
|
||||||
let planning = planningVide();
|
let planning = planningVide();
|
||||||
let echelle = null;
|
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
|
* 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))
|
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() {
|
surFermeture() {
|
||||||
selection = null;
|
|
||||||
dessiner();
|
dessiner();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -195,7 +212,7 @@ const panneauProjet = creerPanneauProjet(
|
|||||||
return confirmerSuppressionProjet(projet);
|
return confirmerSuppressionProjet(projet);
|
||||||
},
|
},
|
||||||
surSuppression(idProjet) {
|
surSuppression(idProjet) {
|
||||||
if (selection?.projet === idProjet) panneau.fermer();
|
if (curseur?.projet === idProjet) panneau.fermer();
|
||||||
majPlanning(supprimerProjet(planning, idProjet));
|
majPlanning(supprimerProjet(planning, idProjet));
|
||||||
},
|
},
|
||||||
surFermeture() {
|
surFermeture() {
|
||||||
@@ -221,6 +238,10 @@ function majPlanning(suivant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dessiner() {
|
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 aDesProjets = planning.projects.length > 0;
|
||||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||||
|
|
||||||
@@ -249,12 +270,12 @@ function dessiner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elargirPourContenir();
|
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
|
// 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.
|
// sur la frise doit se voir dans les champs.
|
||||||
if (selection) {
|
if (curseur?.phase) {
|
||||||
const trouve = localiser(selection.projet, selection.phase);
|
const trouve = localiser(curseur.projet, curseur.phase);
|
||||||
if (trouve) panneau.rafraichir(trouve.projet, trouve.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
|
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
|
||||||
// l'autre.
|
// l'autre.
|
||||||
panneauProjet.fermer();
|
panneauProjet.fermer();
|
||||||
selection = { projet: idProjet, phase: idPhase };
|
curseur = { projet: idProjet, phase: idPhase };
|
||||||
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||||||
dessiner();
|
dessiner();
|
||||||
// Après l'ouverture du panneau : il recouvre le bord droit de la frise, et
|
// 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
|
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
|
||||||
* bascule reste possible à tout moment ensuite.
|
* 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
|
// 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
|
// 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
|
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||||||
// présélectionné : l'utilisateur tape directement par-dessus.
|
// 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 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));
|
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. */
|
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||||||
function ouvrirProjet(projet, options = {}) {
|
function ouvrirProjet(projet, options = {}) {
|
||||||
panneau.fermer();
|
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);
|
panneauProjet.ouvrir(projet, options);
|
||||||
dessiner();
|
dessiner();
|
||||||
}
|
}
|
||||||
@@ -981,11 +1009,29 @@ function confirmerSuppressionProjet(projet) {
|
|||||||
async function supprimerLeProjet(projet) {
|
async function supprimerLeProjet(projet) {
|
||||||
if (!(await confirmerSuppressionProjet(projet))) return;
|
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();
|
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));
|
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 à
|
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||||||
* valider.
|
* valider.
|
||||||
@@ -1018,10 +1064,13 @@ function nouveauProjet() {
|
|||||||
// « Aujourd'hui » veut dire la même chose dans les deux vues — ramène-moi à la
|
// « 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
|
// date du jour — même si le geste diffère : un défilement horizontal sur la
|
||||||
// frise, vertical dans la liste.
|
// frise, vertical dans la liste.
|
||||||
$('aller-aujourdhui').addEventListener('click', () => {
|
function allerAujourdhui() {
|
||||||
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
|
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
|
||||||
else centrerSur(aujourdhui());
|
else centrerSur(aujourdhui());
|
||||||
});
|
}
|
||||||
|
|
||||||
|
$('aller-aujourdhui').addEventListener('click', allerAujourdhui);
|
||||||
|
$('ouvrir-aide').addEventListener('click', () => $('dialogue-aide').showModal());
|
||||||
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
||||||
$('premier-projet').addEventListener('click', nouveauProjet);
|
$('premier-projet').addEventListener('click', nouveauProjet);
|
||||||
|
|
||||||
@@ -1056,9 +1105,10 @@ refs.frise.addEventListener(
|
|||||||
{ passive: false }
|
{ 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) => {
|
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.
|
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||||||
|
|
||||||
@@ -1066,7 +1116,7 @@ document.addEventListener('keydown', (evenement) => {
|
|||||||
if (pas === 0) return;
|
if (pas === 0) return;
|
||||||
|
|
||||||
evenement.preventDefault();
|
evenement.preventDefault();
|
||||||
const trouve = localiser(selection.projet, selection.phase);
|
const trouve = localiser(curseur.projet, curseur.phase);
|
||||||
if (!trouve) return;
|
if (!trouve) return;
|
||||||
|
|
||||||
// Maj + flèche étire la phase au lieu de la déplacer.
|
// Maj + flèche étire la phase au lieu de la déplacer.
|
||||||
@@ -1075,12 +1125,386 @@ document.addEventListener('keydown', (evenement) => {
|
|||||||
: decalerAuClavier(trouve.phase, pas);
|
: decalerAuClavier(trouve.phase, pas);
|
||||||
|
|
||||||
majPlanning(
|
majPlanning(
|
||||||
remplacerProjet(planning, selection.projet, (projet) =>
|
remplacerProjet(planning, curseur.projet, (projet) =>
|
||||||
remplacerPhase(projet, selection.phase, () => suivante)
|
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
|
// Démarrage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
186
js/clavier.js
Normal file
186
js/clavier.js
Normal 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');
|
||||||
|
}
|
||||||
@@ -93,9 +93,9 @@ export function creerEchelle(fenetre) {
|
|||||||
*
|
*
|
||||||
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
|
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
|
||||||
* @param {object} planning l'état courant
|
* @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 echelle = creerEchelle(fenetre);
|
||||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||||
const visibles = retenus.filter((projet) => !projet.hidden);
|
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.couloirs.style.width = `${echelle.largeur}px`;
|
||||||
|
|
||||||
refs.entete.replaceChildren(...construireEntete(echelle));
|
refs.entete.replaceChildren(...construireEntete(echelle));
|
||||||
refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs));
|
refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs, curseur));
|
||||||
refs.couloirs.replaceChildren(
|
refs.couloirs.replaceChildren(
|
||||||
...construireTraits(echelle, hauteurTotale(visibles)),
|
...construireTraits(echelle, hauteurTotale(visibles)),
|
||||||
...construireCouloirs(visibles, echelle, selection)
|
...construireCouloirs(visibles, echelle, curseur)
|
||||||
);
|
);
|
||||||
|
|
||||||
return echelle;
|
return echelle;
|
||||||
@@ -269,21 +269,28 @@ function moisSuivant(annee, mois) {
|
|||||||
// Colonne des libellés
|
// 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 = [];
|
const elements = [];
|
||||||
|
|
||||||
for (const projet of projets) {
|
for (const projet of projets) {
|
||||||
if (projet.hidden) {
|
const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
|
||||||
elements.push(libelleProjet(projet, tagsActifs));
|
elements.push(libelleProjet(projet, tagsActifs, surLeProjet));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.push(libelleProjet(projet, tagsActifs));
|
// Un projet masqué n'a plus que sa ligne : ses phases ne se dessinent nulle
|
||||||
if (projet.collapsed) continue;
|
// 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) {
|
for (const phase of projet.phases) {
|
||||||
|
const sousCurseur = curseur && curseur.projet === projet.id && curseur.phase === phase.id;
|
||||||
const ligne = document.createElement('div');
|
const ligne = document.createElement('div');
|
||||||
ligne.className = 'libelle-phase';
|
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
|
||||||
ligne.textContent = phase.name;
|
ligne.textContent = phase.name;
|
||||||
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
|
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
|
||||||
ligne.dataset.projet = projet.id;
|
ligne.dataset.projet = projet.id;
|
||||||
@@ -295,10 +302,14 @@ function construireLibelles(projets, tagsActifs) {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
function libelleProjet(projet, tagsActifs = []) {
|
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||||
const ligne = document.createElement('div');
|
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.style.setProperty('--couleur-projet', projet.color);
|
||||||
|
ligne.dataset.projet = projet.id;
|
||||||
|
|
||||||
const plier = document.createElement('button');
|
const plier = document.createElement('button');
|
||||||
plier.className = 'plier';
|
plier.className = 'plier';
|
||||||
@@ -388,12 +399,17 @@ function libelleProjet(projet, tagsActifs = []) {
|
|||||||
// Couloirs
|
// Couloirs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function construireCouloirs(projets, echelle, selection) {
|
function construireCouloirs(projets, echelle, curseur) {
|
||||||
const elements = [];
|
const elements = [];
|
||||||
|
|
||||||
for (const projet of projets) {
|
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');
|
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);
|
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
||||||
|
|
||||||
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
|
// 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');
|
const couloir = document.createElement('div');
|
||||||
couloir.className = 'couloir';
|
couloir.className = 'couloir';
|
||||||
couloir.style.setProperty('--couleur-projet', projet.color);
|
couloir.style.setProperty('--couleur-projet', projet.color);
|
||||||
couloir.append(...construirePhase(projet, phase, echelle, selection));
|
couloir.append(...construirePhase(projet, phase, echelle, curseur));
|
||||||
elements.push(couloir);
|
elements.push(couloir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,9 +437,9 @@ function construireCouloirs(projets, echelle, selection) {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
function construirePhase(projet, phase, echelle, selection) {
|
function construirePhase(projet, phase, echelle, curseur) {
|
||||||
const selectionnee =
|
const selectionnee =
|
||||||
selection && selection.projet === projet.id && selection.phase === phase.id;
|
curseur && curseur.projet === projet.id && curseur.phase === phase.id;
|
||||||
|
|
||||||
const elements = [];
|
const elements = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user