Bonanza des boutons Composer

J’ai cloné ce TC dans mon dépôt GitHub à l’adresse GitHub - denvergeeks/DiscourseComposerButtonBonanza et j’y ai appliqué des correctifs pour ces erreurs [car l’accès au service Codeberg.org ne semblait pas fonctionner pour le forking.]

Voici le journal des commits de mes modifications :

Si vous avez rencontré des erreurs avec DCBB et/ou avez même des correctifs pour ces erreurs, ce serait formidable si vous pouviez ouvrir un problème en amont pour signaler tout cela, afin que je puisse les corriger — au lieu de simplement cloner le dépôt ailleurs et de dire aux gens d’utiliser votre clone. (Si vous ne pouvez pas forker le dépôt sur Codeberg pour une raison quelconque, ce serait également bien d’en être informé directement, au lieu de le savoir incidemment via un sujet de discussion sur lequel je ne reçois pas toujours les mises à jour par e-mail.)

Oh, tiens… juste au moment où je m’apprête à appuyer sur Répondre, une PR est apparue ! Merci !

Cette PR aborde-t-elle certains des problèmes survenant avec <div> par rapport à <span> sous le Compositeur WYSIWYG (ce qui a tourmenté @shauny) — ou aborde-t-elle une autre erreur/dysfonctionnement, ou aborde-t-elle uniquement les avertissements de dépréciation (c’est-à-dire les choses qui casseront d’un jour à l’autre, mais qui ne sont pas encore cassées) ?

J’aimerais comprendre ce qui a changé en coulisses. Tout pointeur vers des sujets ou la documentation de Discourse serait très apprécié.

J’ai travaillé avec ChatGPT pour créer une solution que je peux simplement déposer dans mon fichier JS.

Ce que cela fait, c’est intercepter le bouton “Spoiler” et déclencher à la place le bouton par défaut.

J’aimerais voir cela intégré au plugin, s’il n’y a pas d’autres moyens de le faire (idéalement, au lieu d’invoquer le menu et de forcer un clic sur le bouton, cela déclencherait simplement la même chose que ce que le bouton déclenche).

Voici le code que j’ai ajouté au fichier JS de mon thème :

import { apiInitializer } from "discourse/lib/api";

export default apiInitializer((api) => {
  /**
   * === Correction du Spoiler RTE pour le Bonanza de Boutons du Compositeur ===
   *
   * Bureau : fonctionne en cliquant sur Options, puis en cliquant sur "Flouter le spoiler" (Blur spoiler).
   * Mobile : la structure du menu déroulant diffère, nous devons donc localiser l'élément par son texte, et non par des sélecteurs stricts.
   *
   */

  const BONANZA_SPOILER_SELECTOR = "button.ComposerButtonBonanza-btn-spoiler";

  const OPTIONS_TRIGGER_SELECTORS = [
    'button[aria-label="Options"]',
    'button[title="Options"]',
    'button[aria-label*="options" i]',
    'button[title*="options" i]',
  ].join(",");

  function isRichTextComposerContext(el) {
    const composer = el.closest(".d-editor, .composer, .composer-controls");
    if (!composer) return false;

    const hasProseMirror = !!composer.querySelector(".ProseMirror");
    const hasTextarea = !!composer.querySelector("textarea");
    return hasProseMirror && !hasTextarea;
  }

  function getComposerRoot(el) {
    return el.closest(".d-editor, .composer, .composer-controls") || document;
  }

  function normalizeText(s) {
    return (s || "").trim().replace(/\s+/g, " ");
  }

  function isVisible(el) {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    if (!style) return false;
    if (style.display === "none" || style.visibility === "hidden") return false;
    const r = el.getBoundingClientRect?.();
    return !!r && r.width > 0 && r.height > 0;
  }

  function openOptionsMenu(composerRoot) {
    const btn = composerRoot.querySelector(OPTIONS_TRIGGER_SELECTORS);
    if (!btn) return false;
    btn.click();
    return true;
  }

  function findOpenDropdownMenuRoot() {
    // Sur Discourse, les menus déroulants utilisent généralement l'un de ces conteneurs.
    // Nous choisissons le dernier visible (le plus récemment ouvert).
    const candidates = [
      ...document.querySelectorAll(".dropdown-menu, .dropdown-menu__items, .dropdown-menu__content"),
    ].filter(isVisible);

    return candidates.length ? candidates[candidates.length - 1] : null;
  }

  function findBlurSpoilerButton(menuRoot) {
    if (!menuRoot) return null;

    // Premièrement, essayez la structure de bureau (chemin rapide)
    const exact =
      menuRoot.querySelector('button[title="Blur spoiler"]') ||
      menuRoot.querySelector('button[aria-label="Blur spoiler"]');
    if (exact) return exact;

    // Sinon, localisez par contenu textuel n'importe où à l'intérieur des éléments du menu déroulant
    const itemRoots = Array.from(
      menuRoot.querySelectorAll(".dropdown-menu__item, li, div, button")
    );

    for (const node of itemRoots) {
      const text = normalizeText(node.textContent).toLowerCase();
      if (text === "blur spoiler" || text.includes("blur spoiler")) {
        // Cliquez sur le bouton réel le plus proche
        const btn = node.closest("button") || node.querySelector?.("button");
        if (btn) return btn;
        // Si c'est déjà un élément de type bouton, retournez-le
        if (node.tagName === "BUTTON") return node;
      }
    }

    // Dernier recours : analyser tous les boutons du menu par texte d'étiquette
    const buttons = Array.from(menuRoot.querySelectorAll("button"));
    for (const btn of buttons) {
      const t = normalizeText(btn.textContent).toLowerCase();
      const title = normalizeText(btn.getAttribute("title") || "").toLowerCase();
      const aria = normalizeText(btn.getAttribute("aria-label") || "").toLowerCase();

      if (t.includes("blur spoiler") || title === "blur spoiler" || aria === "blur spoiler") {
        return btn;
      }
    }

    return null;
  }

  function clickBlurSpoilerFromOpenMenu() {
    const menuRoot = findOpenDropdownMenuRoot();
    const btn = findBlurSpoilerButton(menuRoot);
    if (!btn) return false;

    // Certains menus déroulants mobiles nécessitent un tick pour terminer le positionnement/l'attachement des gestionnaires
    requestAnimationFrame(() => {
      requestAnimationFrame(() => btn.click());
    });

    return true;
  }

  function openOptionsAndClickBlurSpoiler(composerRoot) {
    if (!openOptionsMenu(composerRoot)) return false;

    // Réessayer jusqu'à ce que le menu et l'élément existent
    let tries = 0;
    const maxTries = 30;

    const attempt = () => {
      tries++;
      if (clickBlurSpoilerFromOpenMenu()) return;
      if (tries < maxTries) setTimeout(attempt, 35);
    };

    setTimeout(attempt, 0);
    return true;
  }

  document.addEventListener(
    "click",
    (e) => {
      const bonanzaBtn = e.target.closest(BONANZA_SPOILER_SELECTOR);
      if (!bonanzaBtn) return;

      // Ne remplacer que dans le contexte RTE ; le mode markdown laisse Bonanza tranquille.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Empêcher l'insertion de BBCode de Bonanza dans RTE
      e.preventDefault();
      e.stopImmediatePropagation();

      const composerRoot = getComposerRoot(bonanzaBtn);
      openOptionsAndClickBlurSpoiler(composerRoot);
    },
    true
  );
});

