L'extensibiilté est l'une des caractéristiques les plus puisssantes de l'éditeur de texte enrichi Quill.js, principalement grâce à son système de plugins, ou comme Quill les appelle, les "Modules". Cet article explore le processus de création de modules personnalisés en se basant sur un exemple pratique de redimensionnement d'image.
Quill.js intègre cinq modules fondamentaux :
- TOOLBAR : Pour la gestion de la barre d'outils.
- KEYBOARD : Pour la configuration des raccourcis clavier.
- HISTORY : Gère les fonctionnalités d'annulation et de rétablissement.
- CLIPBOARD : Pour la personnalisation du comportement du copier-coller.
- SYNTAX : Pour la coloration syntaxique.
L'exemple qui suit s'inspire de modules populaires comme quill-image-resize-module.
Structure de base d'un Module
Commençons par mettre en place la structure minimale d'un module Quill. Créez un fichier nommé modules/redimensionnement-image.ts :
export default class ModuleRedimensionnementImage {
private editeurQuill: any;
private optionsModule: any;
constructor(quillInstance: any, options: any = {}) {
// Stocke l'instance de Quill et les options du module
this.editeurQuill = quillInstance;
this.optionsModule = options;
this.initialiserModule();
}
initialiserModule(): void {
console.log('ModuleRedimensionnementImage initialisé avec les options :', this.optionsModule);
// C'est ici que la logique principale du module commencera
}
}
Ensuite, intégrez ce module dans votre application React avec Quill. Dans App.tsx, par exemple :
import React, { useRef, useState, useMemo } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/snow.css'; // ou tout autre thème
import Quill from 'quill'; // Assurez-vous d'importer Quill lui-même
import ModuleRedimensionnementImage from './modules/redimensionnement-image';
// Enregistrez votre module personnalisé auprès de Quill
Quill.register('modules/redimensionnement', ModuleRedimensionnementImage);
function Application() {
const [contenuHtml, setContenuHtml] = useState('');
const editorRef = useRef(null);
const configurationModules = useMemo(() => ({
toolbar: {
container: '#barre-outils-personnalisee', // Assurez-vous d'avoir un élément avec cet ID
},
redimensionnement: { // 'redimensionnement' est le nom que nous avons utilisé lors de l'enregistrement
parametreExemple: 'valeur test', // Vous pouvez passer des options personnalisées ici
tailleMinimale: 50,
}
}), []);
// Composant de barre d'outils factice pour l'exemple
const BarreOutilsPersonnalisee = () => (
<div id="barre-outils-personnalisee">
<button className="ql-bold">Bold</button>
<button className="ql-italic">Italic</button>
</div>
);
return (
<div className={'conteneur-editeur'}>
<BarreOutilsPersonnalisee />
<ReactQuill
ref={editorRef}
theme="snow"
value={contenuHtml}
modules={configurationModules}
onChange={setContenuHtml}
/>
</div>
);
}
export default Application;
Ce squelette permet d'ajouter n'importe quelle fonctionnalité à votre éditeur Quill.
Interaction avec les images
Pour implémenter le redimensionnement, nous devons d'abord permettre l'interaction avec les images, c'est-à-dire les "activer" lorsqu'elles sont cliquées. Cela implique d'ajouter des écouteurs d'événements.
export default class ModuleRedimensionnementImage {
private editeurQuill: any;
private elementActif: HTMLElement | null = null; // L'élément d'image actuellement sélectionné
private blotAssociee: any = null; // Le blot Quill associé à l'élément actif
private cacheRedimensionnement: HTMLElement | null = null; // L'élément de superposition pour le redimensionnement
constructor(quillInstance: any, options: any = {}) {
this.editeurQuill = quillInstance;
this.editeurQuill.root.addEventListener('mousedown', this.gererClicGlobal, false);
// ... autres initialisations
}
// Gère les clics de souris n'importe où dans l'éditeur
gererClicGlobal = (evenement: MouseEvent) => {
let estActivable = false;
let blotCible;
const elementCible = evenement.target as HTMLElement;
if (elementCible && elementCible.tagName === 'IMG') { // Nous nous intéressons uniquement aux images
blotCible = this.editeurQuill.constructor.find(elementCible);
if (blotCible) {
// Vérifie si l'image doit être activée pour le redimensionnement
estActivable = this.verifierActivation(blotCible, elementCible);
}
}
if (estActivable) {
evenement.preventDefault(); // Empêche le comportement par défaut (ex: sélection de texte)
return;
}
// Si un élément était actif et qu'on clique ailleurs, désactive-le
if (this.elementActif) {
this.masquerCache();
this.elementActif.classList.remove('quill-image-active'); // Supprime la classe d'activation
this.elementActif = null;
this.blotAssociee = null;
}
};
// Détermine si une image donnée doit être mise en surbrillance pour l'interaction
verifierActivation = (blot: any, element: HTMLElement): boolean => {
let doitActiver = false;
if (!blot || !element) return doitActiver;
// Si l'image cliquée est déjà l'image active, ne faites rien
if (this.elementActif === element) return true;
// Supposons une taille minimale pour le redimensionnement, par exemple 10px
const tailleMinimale = this.optionsModule.tailleMinimale || 10;
if (element.offsetWidth >= tailleMinimale) {
doitActiver = true;
// Masque l'ancienne sélection si une image différente est cliquée
if (this.elementActif) {
this.masquerCache();
this.elementActif.classList.remove('quill-image-active');
}
this.elementActif = element;
this.blotAssociee = blot;
this.elementActif.classList.add('quill-image-active'); // Ajoute une classe pour le style actif
this.afficherCache(); // Affiche la superposition de redimensionnement
}
return doitActiver;
};
}
La méthode afficherCache est responsable de la création et du positionnement d'une superposition autour de l'image sélectionnée, ainsi que de la gestion des événements pour maintenir cette superposition à jour ou la masquer.
export default class ModuleRedimensionnementImage {
// ... autres propriétés et méthodes
afficherCache = () => {
if (this.cacheRedimensionnement) {
this.masquerCache(); // S'assurer qu'un seul cache est actif à la fois
}
this.editeurQuill.setSelection(null); // Désélectionne tout texte
this.editeurQuill.container.style.userSelect = 'none'; // Empêche la sélection de l'utilisateur
this.cacheRedimensionnement = document.createElement('div');
Object.assign(this.cacheRedimensionnement.style, {
position: 'absolute',
border: '1px solid #47A9E1', // Exemple de style de bordure
boxSizing: 'border-box',
pointerEvents: 'none', // Permet de cliquer à travers le cache si nécessaire
});
this.editeurQuill.root.parentNode.appendChild(this.cacheRedimensionnement);
// Écoute les changements dans l'éditeur pour masquer le cache si le contenu est modifié
this.editeurQuill.root.addEventListener('input', this.masquerCacheApresModification, true);
// Écoute le défilement pour repositionner le cache
this.editeurQuill.root.addEventListener('scroll', this.mettreAJourPositionCache);
this.positionnerElements(); // Positionne le cache et les poignées de redimensionnement
};
masquerCache = () => {
if (!this.cacheRedimensionnement) return;
this.editeurQuill.root.parentNode.removeChild(this.cacheRedimensionnement);
this.cacheRedimensionnement = null;
this.editeurQuill.container.style.userSelect = ''; // Rétablit la sélection de l'utilisateur
this.editeurQuill.root.removeEventListener('input', this.masquerCacheApresModification, true);
this.editeurQuill.root.removeEventListener('scroll', this.mettreAJourPositionCache);
// Si vous avez des poignées de redimensionnement, assurez-vous de les nettoyer ici aussi
// ...
};
masquerCacheApresModification = () => {
if (!this.elementActif) return;
this.masquerCache();
this.elementActif.classList.remove('quill-image-active');
this.elementActif = null;
this.blotAssociee = null;
};
// Positionne le cache en fonction de l'image active
mettreAJourPositionCache = () => {
if (!this.elementActif || !this.cacheRedimensionnement) return;
const rectImage = this.elementActif.getBoundingClientRect();
const rectEditeur = this.editeurQuill.root.parentNode.getBoundingClientRect();
Object.assign(this.cacheRedimensionnement.style, {
left: `${rectImage.left - rectEditeur.left - 1}px`, // -1 pour la bordure
top: `${rectImage.top - rectEditeur.top - 1}px`,
width: `${rectImage.width + 2}px`, // +2 pour les bordures
height: `${rectImage.height + 2}px`,
});
// ... Mettre à jour la position des poignées de redimensionnement si elles existent
};
positionnerElements = () => {
// Appelle la fonction de mise à jour de la position pour le cache et les poignées
this.mettreAJourPositionCache();
// ... initialiser et positionner les poignées de redimensionnement ici
}
}
Ajout des fonctionnalités de redimensionnement
Nous allons maintenant créer un module interne, GestionnaireRedimensionnement, qui sera instancié par notre ModuleRedimensionnementImage pour gérer les poignées et la logique de glisser-déposer. Cela permet de séparer les préoccupations.
Créez un nouveau fichier modules/gestionnaire-redimensionnement.ts :
export default class GestionnaireRedimensionnement {
private moduleParent: any; // Référence au ModuleRedimensionnementImage parent
private poignees: HTMLElement[] = [];
private poigneeActive: HTMLElement | null = null;
private coordXDebutGlisser: number = 0;
private coordYDebutGlisser: number = 0;
private tailleImageAvantGlisser: { width: number; height: number; } = { width: 0, height: 0 };
private ratioInitial: number = 0;
private largeurMaxEditeur: number = 0;
constructor(moduleRedimensionnementImage: any) {
this.moduleParent = moduleRedimensionnementImage;
this.initialiserPoignees();
}
initialiserPoignees = () => {
// Les styles des poignées peuvent être passés via les options du module parent
const stylesPoignee = {
width: '10px',
height: '10px',
backgroundColor: '#47A9E1',
border: '1px solid #FFF',
position: 'absolute',
boxSizing: 'border-box',
opacity: '0.8',
cursor: 'nwse-resize', // Curseur par défaut
};
// Ajout des 4 poignees (coin supérieur gauche, supérieur droit, inférieur droit, inférieur gauche)
this.ajouterPoignee('nwse-resize', stylesPoignee); // Haut-gauche
this.ajouterPoignee('nesw-resize', stylesPoignee); // Haut-droite
this.ajouterPoignee('nwse-resize', stylesPoignee); // Bas-droite
this.ajouterPoignee('nesw-resize', stylesPoignee); // Bas-gauche
this.positionnerPoignees();
};
ajouterPoignee = (typeCurseur: string, styles: any) => {
const poignee = document.createElement('div');
Object.assign(poignee.style, styles);
poignee.style.cursor = typeCurseur;
poignee.addEventListener('mousedown', this.gererDebutRedimensionnement, false);
this.moduleParent.cacheRedimensionnement.appendChild(poignee); // Ajoute au cache parent
this.poignees.push(poignee);
};
positionnerPoignees = () => {
if (!this.moduleParent.elementActif || !this.moduleParent.cacheRedimensionnement) return;
const rectCache = this.moduleParent.cacheRedimensionnement.getBoundingClientRect();
const taillePoignee = parseInt(this.poignees[0].style.width, 10); // Supposons que toutes les poignées ont la même taille
// Positions des 4 poignées
const positions = [
{ top: -taillePoignee / 2, left: -taillePoignee / 2 }, // Haut-gauche
{ top: -taillePoignee / 2, left: rectCache.width - taillePoignee / 2 }, // Haut-droite
{ top: rectCache.height - taillePoignee / 2, left: rectCache.width - taillePoignee / 2 }, // Bas-droite
{ top: rectCache.height - taillePoignee / 2, left: -taillePoignee / 2 }, // Bas-gauche
];
this.poignees.forEach((poignee, index) => {
Object.assign(poignee.style, {
top: `${positions[index].top}px`,
left: `${positions[index].left}px`,
});
});
};
gererDebutRedimensionnement = (evenement: MouseEvent) => {
evenement.stopPropagation(); // Empêche le clic de se propager à l'image elle-même ou à l'éditeur
this.poigneeActive = evenement.target as HTMLElement;
this.coordXDebutGlisser = evenement.clientX;
this.coordYDebutGlisser = evenement.clientY;
if (this.moduleParent.elementActif) {
this.tailleImageAvantGlisser = {
width: this.moduleParent.elementActif.offsetWidth,
height: this.moduleParent.elementActif.offsetHeight,
};
this.ratioInitial = this.tailleImageAvantGlisser.height / this.tailleImageAvantGlisser.width;
this.largeurMaxEditeur = this.moduleParent.editeurQuill.container.clientWidth - 30; // 30px de marge
document.addEventListener('mousemove', this.gererDeplacementSouris, false);
document.addEventListener('mouseup', this.gererFinRedimensionnement, false);
}
};
gererDeplacementSouris = (evenement: MouseEvent) => {
if (!this.poigneeActive || !this.moduleParent.elementActif) return;
let deltaX = evenement.clientX - this.coordXDebutGlisser;
let deltaY = evenement.clientY - this.coordYDebutGlisser;
let nouvelleLargeur = this.tailleImageAvantGlisser.width;
let nouvelleHauteur = this.tailleImageAvantGlisser.height;
// Ajuste deltaX et deltaY en fonction de la poignée déplacée
switch (this.poigneeActive.style.cursor) {
case 'nwse-resize': // Haut-gauche ou Bas-droite
// Pour les coins, nous devons considérer les deux axes pour maintenir le ratio
nouvelleLargeur = this.tailleImageAvantGlisser.width + deltaX;
nouvelleHauteur = this.tailleImageAvantGlisser.height + deltaY;
break;
case 'nesw-resize': // Haut-droite ou Bas-gauche
nouvelleLargeur = this.tailleImageAvantGlisser.width - deltaX;
nouvelleHauteur = this.tailleImageAvantGlisser.height + deltaY; // Inverse deltaX pour l'autre côté
break;
// Vous pourriez ajouter des cas pour redimensionner seulement en largeur ou hauteur
}
// Maintien du ratio et limites
if (this.ratioInitial > 0) {
nouvelleHauteur = nouvelleLargeur * this.ratioInitial;
}
// Applique des limites (taille minimale et maximale)
nouvelleLargeur = Math.max(10, Math.min(nouvelleLargeur, this.largeurMaxEditeur));
nouvelleHauteur = Math.max(10, nouvelleHauteur);
// Met à jour la taille de l'image
Object.assign(this.moduleParent.elementActif.style, {
width: `${nouvelleLargeur}px`,
height: `${nouvelleHauteur}px`,
});
// Repositionne le cache et les poignées
this.moduleParent.mettreAJourPositionCache();
this.positionnerPoignees();
};
gererFinRedimensionnement = () => {
document.removeEventListener('mousemove', this.gererDeplacementSouris, false);
document.removeEventListener('mouseup', this.gererFinRedimensionnement, false);
this.poigneeActive = null;
// Après redimensionnement, met à jour le style de l'image dans le Delta de Quill
if (this.moduleParent.blotAssociee && this.moduleParent.elementActif) {
this.moduleParent.blotAssociee.format('width', `${this.moduleParent.elementActif.offsetWidth}px`);
this.moduleParent.blotAssociee.format('height', `${this.moduleParent.elementActif.offsetHeight}px`);
}
};
nettoyer = () => {
this.poignees.forEach(poignee => {
poignee.removeEventListener('mousedown', this.gererDebutRedimensionnement, false);
if (poignee.parentNode) {
poignee.parentNode.removeChild(poignee);
}
});
this.poignees = [];
}
}
Pour intégrer ce gestionnaire de redimensionnement dans ModuleRedimensionnementImage, modifiez ce dernier pour l'instancier lorsque le cache est affiché et le nettoyer lorsqu'il est masqué.
// Dans modules/redimensionnement-image.ts
import GestionnaireRedimensionnement from './gestionnaire-redimensionnement';
export default class ModuleRedimensionnementImage {
// ... autres propriétés
private gestionnaireRedimensionnement: GestionnaireRedimensionnement | null = null;
afficherCache = () => {
// ... code existant pour afficher le cache
// Initialise le gestionnaire de redimensionnement une fois que le cache est prêt
if (this.cacheRedimensionnement) {
this.gestionnaireRedimensionnement = new GestionnaireRedimensionnement(this);
this.mettreAJourPositionCache(); // Assure un positionnement initial correct
}
};
masquerCache = () => {
if (!this.cacheRedimensionnement) return;
// Nettoie les poignées et les écouteurs du gestionnaire de redimensionnement
if (this.gestionnaireRedimensionnement) {
this.gestionnaireRedimensionnement.nettoyer();
this.gestionnaireRedimensionnement = null;
}
// ... code existant pour masquer le cache
};
// Assurez-vous que le gestionnaire de redimensionnement repositionne aussi ses poignées
mettreAJourPositionCache = () => {
// ... code existant pour positionner le cache
if (this.gestionnaireRedimensionnement) {
this.gestionnaireRedimensionnement.positionnerPoignees();
}
};
}
Ce processus de développement de modules vous donne un contrôle précis sur le comportement de Quill. En utilisant des modules, il est possible d'ajouter une multitude de fonctionnalités, de la prévisualisation d'images à l'ajout de boutons d'action contextuels ou l'extension à d'autres types de médias comme les vidéos.