/** * 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** ce que porte le curseur — la phase, ou le * projet entier quand il est sur sa ligne (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'); }