C’est bancal, mais ça fonctionne. Testé uniquement sur iOS/macOS Safari et macOS Chrome, pas encore sur Android.

1 « J'aime »

Je viens de publier la version 2.0.0 de Composer Button Bonanza. Le seul changement est la correction de l’utilisation obsolète de site.desktopView. Consultez le commit source pour plus de détails.

J’ai mis à jour cet avertissement. Après avoir examiné les problèmes, je pense que c’est en fait l’inverse : l’éditeur de texte enrichi n’est pas compatible avec ce composant de thème, car son implémentation ProsemirrorTextManipulation de l’interface TextManipulation est incomplète et/ou incorrecte.

En particulier :

  • L’implémentation de ProsemirrorTextManipulation.applyList() n’utilise pas tout à fait le paramètre head fourni par l’appelant. Au lieu de cela, elle examine la clé pour le texte d’exemple fourni par l’appelant afin de deviner ce que l’appelant essaie de faire, et elle est codée en dur pour ne comprendre que les boutons intégrés pour les listes à puces, les listes ordonnées et les blocs de citation.
  • L’implémentation de ProsemirrorTextManipulation.applySurround() ne correspond pas au comportement de l’implémentation d’origine de TextareaTextManipulation.applySurround(), et est responsable de l’utilisation indiscriminée de <div> même lorsqu’elle devrait utiliser <span>. L’implémentation Prosemirror ignore également l’argument opts de applySurround(). (Et, en utilisant la même astuce que applyList(), elle code en dur les clés de texte d’exemple pour détecter les boutons pour l’italique, le gras et le texte préformaté.)

@renato, ces problèmes sont-ils à l’étude ? Y a-t-il un calendrier pour les résoudre ?

4 « J'aime »

Pouvez-vous partager ce pour quoi vous en avez exactement besoin ?

Certaines API construites lorsqu’il n’y avait que l’éditeur de zone de texte ne sont pas vraiment conçues pour avoir une parité complète avec l’éditeur riche ; notre intention n’est pas d’apporter toute la puissance de ProseMirror à une abstraction intermédiaire.

Nous pouvons améliorer ces endroits si possible et nécessaire, mais en général, lorsque nous avons besoin d’opérations complexes, nous faisons généralement appel directement aux dépendances de ProseMirror via une clé commands sur une extension d’éditeur riche enregistrée. Par exemple :

