Bonanza del botón Composer

He hecho un clon de este TC en mi repositorio de GitHub en GitHub - denvergeeks/DiscourseComposerButtonBonanza y luego apliqué correcciones para estos errores [porque el acceso al servicio Codeberg.org parecía no funcionar para bifurcar.]

Aquí está el registro de confirmación de mis cambios:

Si ha experimentado errores con DCBB y/o incluso tiene soluciones para esos errores, sería genial si pudiera abrir un problema upstream para informar de todo esto, de modo que pueda solucionarlos, en lugar de simplemente clonar el repositorio en otro lugar y decir a la gente que use su clon. (Si no puede bifurcar el repositorio en Codeberg por alguna razón, también sería bueno que me lo dijeran directamente, en lugar de incidentalmente a través de un tema de discusión sobre el que no siempre recibo actualizaciones por correo electrónico).

¡Oh, vaya… justo cuando voy a pulsar Responder, ha aparecido una PR! ¡Gracias!

¿Esta PR aborda alguno de los problemas que ocurren con <div> frente a <span> bajo el WYSIWYG-Composer (lo que ha afectado a @shauny) o aborda algún otro error/disfunción, o solo aborda las advertencias de obsolescencia (es decir, cosas que se romperán en cualquier momento, pero que aún no están rotas)?

Me gustaría entender qué cambió tras bambalinas. Cualquier indicación de temas o documentación de Discourse sería muy apreciada.

Trabajé con ChatGPT para crear una solución que podría simplemente colocar en mi archivo JS.

Esto intercepta el botón “Spoiler” y luego activa el botón predeterminado en su lugar.

Me encantaría ver esto como parte del complemento, si no hay otras formas de hacerlo (idealmente, en lugar de invocar el menú y forzar un clic en el botón, simplemente activaría lo mismo que activa el botón).

Aquí está el código que agregué al archivo JS de mi tema:

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

export default apiInitializer((api) => {
  /**
   * === Corrección del Spoiler del RTE para el Bonanza de Botones del Compositor ===
   *
   * Escritorio: funciona haciendo clic en Opciones y luego en "Difuminar spoiler".
   * Móvil: el marcado del menú desplegable difiere, por lo que debemos localizar el elemento por texto, no por selectores estrictos.
   *
   */

  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() {
    // En Discourse, los menús desplegables suelen utilizar uno de estos contenedores.
    // Elegimos el último visible (el abierto más recientemente).
    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;

    // Primero, probamos la estructura más parecida a la de escritorio (vía rápida)
    const exact =
      menuRoot.querySelector('button[title="Blur spoiler"]') ||
      menuRoot.querySelector('button[aria-label="Blur spoiler"]');
    if (exact) return exact;

    // De lo contrario, localizamos por contenido de texto en cualquier lugar dentro de los elementos del menú desplegable
    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")) {
        // Hacemos clic en el botón real más cercano
        const btn = node.closest("button") || node.querySelector?.("button");
        if (btn) return btn;
        // Si ya es un elemento similar a un botón, lo devolvemos
        if (node.tagName === "BUTTON") return node;
      }
    }

    // Último recurso: escaneamos todos los botones del menú por texto de etiqueta
    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;

    // Algunos menús desplegables móviles necesitan un tick para terminar de posicionarse/adjuntar controladores
    requestAnimationFrame(() => {
      requestAnimationFrame(() => btn.click());
    });

    return true;
  }

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

    // Reintentamos hasta que el menú y el elemento existan
    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;

      // Solo anulamos en RTE; el modo markdown deja Bonanza en paz.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Prevenir la inserción de BBCode de Bonanza en RTE
      e.preventDefault();
      e.stopImmediatePropagation();

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

Es un apaño, pero funciona. Probado solo en iOS/macOS Safari y macOS Chrome, aún no en Android.