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.