Dans cet exemple, applySurround applique aveuglément le bbcode de spoiler à tout texte sélectionné, tandis que toggleSpoiler possède toutes les fonctionnalités de ProseMirror pour déterminer s’il est déjà à l’intérieur d’un nœud spoiler, s’il s’agit d’un spoiler en ligne ou d’un spoiler de bloc, etc.

2 « J'aime »

Si ces deux méthodes étaient implémentées avec plus de fidélité à l’interface à laquelle elles appartiennent, je pense que Composer Button Bonanza fonctionnerait à peu près « tout simplement » dans le nouvel éditeur de texte enrichi (du moins aussi bien que dans le mode Markdown). Je suis surpris qu’aucun autre auteur de composant de thème ou de plugin n’ait soulevé de problèmes similaires jusqu’à présent. (Bien que, peut-être que oui ; je n’ai pas essayé de rechercher des plaintes similaires.)

J’ignore ce qu’implique « toute la puissance de ProseMirror », mais je doute que ce soit nécessaire. Les interfaces applyList() et applySurround() ne sont pas si compliquées — bien qu’elles comportent plus que ce qui a été implémenté jusqu’à présent.

(Ce qui a été implémenté jusqu’à présent semble être moins une « application du balisage de liste à la sélection » ou un « application du balisage de texte entourant la sélection » fondé sur des principes, qu’un simple « envoi des appels à partir des boutons de la barre d’outils intégrés connus vers des fonctions spécifiques de prosemirror ».)

2 « J'aime »

Salut !

C’est un composant fantastique que j’ai négligé.

Je travaillais sur ma propre version d’un composant de thème similaire, qui permet d’ajouter des raccourcis clavier personnalisés pour des wraps personnalisés (soit de type [wrap], soit n’importe quel contenu HTML / Markdown / spécifique à Discourse autorisé).

Par exemple, je peux définir Ctrl+Maj+K pour insérer ou envelopper la sélection avec <kbd> </kbd>, ou <hr>, ou [wrap=announcement] [/wrap] (en ligne ou en bloc), ou tout ce que nous souhaitons, et j’ai également ajouté des boutons optionnels dans le menu engrenage.
Il prend en charge les sauts de ligne avec \n dans le contenu que nous insérons.

Il est également compatible avec l’éditeur riche. Voici le code à titre de référence : GitHub - Canapin/discourse-custom-shortcuts · GitHub, avec un avertissement : il a été généré par Claude et je n’ai pas encore examiné le code. De plus, si je devais continuer à développer ma propre solution au lieu d’utiliser la vôtre, je modifierais probablement ou ajouterais des fonctionnalités pour réutiliser certaines des vôtres.

Voici à quoi ressemble mon composant :




Pour en revenir à votre composant, j’aime sa polyvalence, mais il me manque deux éléments fondamentaux :

  • La prise en charge de l’éditeur riche
  • Un moyen facile d’ajouter du contenu personnalisé (wraps personnalisés, balises, ou autres)

Bien que je puisse coder mes propres choses de manière informelle, je ne me sens pas assez à l’aise pour me fier à l’IA afin de proposer des pull requests (par exemple pour la prise en charge de l’éditeur riche) pour votre composant, et je ne suis pas programmeur.

Outre la question de l’éditeur riche, quelle est votre opinion sur la faisabilité d’ajouter de nouveaux contenus personnalisés depuis votre composant (essentiellement ce que j’ai décrit pour mon propre composant) ? Serait-ce accepté ou cela sort-il de son périmètre ?

1 « J'aime »

Je ne comprends pas tout à fait votre question — Composer Button Bonanza ne prend-il pas déjà cela en charge ?

Avez-vous essayé de personnaliser les paramètres du composant, en particulier le paramètre « Boutons » ?

  • « Boutons » sert à définir les boutons (en réalité, les fonctions).
  • « Mise en page » permet de spécifier où et quand ils apparaissent dans l’interface (ainsi que de définir des raccourcis).
  • « Traductions » sert à ajouter des traductions aux définitions des boutons.
2 « J'aime »

Ooooh oui, j’ai complètement manqué ce paramètre ! Je vais y jeter un coup d’œil plus approfondi, cela pourrait répondre à mes besoins :handshake:

1 « J'aime »

J’ai du mal à faire fonctionner le raccourci clavier.

image

Le bouton est présent dans la barre d’outils et fonctionne, mais Ctrl+Shift+k ne fait rien. Une idée ?

1 « J'aime »

Ooooh, désolé ! J’ai fait une erreur dans la documentation des paramètres de disposition : les spécificateurs de raccourci doivent utiliser un signe plus, et non un trait d’union.

Par exemple : shift+k (et pas shift-k).

(Je mettrai à jour la documentation de la page des paramètres dans les jours à venir.)

1 « J'aime »

Fait ; la version 2.0.1 est publiée. (Cela ajoute également plus de documentation dans l’ensemble et corrige certains bugs liés au style CSS des nouveaux boutons.)

1 « J'aime »