Développement d'extensions personnalisées pour Quill.js : Un module de redimensionnement d'image

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.

Étiquettes: Quill.js JavaScript React frontend Custom Modules

Publié le 28 juillet à 10h09