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:
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');
|
||||
}
|
||||
Reference in New Issue
Block a user