Я работал с ChatGPT, чтобы создать исправление, которое можно просто вставить в мой JS-файл.
Суть в том, что оно перехватывает кнопку «Спойлер» и вместо этого вызывает действие по умолчанию для стандартной кнопки.
Мне бы очень хотелось увидеть это как часть плагина, если нет других способов это сделать (в идеале, вместо вызова меню и принудительного клика по кнопке, должно просто срабатывать то же действие, что и при нажатии на кнопку).
Вот код, который я добавил в JS-файл своей темы:
import { apiInitializer } from "discourse/lib/api";
export default apiInitializer((api) => {
/**
* === Исправление для спойлера RTE в рамках Composer Button Bonanza ===
*
* Десктоп: работает путем нажатия на «Опции», а затем на «Размыть спойлер».
* Мобильные устройства: разметка выпадающего меню отличается, поэтому мы должны искать элемент по тексту, а не по строгим селекторам.
*
*/
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() {
// В Discourse выпадающие меню обычно используют один из этих контейнеров.
// Мы выбираем последний видимый (наиболее недавно открытый).
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;
// Сначала пробуем структуру, похожую на десктопную (быстрый путь)
const exact =
menuRoot.querySelector('button[title="Blur spoiler"]') ||
menuRoot.querySelector('button[aria-label="Blur spoiler"]');
if (exact) return exact;
// Иначе ищем по текстовому содержимому внутри элементов выпадающего списка
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")) {
// Кликаем по ближайшей реальной кнопке
const btn = node.closest("button") || node.querySelector?.("button");
if (btn) return btn;
// Если это уже элемент типа кнопки, возвращаем его
if (node.tagName === "BUTTON") return node;
}
}
// Последнее средство: сканируем все кнопки в меню по тексту метки
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;
// Некоторые мобильные выпадающие списки требуют паузу для завершения позиционирования/привязки обработчиков
requestAnimationFrame(() => {
requestAnimationFrame(() => btn.click());
});
return true;
}
function openOptionsAndClickBlurSpoiler(composerRoot) {
if (!openOptionsMenu(composerRoot)) return false;
// Повторяем попытку, пока меню и элемент не появятся
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;
// Переопределяем только в режиме RTE; в режиме Markdown оставляем Bonanza как есть.
if (!isRichTextComposerContext(bonanzaBtn)) return;
// Предотвращаем вставку BBCode от Bonanza в режиме RTE
e.preventDefault();
e.stopImmediatePropagation();
const composerRoot = getComposerRoot(bonanzaBtn);
openOptionsAndClickBlurSpoiler(composerRoot);
},
true
);
});
Это довольно костыльное решение, но оно работает. Протестировано только в Safari на iOS/macOS и Chrome на macOS, на Android пока нет.