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>
534 lines
19 KiB
JavaScript
534 lines
19 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|