Composer Button Bonanza

I’ve submitted a PR which can be found at #1 - Update javascripts/discourse/api-initializers/api-initializer.js - centertap/DiscourseComposerButtonBonanza - Codeberg.org

[Edit: The Codeberg.org site came back online, after being offline when I had earlier tried to fork and submit a PR.]

If you experienced errors with DCBB and/or even have fixes for those errors, it would be awesome if you could open an upstream issue to report all of this, so that I can fix them — instead of just cloning the repo somewhere else altogether and telling people to use use your clone. (If you can’t fork the repo on Codeberg for some reason, it would be nice to be told about that directly, too, instead of incidentally via a discussion topic where I don’t always get email updates.)

Oh, hey… just as I go to hit Reply, a PR has appeared! Thank you!

Does this PR address any of the problems happening with <div> vs <span> under WYSIWYG-Composer (what has plagued @shauny) — or is it addressing some other error/dysfunction, or is addressing deprecation warnings only (i.e., things that will break any day now, but are not broken just yet)?

I would like to understand what changed behind the scenes. Any pointers to topics or Discourse documentation would be very appreciated.

I worked with ChatGPT to create a fix that I could just drop into my JS file.

What this does is intercepts the “Spoiler” button and then triggers the default button instead.

I’d love to see this as part of the plugin, if there are no other ways to do it (ideally instead of invoking the menu and forcing a click on the button, it would just trigger the same thing the button triggers).

Here is the code I added to my theme’s JS file:

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

export default apiInitializer((api) => {
  /**
   * === RTE Spoiler Fix for Composer Button Bonanza ===
   *
   * Desktop: works by clicking Options, then clicking "Blur spoiler".
   * Mobile: dropdown markup differs, so we must locate the item by text, not strict selectors.
   *
   */

  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() {
    // On Discourse, dropdowns typically use one of these containers.
    // We pick the last visible one (most recently opened).
    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;

    // First, try the desktop-ish structure (fast path)
    const exact =
      menuRoot.querySelector('button[title="Blur spoiler"]') ||
      menuRoot.querySelector('button[aria-label="Blur spoiler"]');
    if (exact) return exact;

    // Otherwise, locate by text content anywhere inside dropdown items
    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")) {
        // Click the closest actual button
        const btn = node.closest("button") || node.querySelector?.("button");
        if (btn) return btn;
        // If it's already a button-like element, return it
        if (node.tagName === "BUTTON") return node;
      }
    }

    // Last resort: scan all buttons in the menu by label text
    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;

    // Some mobile dropdowns need a tick to finish positioning/attaching handlers
    requestAnimationFrame(() => {
      requestAnimationFrame(() => btn.click());
    });

    return true;
  }

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

    // Retry until the menu and item exist
    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;

      // Only override in RTE; markdown mode leave Bonanza alone.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Prevent Bonanza's BBCode insertion in RTE
      e.preventDefault();
      e.stopImmediatePropagation();

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

It’s hacky, but it works. Tested in iOS/macOS Safari and macOS Chrome only, not on Android yet.