Rendre les notes en markdown, et en donner au projet

Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.

Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.

Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).

La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.

Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.

Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:35:17 +02:00
parent e04877daae
commit ff147a17b9
16 changed files with 1150 additions and 35 deletions

View File

@@ -139,6 +139,7 @@ const panneau = creerPanneau(
titre: $('panneau-titre'),
erreur: $('erreur-phase'),
champFin: $('champ-fin'),
notes: $('notes-phase'),
supprimer: $('supprimer-phase'),
fermer: $('fermer-panneau'),
},
@@ -179,6 +180,7 @@ const panneauProjet = creerPanneauProjet(
conteneurCouleurs: $('panneau-projet-couleurs'),
suggestions: $('panneau-projet-suggestions'),
blocSuggestions: $('panneau-projet-suggestions-bloc'),
notes: $('notes-projet'),
supprimer: $('supprimer-projet'),
fermer: $('fermer-panneau-projet'),
},

View File

@@ -14,9 +14,16 @@
*/
import { STATUTS, dateValide, modifierPhase } from './model.js';
import { creerBlocNotes } from './notes.js';
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
const { panneau, formulaire, titre, erreur, champFin, notes, supprimer, fermer } = refs;
// Le bloc pose lui-même un `<textarea name="notes">` dans le formulaire, et
// émet un `input` à chaque changement — bouton de la barre d'outils ou case
// cochée comprises. Le reste du panneau continue donc de lire son formulaire
// sans rien savoir du markdown.
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes' });
let selection = null; // { projet, phase }
let phaseCourante = null;
@@ -33,7 +40,10 @@ export function creerPanneau(refs, rappels) {
formulaire.end.value = phase.end;
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
formulaire.milestone.checked = phase.milestone;
formulaire.notes.value = phase.notes;
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
// y écrire quelque chose.
blocNotes.definir(phase.notes, { reinitialiserMode: true });
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
champFin.hidden = phase.milestone;
@@ -152,9 +162,13 @@ export function creerPanneau(refs, rappels) {
const actif = document.activeElement;
phaseCourante = phase;
titre.textContent = `${projet.name}${phase.name}`;
for (const champ of ['name', 'start', 'end', 'status', 'notes']) {
for (const champ of ['name', 'start', 'end', 'status']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
}
// Le mode n'est pas réinitialisé : basculer en aperçu sous les doigts de
// qui écrit, parce qu'un glisser sur la frise a rafraîchi le panneau,
// serait le pire moment pour changer d'état.
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
champFin.hidden = phase.milestone;
},

View File

@@ -12,7 +12,18 @@
* La date de fin est *incluse* : du 01/08 au 01/08 dure un jour.
*/
export const VERSION_FORMAT = 3;
/**
* Version du format de fichier.
*
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
* là qu'est la raison d'incrémenter — le validateur reconstruit chaque projet
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
* toutes les notes de projet à la première sauvegarde.
*/
export const VERSION_FORMAT = 4;
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
@@ -347,6 +358,11 @@ export function validerPlanning(donnees) {
tags: validerTags(projet.tags, repere),
collapsed: Boolean(projet.collapsed),
hidden: Boolean(projet.hidden),
// Absentes d'un fichier en version 3, et ramenées à vide si elles ne sont
// pas du texte : une note est de la prose, comme celles d'une phase, et
// bloquer le chargement d'un planning entier pour un champ mal typé serait
// disproportionné là où le champ n'engage aucun calcul.
notes: typeof projet.notes === 'string' ? projet.notes : '',
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
phases: trierPhases(phases),
};
@@ -616,6 +632,7 @@ export function creerProjet(planning, nom, tags = []) {
tags: normaliserTags(tags),
collapsed: false,
hidden: false,
notes: '',
phases: [],
};
}

View File

@@ -29,6 +29,8 @@ import {
phasesParMois,
projetFiltre,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { rendreMarkdown } from './notes.js';
import { pastillesTags } from './tags.js';
/**
@@ -218,14 +220,20 @@ function triangle(phase) {
/**
* Les notes occupent une seconde rangée de la grille, sous toutes les colonnes.
* `white-space: pre-wrap` (style.css) leur conserve leurs retours à la ligne :
* elles sont souvent une petite liste, et la mettre à plat en changerait le
* sens.
*
* Elles sont rendues en markdown, comme dans le panneau : une note doit se lire
* pareil des deux côtés, sans quoi on douterait d'avoir la même sous les yeux.
*
* Les cases à cocher, elles, restent **inertes** ici. La vue est en lecture
* seule (décision 24), et une case cliquable y serait la seule chose qu'on
* puisse modifier — une exception isolée dans une vue dont toute la promesse est
* qu'on n'y casse rien. On les voit cochées ou non, ce qui est justement ce
* qu'on vient y chercher quand on fait le point.
*/
function notes(phase) {
const bloc = document.createElement('p');
const bloc = document.createElement('div');
bloc.className = 'mois-entree__notes';
bloc.textContent = phase.notes;
bloc.append(rendreMarkdown(phase.notes));
bloc.hidden = true;
return bloc;
}
@@ -239,7 +247,10 @@ function infobulle(projet, phase) {
: `${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
` (${jours} jour${jours > 1 ? 's' : ''})`) +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '')
// Aplati : un `title` ne connaît que le texte. Sans cela, survoler afficherait
// la source markdown, astérisques comprises — une note *moins* lisible
// qu'avant qu'on ne l'enrichisse.
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '')
);
}

533
js/notes.js Normal file
View File

@@ -0,0 +1,533 @@
/**
* Notes en markdown : rendu et bloc d'édition.
*
* Deux choses ici, qui vont ensemble. Le **rendu** d'abord — `rendreMarkdown`
* transforme l'arbre de `markdown.js` en DOM, et sert aussi bien au panneau
* qu'à la vue par mois. Le **bloc de notes** ensuite : l'aperçu, la barre
* d'outils et la zone de saisie que les deux panneaux se partagent.
*
* **Aucun `innerHTML`.** Le DOM est bâti nœud par nœud. Un planning s'échange —
* on en reçoit un par courriel, on en ouvre un que quelqu'un d'autre a écrit —
* et une note est du texte libre : la seule façon sûre d'en afficher est de ne
* jamais laisser le navigateur l'interpréter comme du balisage. Les URL sont
* filtrées en amont par `urlSure`.
*
* **On lit d'abord, on écrit ensuite.** Le bloc s'ouvre sur l'aperçu quand la
* note existe déjà, sur la saisie quand elle est vide. C'est l'usage : on ouvre
* une phase commentée pour relire pourquoi elle est bloquée, et une phase
* vierge pour y consigner quelque chose. Le crayon bascule d'un état à l'autre.
*/
import { analyserMarkdown, basculerCase } from './markdown.js';
// ---------------------------------------------------------------------------
// Rendu
// ---------------------------------------------------------------------------
/**
* Rend une note en fragment de DOM.
*
* @param {string} texte la note, en markdown.
* @param {object} options
* - casesActives : rend les cases à cocher cliquables. Faux ailleurs que dans
* le panneau — la vue par mois est en lecture seule (décision 24), et une
* case cochable y serait la seule chose qu'on puisse y modifier.
* @returns {DocumentFragment}
*/
export function rendreMarkdown(texte, { casesActives = false } = {}) {
const fragment = document.createDocumentFragment();
peindreBlocs(analyserMarkdown(texte), fragment, casesActives);
return fragment;
}
function peindreBlocs(blocs, parent, casesActives) {
for (const bloc of blocs) {
switch (bloc.type) {
case 'titre': {
// La balise est décalée de deux rangs : le titre du panneau est un
// `h2`, et une note ne peut pas ouvrir une section de rang supérieur à
// ce qui la contient. Mais le **style** suit `data-niveau`, c'est-à-dire
// le nombre de dièses écrits, et non la balise : le plafond à `h6`
// écrase les rangs 4 à 6 sur une même balise, et styler par balise
// rendait `#` plus petit que `##` — soit exactement l'inverse de ce que
// l'auteur de la note a demandé.
const titre = document.createElement(`h${Math.min(6, bloc.niveau + 2)}`);
titre.className = 'md-titre';
titre.dataset.niveau = String(bloc.niveau);
peindreInline(bloc.contenu, titre);
parent.append(titre);
break;
}
case 'paragraphe': {
const paragraphe = document.createElement('p');
paragraphe.className = 'md-paragraphe';
peindreInline(bloc.contenu, paragraphe);
parent.append(paragraphe);
break;
}
case 'liste':
parent.append(peindreListe(bloc, casesActives));
break;
case 'citation': {
const citation = document.createElement('blockquote');
citation.className = 'md-citation';
peindreBlocs(bloc.blocs, citation, casesActives);
parent.append(citation);
break;
}
case 'code': {
const preforme = document.createElement('pre');
preforme.className = 'md-code';
const code = document.createElement('code');
code.textContent = bloc.lignes.join('\n');
preforme.append(code);
parent.append(preforme);
break;
}
case 'separateur':
parent.append(document.createElement('hr'));
break;
}
}
}
function peindreListe(bloc, casesActives) {
const liste = document.createElement(bloc.ordonnee ? 'ol' : 'ul');
liste.className = 'md-liste';
// Une liste de cases à cocher perd sa puce : la case *est* la puce, et les
// deux côte à côte décalent le texte sans rien ajouter.
if (bloc.elements.some((element) => element.coche !== null)) {
liste.classList.add('md-liste--taches');
}
for (const element of bloc.elements) {
const item = document.createElement('li');
if (element.coche === null) {
peindreInline(element.contenu, item);
} else {
item.append(peindreTache(element, casesActives));
}
peindreBlocs(element.blocs, item, casesActives);
liste.append(item);
}
return liste;
}
/**
* Élément de liste porteur d'une case.
*
* La case et son texte sont dans un même `<label>` : toute la ligne devient
* cliquable, ce qui vaut mieux qu'une cible de treize pixels de côté. Le numéro
* de ligne voyage dans un `data-` — c'est lui que le bloc de notes rendra à
* `basculerCase` pour réécrire la bonne ligne de la note.
*/
function peindreTache(element, casesActives) {
const etiquette = document.createElement('label');
etiquette.className = 'md-tache';
const coche = document.createElement('input');
coche.type = 'checkbox';
coche.checked = element.coche;
coche.dataset.ligne = String(element.ligne);
coche.disabled = !casesActives;
etiquette.append(coche);
const texte = document.createElement('span');
if (element.coche) texte.className = 'md-tache--faite';
peindreInline(element.contenu, texte);
etiquette.append(texte);
return etiquette;
}
function peindreInline(noeuds, parent) {
for (const noeud of noeuds) {
switch (noeud.type) {
case 'texte':
parent.append(document.createTextNode(noeud.valeur));
break;
case 'saut':
parent.append(document.createElement('br'));
break;
case 'gras': {
const gras = document.createElement('strong');
peindreInline(noeud.contenu, gras);
parent.append(gras);
break;
}
case 'italique': {
const italique = document.createElement('em');
peindreInline(noeud.contenu, italique);
parent.append(italique);
break;
}
case 'code': {
const code = document.createElement('code');
code.className = 'md-code-ligne';
code.textContent = noeud.valeur;
parent.append(code);
break;
}
case 'lien': {
const lien = document.createElement('a');
lien.href = noeud.url;
// Le planning s'ouvre dans un onglet où l'on travaille : suivre un lien
// ne doit pas le remplacer. `noopener` coupe l'accès de la page ouverte
// à celle-ci, `noreferrer` lui tait d'où l'on vient.
lien.target = '_blank';
lien.rel = 'noopener noreferrer';
lien.title = noeud.url;
peindreInline(noeud.contenu, lien);
parent.append(lien);
break;
}
}
}
}
// ---------------------------------------------------------------------------
// Bloc de notes
// ---------------------------------------------------------------------------
/**
* Barre d'outils, volontairement courte.
*
* Six boutons pour ce qu'on écrit vraiment dans une note de suivi. Le markdown
* complet est accessible au clavier — c'est du texte —, la barre ne sert qu'à
* ne pas avoir à se rappeler la syntaxe des quatre ou cinq formes courantes.
* Une barre exhaustive prendrait deux rangées dans un volet de 340 pixels et
* repousserait la note d'autant.
*/
const OUTILS = [
{ cle: 'gras', libelle: 'B', titre: 'Gras (Ctrl+B)', entoure: '**' },
{ cle: 'italique', libelle: 'I', titre: 'Italique (Ctrl+I)', entoure: '*' },
{ cle: 'titre', libelle: 'H', titre: 'Titre', prefixe: '## ' },
{ cle: 'liste', libelle: '•', titre: 'Liste à puces', prefixe: '- ' },
{ cle: 'tache', libelle: '☑', titre: 'Case à cocher', prefixe: '- [ ] ' },
{ cle: 'lien', libelle: '🔗', titre: 'Lien', lien: true },
];
/**
* Construit le bloc de notes dans son hôte et renvoie de quoi le piloter.
*
* @param {HTMLElement} hote conteneur, vidé puis rempli.
* @param {object} options
* - nom : attribut `name` de la zone de saisie. Le bloc vit dans un `<form>`,
* et c'est ce nom qui le rend lisible par `formulaire.notes.value` — les
* deux panneaux continuent donc de lire leur formulaire comme avant.
* - libelle : intitulé affiché.
*
* Toute modification — frappe, bouton de la barre, case cochée — émet un
* `input` qui remonte au formulaire. Les panneaux appliquent déjà les
* changements au fil de la saisie : ils n'ont rien à savoir de ce bloc.
*/
export function creerBlocNotes(hote, { nom = 'notes', libelle = 'Notes' } = {}) {
hote.classList.add('notes');
hote.replaceChildren();
const entete = document.createElement('div');
entete.className = 'notes__entete';
const intitule = document.createElement('span');
intitule.className = 'notes__intitule';
intitule.textContent = libelle;
entete.append(intitule);
const outils = document.createElement('div');
outils.className = 'notes__outils';
outils.hidden = true;
entete.append(outils);
const bascule = document.createElement('button');
bascule.type = 'button';
bascule.className = 'notes__bascule';
entete.append(bascule);
const apercu = document.createElement('div');
apercu.className = 'notes__apercu';
const champ = document.createElement('textarea');
champ.className = 'notes__saisie';
champ.name = nom;
champ.hidden = true;
champ.spellcheck = true;
hote.append(entete, apercu, champ);
let enEdition = false;
function afficherMode() {
outils.hidden = !enEdition;
champ.hidden = !enEdition;
apercu.hidden = enEdition;
bascule.textContent = enEdition ? '👁' : '✎';
bascule.title = enEdition ? 'Aperçu' : 'Modifier';
bascule.setAttribute('aria-label', bascule.title);
bascule.setAttribute('aria-pressed', String(enEdition));
}
function peindreApercu() {
apercu.replaceChildren();
if (!champ.value.trim()) {
// Un aperçu vide n'apprend rien et n'offre aucune prise. Cette invite est
// à la fois le message et le bouton : cliquer n'importe où dans la zone
// vide ouvre la saisie, ce qui est le seul geste qu'on puisse y vouloir.
const invite = document.createElement('p');
invite.className = 'notes__invite';
invite.textContent = 'Aucune note — cliquer pour écrire.';
apercu.append(invite);
return;
}
apercu.append(rendreMarkdown(champ.value, { casesActives: true }));
}
/** Signale au formulaire hôte que la note a changé. */
function signaler() {
champ.dispatchEvent(new Event('input', { bubbles: true }));
}
function passerEnEdition({ focus = true } = {}) {
enEdition = true;
afficherMode();
if (focus) champ.focus();
}
bascule.addEventListener('click', () => {
if (enEdition) {
enEdition = false;
peindreApercu();
afficherMode();
} else {
passerEnEdition();
}
});
// Cliquer dans le vide de l'aperçu ouvre la saisie ; cliquer un lien ou une
// case, non — ces deux-là ont déjà leur propre effet, et basculer en édition
// sous le doigt de qui vient de cocher serait une trahison du geste.
apercu.addEventListener('click', (evenement) => {
if (evenement.target.closest('a, .md-tache')) return;
passerEnEdition();
});
apercu.addEventListener('change', (evenement) => {
const coche = evenement.target;
if (!coche.matches('input[type="checkbox"]')) return;
// Seule écriture qui ne passe pas par `remplacer` : le champ est masqué en
// mode aperçu, il ne peut donc pas prendre le focus dont `execCommand` a
// besoin. Cocher une case vide la pile d'annulation du champ — sans grande
// conséquence, puisqu'on n'était pas en train d'y taper.
champ.value = basculerCase(champ.value, Number(coche.dataset.ligne));
// On repeint : la bascule a pu ne rien changer — note modifiée ailleurs
// entre le rendu et le clic —, et la case doit alors revenir à l'état que
// le texte décrit vraiment, pas à celui que le navigateur vient de cocher.
peindreApercu();
signaler();
});
outils.addEventListener('mousedown', (evenement) => {
// Sans cela, le bouton prendrait le focus et la sélection du textarea
// s'évanouirait avant que l'action ne s'applique.
if (evenement.target.closest('button')) evenement.preventDefault();
});
outils.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('button');
if (!bouton) return;
appliquerOutil(champ, OUTILS.find((outil) => outil.cle === bouton.dataset.outil));
signaler();
});
for (const outil of OUTILS) {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.dataset.outil = outil.cle;
bouton.textContent = outil.libelle;
bouton.title = outil.titre;
bouton.setAttribute('aria-label', outil.titre);
outils.append(bouton);
}
champ.addEventListener('keydown', (evenement) => {
if (!evenement.ctrlKey && !evenement.metaKey) return;
const cle = evenement.key.toLowerCase() === 'b' ? 'gras'
: evenement.key.toLowerCase() === 'i' ? 'italique'
: null;
if (!cle) return;
evenement.preventDefault();
appliquerOutil(champ, OUTILS.find((outil) => outil.cle === cle));
signaler();
});
afficherMode();
peindreApercu();
return {
champ,
/**
* Charge une note.
*
* @param {object} options
* - reinitialiserMode : choisit le mode d'après le contenu. Vrai à
* l'ouverture du panneau, faux quand la note est rechargée depuis
* ailleurs — refermer l'aperçu sur les doigts de qui tape serait le
* pire moment pour changer de mode.
*/
definir(texte, { reinitialiserMode = false } = {}) {
// Réécrire la valeur d'un textarea y replace le curseur à la fin : on
// s'en abstient quand rien n'a changé, ce qui est le cas le plus fréquent
// lors d'un rafraîchissement venu de la frise.
if (champ.value !== (texte ?? '')) champ.value = texte ?? '';
if (reinitialiserMode) enEdition = !champ.value.trim();
if (!enEdition) peindreApercu();
afficherMode();
},
/** Repeint l'aperçu après une frappe, quand il est visible. */
synchroniser() {
if (!enEdition) peindreApercu();
},
};
}
/**
* Applique une action de la barre d'outils à la sélection courante.
*
* Trois formes, selon l'outil : entourer la sélection, préfixer les lignes
* qu'elle touche, ou insérer un lien. Dans les trois cas la sélection reste
* cohérente après coup — on continue de taper là où on en était.
*/
function appliquerOutil(champ, outil) {
if (!outil) return;
if (outil.entoure) return entourer(champ, outil.entoure);
if (outil.prefixe) return prefixer(champ, outil.prefixe);
if (outil.lien) return insererLien(champ);
}
/**
* Remplace une plage du champ **sans rompre la pile d'annulation** du
* navigateur.
*
* Affecter `champ.value` vide cette pile : `Ctrl+Z` cesse alors de remonter au
* delà de l'action, et la frappe qui précédait devient irrécupérable. C'est un
* prix qu'on ne peut pas faire payer à un champ de saisie — annuler est le
* réflexe le plus élémentaire qu'on ait devant du texte.
*
* `execCommand` n'a pas de remplaçant pour cet usage. Il est marqué obsolète et
* reste pourtant la seule façon d'écrire dans un `textarea` en laissant le
* navigateur enregistrer l'opération comme si elle avait été tapée. Les API qui
* devaient lui succéder (`InputEvent` synthétiques) ne modifient rien : le
* navigateur ignore un événement qu'il n'a pas produit lui-même. Écrire notre
* propre historique reviendrait à réimplémenter `Ctrl+Z`, `Ctrl+Y` et leur
* fusion des frappes voisines — pour un champ de notes, la dépense est sans
* commune mesure avec le service.
*
* D'où le repli sur l'affectation directe si la commande échoue : on perd
* l'annulation, jamais la saisie.
*
* Le curseur est laissé à la fin de l'insertion ; c'est à l'appelant de le
* replacer, ce qui ne touche pas à l'historique.
*/
function remplacer(champ, debut, fin, texte) {
champ.focus();
champ.setSelectionRange(debut, fin);
// `execCommand` écrit là où est le focus, pas dans l'élément qu'on lui
// désigne — il n'en prend aucun. Si le champ n'a pas pu le prendre (il est
// masqué en mode aperçu), la commande irait écrire dans le champ « Nom » ou
// dans le dernier élément actif. On vérifie donc, plutôt que d'insérer du
// markdown au hasard dans le panneau.
if (document.activeElement === champ) {
// Une insertion vide n'en est pas une : c'est une suppression, et
// `insertText` ne la traite pas partout.
const commande = texte ? 'insertText' : 'delete';
try {
if (document.execCommand(commande, false, texte)) return;
} catch {
// Commande refusée : on retombe sur l'affectation ci-dessous.
}
}
const valeur = champ.value;
champ.value = valeur.slice(0, debut) + texte + valeur.slice(fin);
champ.setSelectionRange(debut + texte.length, debut + texte.length);
}
function entourer(champ, marque) {
const { selectionStart: debut, selectionEnd: fin, value: texte } = champ;
const selection = texte.slice(debut, fin);
// Sélection déjà entourée : le bouton retire la marque. C'est ce qu'on attend
// d'un bouton à deux états, et sans cela « gras » deux fois de suite produit
// un `****` qui n'a plus rien de gras.
const dedans =
texte.slice(debut - marque.length, debut) === marque &&
texte.slice(fin, fin + marque.length) === marque;
if (dedans) {
remplacer(champ, debut - marque.length, fin + marque.length, selection);
champ.setSelectionRange(debut - marque.length, fin - marque.length);
} else {
remplacer(champ, debut, fin, marque + selection + marque);
// Sans sélection, le curseur se pose entre les deux marques : le bouton
// arme la mise en forme et on tape dedans.
champ.setSelectionRange(debut + marque.length, fin + marque.length);
}
}
/** Pose (ou retire) un préfixe en tête de chaque ligne touchée par la sélection. */
function prefixer(champ, prefixe) {
const texte = champ.value;
const debutLigne = texte.lastIndexOf('\n', champ.selectionStart - 1) + 1;
const finBrute = texte.indexOf('\n', champ.selectionEnd);
const finLigne = finBrute < 0 ? texte.length : finBrute;
const lignes = texte.slice(debutLigne, finLigne).split('\n');
const toutesPrefixees = lignes.every((ligne) => ligne.startsWith(prefixe));
const modifiees = lignes.map((ligne) =>
toutesPrefixees ? ligne.slice(prefixe.length) : prefixe + ligne
);
const remplacement = modifiees.join('\n');
remplacer(champ, debutLigne, finLigne, remplacement);
champ.setSelectionRange(debutLigne, debutLigne + remplacement.length);
}
/**
* Insère `[libellé](url)` et présélectionne la partie à remplir : l'adresse si
* un libellé était sélectionné, le libellé sinon. On tape aussitôt, sans avoir
* à viser au curseur entre deux parenthèses.
*/
function insererLien(champ) {
const { selectionStart: debut, selectionEnd: fin, value: texte } = champ;
const selection = texte.slice(debut, fin);
const libelle = selection || 'texte';
const insertion = `[${libelle}](https://)`;
remplacer(champ, debut, fin, insertion);
if (selection) {
const debutUrl = debut + libelle.length + 3;
champ.setSelectionRange(debutUrl + 'https://'.length, debutUrl + 'https://'.length);
} else {
champ.setSelectionRange(debut + 1, debut + 1 + libelle.length);
}
}

