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:
458
js/app.js
458
js/app.js
@@ -10,12 +10,14 @@
|
||||
import { creerPanneau } from './detail.js';
|
||||
import { creerPanneauProjet } from './projet.js';
|
||||
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
||||
import { installerClavier } from './clavier.js';
|
||||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||||
import { PIXELS_PAR_JOUR, rendre } from './timeline.js';
|
||||
import { rendreMois, revelerMoisCourant } from './mois.js';
|
||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||
import {
|
||||
COULEURS,
|
||||
STATUTS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
aujourdhui,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
figerReference,
|
||||
formaterDateLongue,
|
||||
libererReference,
|
||||
modifierPhase,
|
||||
normaliserTags,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
@@ -62,7 +65,20 @@ const refs = {
|
||||
|
||||
let planning = planningVide();
|
||||
let echelle = null;
|
||||
let selection = null; // { projet, phase }
|
||||
|
||||
/**
|
||||
* Curseur clavier : la ligne de la frise sur laquelle portent les raccourcis.
|
||||
*
|
||||
* `{ projet, phase }`, où `phase` vaut `null` quand le curseur est posé sur la
|
||||
* ligne d'un projet — position à part entière, d'où l'on plie, on renomme et on
|
||||
* ajoute une phase.
|
||||
*
|
||||
* Il ne se confond plus avec « le panneau est ouvert sur cette phase », comme
|
||||
* c'était le cas de l'ancienne `selection` : un curseur qui s'effaçait à la
|
||||
* fermeture du panneau ne pouvait pas servir de point de départ aux
|
||||
* déplacements. Il survit donc à `Échap`, et c'est le panneau qui le suit.
|
||||
*/
|
||||
let curseur = null;
|
||||
|
||||
/**
|
||||
* Vue affichée : 'frise' ou 'mois'. État de vue, comme le filtre et la fenêtre
|
||||
@@ -164,8 +180,9 @@ const panneau = creerPanneau(
|
||||
remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase))
|
||||
);
|
||||
},
|
||||
// Le curseur n'est pas effacé : `Échap` referme le volet et laisse la
|
||||
// frappe suivante repartir de la phase qu'on regardait.
|
||||
surFermeture() {
|
||||
selection = null;
|
||||
dessiner();
|
||||
},
|
||||
}
|
||||
@@ -195,7 +212,7 @@ const panneauProjet = creerPanneauProjet(
|
||||
return confirmerSuppressionProjet(projet);
|
||||
},
|
||||
surSuppression(idProjet) {
|
||||
if (selection?.projet === idProjet) panneau.fermer();
|
||||
if (curseur?.projet === idProjet) panneau.fermer();
|
||||
majPlanning(supprimerProjet(planning, idProjet));
|
||||
},
|
||||
surFermeture() {
|
||||
@@ -221,6 +238,10 @@ function majPlanning(suivant) {
|
||||
}
|
||||
|
||||
function dessiner() {
|
||||
// Avant tout rendu : le curseur peut désigner une ligne que le changement
|
||||
// vient d'emporter — phase supprimée, projet replié, filtre resserré.
|
||||
normaliserCurseur();
|
||||
|
||||
const aDesProjets = planning.projects.length > 0;
|
||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||
|
||||
@@ -249,12 +270,12 @@ function dessiner() {
|
||||
}
|
||||
|
||||
elargirPourContenir();
|
||||
echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
|
||||
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
|
||||
|
||||
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
|
||||
// sur la frise doit se voir dans les champs.
|
||||
if (selection) {
|
||||
const trouve = localiser(selection.projet, selection.phase);
|
||||
if (curseur?.phase) {
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
|
||||
}
|
||||
|
||||
@@ -741,7 +762,7 @@ function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) {
|
||||
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
|
||||
// l'autre.
|
||||
panneauProjet.fermer();
|
||||
selection = { projet: idProjet, phase: idPhase };
|
||||
curseur = { projet: idProjet, phase: idPhase };
|
||||
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||||
dessiner();
|
||||
// Après l'ouverture du panneau : il recouvre le bord droit de la frise, et
|
||||
@@ -879,13 +900,17 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
|
||||
* bascule reste possible à tout moment ensuite.
|
||||
*/
|
||||
function ajouterUnePhase(projet) {
|
||||
function ajouterUnePhase(projet, { debut = null } = {}) {
|
||||
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
|
||||
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
|
||||
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||||
// présélectionné : l'utilisateur tape directement par-dessus.
|
||||
//
|
||||
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
|
||||
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
|
||||
// « après » ne s'expriment que par une date, jamais par un rang.
|
||||
const derniere = projet.phases[projet.phases.length - 1];
|
||||
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||||
if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||||
|
||||
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||||
|
||||
@@ -906,6 +931,9 @@ function ajouterUnePhase(projet) {
|
||||
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||||
function ouvrirProjet(projet, options = {}) {
|
||||
panneau.fermer();
|
||||
// Ouvrir les paramètres pose le curseur sur la ligne du projet, à la souris
|
||||
// comme au clavier : le curseur désigne toujours ce que le volet montre.
|
||||
curseur = { projet: projet.id, phase: null };
|
||||
panneauProjet.ouvrir(projet, options);
|
||||
dessiner();
|
||||
}
|
||||
@@ -981,11 +1009,29 @@ function confirmerSuppressionProjet(projet) {
|
||||
async function supprimerLeProjet(projet) {
|
||||
if (!(await confirmerSuppressionProjet(projet))) return;
|
||||
|
||||
if (selection?.projet === projet.id) panneau.fermer();
|
||||
if (curseur?.projet === projet.id) panneau.fermer();
|
||||
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
|
||||
|
||||
// Le curseur va se retrouver sur une ligne qui n'existe plus : on le pose sur
|
||||
// le projet suivant — ou le précédent s'il n'y en a pas — plutôt que de
|
||||
// l'effacer, pour pouvoir enchaîner les `dd` sans repasser par la souris.
|
||||
//
|
||||
// Avant `majPlanning`, pas après : le voisin survit à la suppression, donc
|
||||
// `normaliserCurseur` le laissera en place, et un seul rendu suffit.
|
||||
if (curseur?.projet === projet.id) curseur = projetVoisin(projet.id);
|
||||
|
||||
majPlanning(supprimerProjet(planning, projet.id));
|
||||
}
|
||||
|
||||
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
|
||||
function projetVoisin(idProjet) {
|
||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||||
const index = retenus.findIndex((p) => p.id === idProjet);
|
||||
if (index === -1) return null;
|
||||
const voisin = retenus[index + 1] ?? retenus[index - 1];
|
||||
return voisin ? { projet: voisin.id, phase: null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||||
* valider.
|
||||
@@ -1018,10 +1064,13 @@ function nouveauProjet() {
|
||||
// « Aujourd'hui » veut dire la même chose dans les deux vues — ramène-moi à la
|
||||
// date du jour — même si le geste diffère : un défilement horizontal sur la
|
||||
// frise, vertical dans la liste.
|
||||
$('aller-aujourdhui').addEventListener('click', () => {
|
||||
function allerAujourdhui() {
|
||||
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
|
||||
else centrerSur(aujourdhui());
|
||||
});
|
||||
}
|
||||
|
||||
$('aller-aujourdhui').addEventListener('click', allerAujourdhui);
|
||||
$('ouvrir-aide').addEventListener('click', () => $('dialogue-aide').showModal());
|
||||
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
||||
$('premier-projet').addEventListener('click', nouveauProjet);
|
||||
|
||||
@@ -1056,9 +1105,10 @@ refs.frise.addEventListener(
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Flèches : ajustement fin de la phase sélectionnée, au jour près.
|
||||
// Flèches : ajustement fin de la phase du curseur, au jour près.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (!selection) return;
|
||||
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
|
||||
if (!curseur?.phase) return;
|
||||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||||
|
||||
@@ -1066,7 +1116,7 @@ document.addEventListener('keydown', (evenement) => {
|
||||
if (pas === 0) return;
|
||||
|
||||
evenement.preventDefault();
|
||||
const trouve = localiser(selection.projet, selection.phase);
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (!trouve) return;
|
||||
|
||||
// Maj + flèche étire la phase au lieu de la déplacer.
|
||||
@@ -1075,12 +1125,386 @@ document.addEventListener('keydown', (evenement) => {
|
||||
: decalerAuClavier(trouve.phase, pas);
|
||||
|
||||
majPlanning(
|
||||
remplacerProjet(planning, selection.projet, (projet) =>
|
||||
remplacerPhase(projet, selection.phase, () => suivante)
|
||||
remplacerProjet(planning, curseur.projet, (projet) =>
|
||||
remplacerPhase(projet, curseur.phase, () => suivante)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curseur clavier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lignes de la frise, dans l'ordre où elles s'affichent.
|
||||
*
|
||||
* Même énumération que `construireLibelles` (timeline.js) : une ligne par projet
|
||||
* retenu par le filtre, suivie de ses phases quand il est déplié et non masqué.
|
||||
* Les deux doivent rester d'accord, le rang d'une ligne ici servant d'indice
|
||||
* dans les enfants de la colonne pour amener le défilement dessus.
|
||||
*/
|
||||
function lignesFrise() {
|
||||
const lignes = [];
|
||||
|
||||
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
|
||||
lignes.push({ projet: projet.id, phase: null });
|
||||
if (projet.hidden || projet.collapsed) continue;
|
||||
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
|
||||
}
|
||||
|
||||
return lignes;
|
||||
}
|
||||
|
||||
function memeLigne(a, b) {
|
||||
return Boolean(a && b && a.projet === b.projet && a.phase === b.phase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ramène le curseur sur une ligne qui existe encore.
|
||||
*
|
||||
* Replier un projet, resserrer le filtre ou supprimer une phase peut faire
|
||||
* disparaître la ligne sous le curseur. On retombe alors sur la ligne du projet,
|
||||
* qui survit à ces trois cas, et on n'efface qu'en dernier recours : perdre sa
|
||||
* place est plus coûteux que la voir remonter d'un cran.
|
||||
*/
|
||||
function normaliserCurseur() {
|
||||
if (!curseur) return;
|
||||
|
||||
const lignes = lignesFrise();
|
||||
if (lignes.some((ligne) => memeLigne(ligne, curseur))) return;
|
||||
|
||||
const surLeProjet = { projet: curseur.projet, phase: null };
|
||||
curseur = lignes.some((ligne) => memeLigne(ligne, surLeProjet)) ? surLeProjet : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose le curseur sur une ligne, amène la vue dessus, et fait suivre le panneau
|
||||
* s'il est ouvert.
|
||||
*
|
||||
* Ce suivi n'est pas un ornement : les raccourcis agissent sur le curseur, et un
|
||||
* volet resté sur une autre phase donnerait deux cibles concurrentes à l'écran —
|
||||
* on éditerait dans les champs une phase que `Espace` ou `dd` n'atteindraient
|
||||
* pas. Ouvert, le panneau est donc la vue détaillée du curseur, et `j` `k`
|
||||
* deviennent le moyen de passer les phases en revue une à une.
|
||||
*/
|
||||
function poserCurseur(ligne) {
|
||||
if (!ligne) return;
|
||||
curseur = ligne;
|
||||
|
||||
if (refs.panneau.hidden && !panneauProjet.projetOuvert()) dessiner();
|
||||
else ouvrirSousCurseur();
|
||||
|
||||
revelerCurseur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre le volet qui correspond à la ligne du curseur : le détail d'une phase,
|
||||
* les paramètres d'un projet.
|
||||
*
|
||||
* @param {boolean} saisir place le curseur de frappe dans le champ « Nom ».
|
||||
* Vrai quand la frappe le demande (`Entrée`, `e`), faux quand le volet ne fait
|
||||
* que suivre un déplacement du curseur — et la nuance porte tout le clavier :
|
||||
* prendre le focus au passage de `j` enverrait la frappe suivante dans le
|
||||
* champ, où les raccourcis se taisent. On ne saisirait plus jamais qu'un nom.
|
||||
*/
|
||||
function ouvrirSousCurseur({ saisir = false } = {}) {
|
||||
if (!curseur) return;
|
||||
|
||||
if (curseur.phase) {
|
||||
ouvrirPhase(curseur.projet, curseur.phase, { reveler: true, nomAEcraser: saisir });
|
||||
return;
|
||||
}
|
||||
|
||||
const projet = planning.projects.find((p) => p.id === curseur.projet);
|
||||
if (projet) ouvrirProjet(projet, { nomAEcraser: saisir });
|
||||
}
|
||||
|
||||
/** Le projet auquel appartient la ligne du curseur, ou `null`. */
|
||||
function projetDuCurseur() {
|
||||
if (!curseur) return null;
|
||||
return planning.projects.find((p) => p.id === curseur.projet) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amène la ligne du curseur dans le champ de vision.
|
||||
*
|
||||
* Verticalement toujours : sans cela `j` sortirait de l'écran au bout de quinze
|
||||
* lignes. Horizontalement seulement si la barre est entièrement hors cadre —
|
||||
* recentrer à chaque pas ferait sauter la frise sous les yeux alors qu'on ne
|
||||
* fait que descendre une colonne.
|
||||
*/
|
||||
function revelerCurseur() {
|
||||
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
|
||||
|
||||
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
|
||||
const element = refs.libelles.children[index];
|
||||
if (element) revelerLigne(element);
|
||||
|
||||
if (!curseur.phase) return;
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (trouve && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Défile le minimum pour qu'une ligne de la colonne soit entièrement visible.
|
||||
*
|
||||
* Les positions se lisent à l'écran plutôt que dans `offsetTop` : la colonne est
|
||||
* `sticky` dans une grille, et son parent de référence n'est pas celui qu'on
|
||||
* croirait. La hauteur de l'en-tête est retranchée du haut utile — il est collé
|
||||
* par-dessus la frise, et une ligne amenée au ras du bord se glisserait dessous.
|
||||
*/
|
||||
function revelerLigne(element) {
|
||||
const ligne = element.getBoundingClientRect();
|
||||
const zone = refs.frise.getBoundingClientRect();
|
||||
const hautUtile = zone.top + refs.entete.offsetHeight;
|
||||
|
||||
if (ligne.top < hautUtile) refs.frise.scrollTop -= hautUtile - ligne.top;
|
||||
else if (ligne.bottom > zone.bottom) refs.frise.scrollTop += ligne.bottom - zone.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déplace le curseur de `pas` lignes, sans boucler d'un bout à l'autre.
|
||||
*
|
||||
* Pas de curseur encore posé : la première frappe le fait apparaître sur la
|
||||
* première ligne, plutôt que de ne rien faire et de laisser croire que les
|
||||
* raccourcis ne marchent pas.
|
||||
*/
|
||||
function deplacerCurseur(pas) {
|
||||
// La vue par mois n'a pas de curseur : elle se lit, elle ne s'édite pas. `j`
|
||||
// et `k` y font donc ce qu'ils font dans un document — ils déroulent la liste.
|
||||
if (vue === 'mois') {
|
||||
refs.vueMois.scrollTop += pas * PAS_DEFILEMENT_MOIS;
|
||||
return;
|
||||
}
|
||||
|
||||
const lignes = lignesFrise();
|
||||
if (!lignes.length) return;
|
||||
|
||||
if (!curseur) {
|
||||
poserCurseur(pas < 0 ? lignes[lignes.length - 1] : lignes[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
|
||||
const suivant = Math.min(lignes.length - 1, Math.max(0, index + pas));
|
||||
poserCurseur(lignes[suivant]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `h` et `l` : la phase précédente ou suivante **du même projet**, sans jamais
|
||||
* déborder sur le voisin — c'est ce qui les distingue de `k` et `j`. La ligne du
|
||||
* projet est la butée haute : `h` depuis sa première phase y remonte, et `l`
|
||||
* depuis elle redescend sur cette première phase.
|
||||
*/
|
||||
function deplacerDansLeProjet(pas) {
|
||||
const projet = projetDuCurseur();
|
||||
if (!projet) {
|
||||
deplacerCurseur(pas);
|
||||
return;
|
||||
}
|
||||
|
||||
const lignes = lignesFrise().filter((ligne) => ligne.projet === projet.id);
|
||||
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
|
||||
const suivant = index + pas;
|
||||
if (suivant < 0 || suivant >= lignes.length) return;
|
||||
poserCurseur(lignes[suivant]);
|
||||
}
|
||||
|
||||
/** `{` et `}` : la ligne du projet précédent ou suivant. */
|
||||
function deplacerDeProjet(pas) {
|
||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||||
if (!retenus.length) return;
|
||||
|
||||
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
|
||||
// Sans curseur, `}` doit atteindre le premier projet et non le second.
|
||||
const index = depart === -1 ? (pas > 0 ? 0 : retenus.length - 1) : depart + pas;
|
||||
const projet = retenus[Math.min(retenus.length - 1, Math.max(0, index))];
|
||||
poserCurseur({ projet: projet.id, phase: null });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raccourcis à la vim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Décalage horizontal de la frise, en jours. */
|
||||
function defilerDe(jours) {
|
||||
if (vue !== 'frise' || refs.frise.hidden) return;
|
||||
refs.frise.scrollLeft += jours * PIXELS_PAR_JOUR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pas de `j` et `k` dans la vue par mois, où ils déroulent la liste faute de
|
||||
* curseur à déplacer. Trois lignes environ : assez pour avancer, assez peu pour
|
||||
* ne pas perdre le fil de ce qu'on lisait.
|
||||
*/
|
||||
const PAS_DEFILEMENT_MOIS = 96;
|
||||
|
||||
/**
|
||||
* Plie ou déplie le projet du curseur.
|
||||
*
|
||||
* Depuis une phase, le pli emporte la ligne où l'on se trouve : le curseur monte
|
||||
* sur celle du projet, seule position qui reste. Un second `za` déplie, et un
|
||||
* `l` ramène dans le projet — l'aller-retour se fait sans la souris.
|
||||
*/
|
||||
function basculerPli(projet, replie) {
|
||||
if (replie) curseur = { projet: projet.id, phase: null };
|
||||
majPlanning(remplacerProjet(planning, projet.id, (p) => ({ ...p, collapsed: replie })));
|
||||
revelerCurseur();
|
||||
}
|
||||
|
||||
/** `zR` et `zM` : déplie ou plie tous les projets d'un coup. */
|
||||
function plierTous(replie) {
|
||||
majPlanning({
|
||||
...planning,
|
||||
projects: planning.projects.map((projet) => ({ ...projet, collapsed: replie })),
|
||||
});
|
||||
revelerCurseur();
|
||||
}
|
||||
|
||||
/** `Espace` : fait tourner le statut de la phase du curseur. */
|
||||
function statutSuivant() {
|
||||
if (!curseur?.phase) return;
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (!trouve) return;
|
||||
|
||||
const suivant = STATUTS[(STATUTS.indexOf(trouve.phase.status) + 1) % STATUTS.length];
|
||||
majPlanning(
|
||||
remplacerProjet(planning, curseur.projet, (projet) =>
|
||||
remplacerPhase(projet, curseur.phase, (phase) => modifierPhase(phase, { status: suivant }))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `a`, `A`, `o`, `O` : quatre façons d'ajouter une phase, qui ne diffèrent que
|
||||
* par la date de départ.
|
||||
*
|
||||
* Les phases sont triées par date (`model.js`) : insérer « avant » ou « après »
|
||||
* ne peut donc se dire qu'en dates, pas en rangs. Une nouvelle phase dure vingt
|
||||
* jours, comme celle du bouton `+`, et se pose bord à bord avec sa voisine.
|
||||
*
|
||||
* Depuis la ligne d'un projet, `o` et `O` n'ont pas de phase de référence : ils
|
||||
* retombent sur la fin et le début du projet, ce que `A` et `a` font déjà.
|
||||
*/
|
||||
function ajouterAuClavier(ou) {
|
||||
const projet = projetDuCurseur();
|
||||
if (!projet) return;
|
||||
|
||||
const phase = curseur.phase ? localiser(projet.id, curseur.phase)?.phase : null;
|
||||
const premiere = projet.phases[0];
|
||||
|
||||
let debut = null;
|
||||
if (ou === 'apres' && phase) debut = ajouterJours(phase.end, 1);
|
||||
else if (ou === 'avant' && phase) debut = ajouterJours(phase.start, -21);
|
||||
else if (ou === 'debut' && premiere) debut = ajouterJours(premiere.start, -21);
|
||||
|
||||
ajouterUnePhase(projet, { debut });
|
||||
}
|
||||
|
||||
/** `dd` : supprime la phase du curseur, ou son projet depuis la ligne du projet. */
|
||||
async function supprimerAuClavier() {
|
||||
const projet = projetDuCurseur();
|
||||
if (!projet) return;
|
||||
|
||||
if (!curseur.phase) {
|
||||
await supprimerLeProjet(projet);
|
||||
return;
|
||||
}
|
||||
|
||||
const trouve = localiser(projet.id, curseur.phase);
|
||||
if (!trouve) return;
|
||||
const cible = curseur;
|
||||
|
||||
const ok = await demanderConfirmation({
|
||||
titreTexte: 'Supprimer la phase',
|
||||
messageTexte: `« ${trouve.phase.name} » sera définitivement supprimée du planning.`,
|
||||
libelleValider: 'Supprimer',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok || !memeLigne(curseur, cible)) return;
|
||||
|
||||
// Le curseur se pose sur la phase suivante du même projet, ce qui permet
|
||||
// d'enchaîner les suppressions sans repasser par la souris. En bout de liste
|
||||
// il n'y en a pas, et `normaliserCurseur` le rattrapera sur la ligne du
|
||||
// projet. Le déplacement précède la modification : la ligne visée survit, un
|
||||
// seul rendu suffit donc à faire les deux.
|
||||
const lignes = lignesFrise();
|
||||
const suivante = lignes[lignes.findIndex((ligne) => memeLigne(ligne, cible)) + 1];
|
||||
if (suivante?.projet === projet.id) curseur = suivante;
|
||||
|
||||
panneau.fermer();
|
||||
majPlanning(remplacerProjet(planning, projet.id, (p) => supprimerPhase(p, cible.phase)));
|
||||
revelerCurseur();
|
||||
}
|
||||
|
||||
installerClavier({
|
||||
// Déplacements
|
||||
ligneSuivante: () => deplacerCurseur(1),
|
||||
lignePrecedente: () => deplacerCurseur(-1),
|
||||
phaseSuivante: () => deplacerDansLeProjet(1),
|
||||
phasePrecedente: () => deplacerDansLeProjet(-1),
|
||||
projetSuivant: () => deplacerDeProjet(1),
|
||||
projetPrecedent: () => deplacerDeProjet(-1),
|
||||
premiereLigne: () => {
|
||||
if (vue === 'mois') refs.vueMois.scrollTop = 0;
|
||||
else poserCurseur(lignesFrise()[0]);
|
||||
},
|
||||
derniereLigne: () => {
|
||||
if (vue === 'mois') refs.vueMois.scrollTop = refs.vueMois.scrollHeight;
|
||||
else poserCurseur(lignesFrise().at(-1));
|
||||
},
|
||||
|
||||
// Plis et cadrage
|
||||
basculerPli: () => {
|
||||
const projet = projetDuCurseur();
|
||||
if (projet) basculerPli(projet, !projet.collapsed);
|
||||
},
|
||||
deplierTout: () => plierTous(false),
|
||||
plierTout: () => plierTous(true),
|
||||
recentrer: () => {
|
||||
if (!curseur) return;
|
||||
const trouve = curseur.phase ? localiser(curseur.projet, curseur.phase) : null;
|
||||
if (trouve) centrerSur(trouve.phase.start);
|
||||
centrerLigne();
|
||||
},
|
||||
|
||||
// Temps et vues
|
||||
moisSuivant: () => defilerDe(30),
|
||||
moisPrecedent: () => defilerDe(-30),
|
||||
anneeSuivante: () => defilerDe(365),
|
||||
anneePrecedente: () => defilerDe(-365),
|
||||
allerAujourdhui: () => allerAujourdhui(),
|
||||
basculerVue: () => basculerVue(vue === 'frise' ? 'mois' : 'frise'),
|
||||
|
||||
// Actions sur la ligne du curseur
|
||||
editer: () => ouvrirSousCurseur({ saisir: true }),
|
||||
ajouterFin: () => ajouterAuClavier('fin'),
|
||||
ajouterDebut: () => ajouterAuClavier('debut'),
|
||||
ajouterApres: () => ajouterAuClavier('apres'),
|
||||
ajouterAvant: () => ajouterAuClavier('avant'),
|
||||
nouveauProjet,
|
||||
supprimer: () => supprimerAuClavier(),
|
||||
statutSuivant,
|
||||
|
||||
aide: () => $('dialogue-aide').showModal(),
|
||||
});
|
||||
|
||||
/** `zz` : amène la ligne du curseur au milieu de la hauteur visible. */
|
||||
function centrerLigne() {
|
||||
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
|
||||
|
||||
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
|
||||
const element = refs.libelles.children[index];
|
||||
if (!element) return;
|
||||
|
||||
const ligne = element.getBoundingClientRect();
|
||||
const zone = refs.frise.getBoundingClientRect();
|
||||
const entete = refs.entete.offsetHeight;
|
||||
const centre = zone.top + entete + (zone.height - entete) / 2;
|
||||
|
||||
refs.frise.scrollTop += ligne.top + ligne.height / 2 - centre;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Démarrage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user