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.