View File

@@ -10,6 +10,8 @@
* docs/decisions.md, section 21.
*/
import { creerBlocNotes } from './notes.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
panneau,
@@ -19,11 +21,14 @@ export function creerPanneauProjet(refs, outils, rappels) {
conteneurCouleurs,
suggestions,
blocSuggestions,
notes,
supprimer,
fermer,
} = refs;
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
const blocNotes = creerBlocNotes(notes, { libelle: 'Notes du projet' });
let idCourant = null;
let projetCourant = null;
@@ -105,6 +110,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
formulaire.tags.value = projet.tags.join(', ');
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
afficherErreur(null);
@@ -132,6 +138,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
...projetCourant,
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
...forces,
};
@@ -144,6 +151,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
) {
return;
@@ -211,6 +219,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
const actif = document.activeElement;
if (formulaire.name !== actif) formulaire.name.value = projet.name;
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
},

View File

@@ -29,6 +29,7 @@ import {
semaineISO,
versUTC,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { pastillesTags } from './tags.js';
/**
@@ -321,6 +322,10 @@ function libelleProjet(projet, tagsActifs = []) {
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
// pouvoir apprendre quoi, et comment revenir en arrière.
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
@@ -439,7 +444,13 @@ function construirePhase(projet, phase, echelle, selection) {
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
losange.dataset.role = 'barre';
losange.title = `${phase.name}${formaterDateLongue(phase.start)}`;
// Un jalon porte des notes comme une tâche — souvent les plus utiles, un
// jalon étant justement le point où l'on consigne une décision. Son
// infobulle les taisait, alors que celle d'une barre les montrait.
losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange);
const nom = document.createElement('span');
@@ -461,7 +472,11 @@ function construirePhase(projet, phase, echelle, selection) {
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '');
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
// source markdown rendrait la note moins lisible qu'avant qu'on ne
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
// portent du sens — une puce pour un tiret, une case dessinée pour `[x]`.
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
// Le nom vit dans son propre élément plutôt que directement dans la barre :
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous