Bonanza de botones de 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.

Acabo de lanzar la versión 2.0.0 de Composer Button Bonanza. El único cambio es para corregir el uso obsoleto de site.desktopView. Consulta el commit de origen para obtener más detalles.

Actualicé esa advertencia. Habiendo investigado los problemas ahora, creo que en realidad es al revés: el editor de texto enriquecido no es compatible con este componente temático, porque su implementación de ProsemirrorTextManipulation de la interfaz TextManipulation está incompleta y/o es incorrecta.

En particular:

  • La implementación de ProsemirrorTextManipulation.applyList() no utiliza exactamente el parámetro head suministrado por el llamador. En su lugar, observa la clave para el texto de ejemplo suministrado por el llamador para adivinar lo que el llamador está tratando de hacer, y está codificada para entender solo los botones integrados para listas con viñetas, listas ordenadas y bloques de citas.
  • La implementación de ProsemirrorTextManipulation.applySurround() no coincide con el comportamiento de la implementación original de TextareaTextManipulation.applySurround(), y es responsable de usar indiscriminadamente <div> incluso cuando debería usar <span>. La implementación de Prosemirror también ignora el argumento opts de applySurround(). (Y, usando el mismo truco que applyList(), codifica las claves de texto de ejemplo para detectar los botones de cursiva, negrita y texto preformateado).

@renato, ¿están estos problemas en el radar de alguien? ¿Hay un cronograma para solucionarlos?

3 Me gusta

¿Puedes compartir para qué exactamente los necesitas?

Algunas API creadas cuando solo existía el editor de área de texto no están realmente destinadas a tener paridad total con el editor enriquecido, no es nuestra intención llevar todo el poder de ProseMirror a una abstracción intermedia.

Podemos mejorar esos lugares si es posible y necesario, pero en general, cuando necesitamos operaciones complejas, solemos recurrir a las dependencias de ProseMirror directamente a través de una clave commands en una extensión de editor enriquecido registrada. Por ejemplo:

En este ejemplo, applySurround aplica ciegamente el bbcode de spoiler al texto seleccionado, mientras que toggleSpoiler tiene todas las características de ProseMirror para decidir si ya está dentro de un nodo spoiler, si es un spoiler en línea o un spoiler de bloque, etc.

1 me gusta

Si estos dos métodos se implementaran con mayor fidelidad a la interfaz a la que pertenecen, creo que Composer Button Bonanza prácticamente funcionaría “automáticamente” en el nuevo RTE (al menos tan bien como lo hace en el modo Markdown). Me sorprende que ningún otro autor de componentes temáticos o complementos haya planteado problemas relacionados hasta ahora. (Aunque, tal vez lo hayan hecho; no he intentado buscar quejas similares).

No sé qué implica “todo el poder de ProseMirror”, pero dudo que sea necesario. Las interfaces applyList() y applySurround() no son tan complicadas, aunque tienen más de lo que se ha implementado hasta ahora.

(Lo que se ha implementado hasta ahora parece ser no tanto una “aplicación de marcado de lista a la selección” o “aplicación de marcado de texto rodeando la selección” basada en principios, sino más bien “simplemente despachar las llamadas desde los botones de la barra de herramientas conocidos y predeterminados a funciones específicas de prosemirror”.)

1 me gusta

¡Hola!

Este es un componente fantástico que había pasado por alto.

Estaba trabajando en mi propia versión de un componente de tema similar, que permite agregar atajos de teclado personalizados para envolturas personalizadas (ya sea de tipo [wrap] o cualquier contenido HTML, Markdown o específico de Discourse permitido).

Por ejemplo, puedo definir Ctrl+Shift+K para insertar o envolver la selección con <kbd> </kbd>, <hr>, [wrap=announcement] [/wrap] (ya sea en línea o en bloque) o cualquier cosa que deseemos, y también agregué botones opcionales en el menú de engranajes.
Soporta saltos de línea con \n en el contenido que insertamos.

También es compatible con el editor enriquecido. El código para referencia: GitHub - Canapin/discourse-custom-shortcuts · GitHub, con una advertencia: fue generado por Claude y aún no he revisado el código. Además, si decidiera continuar con mi propia solución en lugar de usar la tuya, probablemente modificaría o añadiría características para aprovechar algunas de las tuyas.

Así es como se ve mi componente:




Volviendo a tu componente, me gusta su versatilidad, pero le faltan dos cosas fundamentales para mí:

  • Soporte para el editor enriquecido
  • Una forma sencilla de agregar contenido personalizado (envolturas personalizadas, etiquetas u otros)

Aunque puedo generar mi propio código con ayuda de IA, no me siento lo suficientemente cómodo como para confiar en que la IA proponga solicitudes de extracción (por ejemplo, el soporte para el editor enriquecido) para tu componente, y además no soy programador.

Aparte del tema del editor enriquecido, ¿qué opinas sobre la viabilidad de agregar nuevo contenido personalizado desde tu componente (básicamente lo que describí de mi propio componente)? ¿Sería aceptado o estaría fuera de su alcance?

No termino de entender tu pregunta… ¿acaso Composer Button Bonanza no ya lo soporta?

¿Has probado personalizar la configuración del componente, en particular, la opción “Buttons”?

  • “Buttons” sirve para definir los botones (en realidad, funciones).
  • “Layout” sirve para especificar dónde y cuándo aparecen en la interfaz (también permite definir atajos de teclado).
  • “Translations” sirve para agregar traducciones a las definiciones de los botones.
1 me gusta

¡Oh, sí, desde luego! Me había perdido completamente esta configuración. Lo revisaré con más detalle; podría cumplir con lo que necesito :handshake:

Tengo problemas para que funcione el atajo de teclado.

image

El botón está en la barra de herramientas y funciona, pero Ctrl+Shift+k no hace nada. ¿Alguna idea?

1 me gusta

¡Vaya, lo siento! Cometí un error en la documentación de la configuración de diseño: los especificadores de atajos deben usar un signo más, no un guion.

Por ejemplo: shift+k (no shift-k).

(Actualizaré la documentación de la página de configuración en el próximo día o dos.)

1 me gusta