Festival de Botões do Compositor

Eu fiz um clone deste TC no meu repositório do GitHub em GitHub - denvergeeks/DiscourseComposerButtonBonanza e então apliquei correções para estes erros [porque o acesso ao serviço Codeberg.org parecia não funcionar para forking.]

Aqui está o log de commits das minhas alterações:

Se você encontrou erros com o DCBB e/ou tem correções para esses erros, seria ótimo se pudesse abrir uma issue upstream para relatar tudo isso, para que eu possa corrigi-los — em vez de apenas clonar o repositório em outro lugar e dizer às pessoas para usarem seu clone. (Se você não pode forkar o repositório no Codeberg por algum motivo, seria bom ser informado sobre isso diretamente também, em vez de incidentalmente através de um tópico de discussão onde nem sempre recebo atualizações por e-mail.)

Ah, ei… assim que estou prestes a clicar em Responder, um PR apareceu! Obrigado!

Este PR aborda algum dos problemas que ocorrem com <div> versus <span> sob o WYSIWYG-Composer (o que atormentou @shauny) — ou está abordando algum outro erro/disfunção, ou está abordando apenas avisos de depreciação (ou seja, coisas que quebrarão a qualquer dia, mas ainda não quebraram)?

Eu gostaria de entender o que mudou nos bastidores. Qualquer indicação de tópicos ou documentação do Discourse seria muito apreciada.

Trabalhei com o ChatGPT para criar uma correção que eu pudesse simplesmente colocar no meu arquivo JS.

O que isso faz é interceptar o botão “Spoiler” e, em seguida, acionar o botão padrão em seu lugar.

Eu adoraria ver isso como parte do plugin, se não houver outras maneiras de fazer isso (idealmente, em vez de invocar o menu e forçar um clique no botão, ele acionaria a mesma coisa que o botão aciona).

Aqui está o código que adicionei ao arquivo JS do meu tema:

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

export default apiInitializer((api) => {
  /**
   * === Correção do Spoiler do RTE para o Bonanza de Botões do Compositor ===
   *
   * Desktop: funciona clicando em Opções e depois clicando em "Blur spoiler" (Esfumaçar spoiler).
   * Mobile: a marcação do menu suspenso difere, então devemos localizar o item pelo texto, não por seletores estritos.
   *
   */

  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() {
    // No Discourse, os menus suspensos geralmente usam um destes contêineres.
    // Escolhemos o último visível (o mais recentemente aberto).
    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;

    // Primeiro, tenta a estrutura estilo desktop (caminho rápido)
    const exact =
      menuRoot.querySelector('button[title="Blur spoiler"]') ||
      menuRoot.querySelector('button[aria-label="Blur spoiler"]');
    if (exact) return exact;

    // Caso contrário, localiza pelo conteúdo do texto em qualquer lugar dentro dos itens do menu suspenso
    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")) {
        // Clica no botão real mais próximo
        const btn = node.closest("button") || node.querySelector?.("button");
        if (btn) return btn;
        // Se já for um elemento semelhante a um botão, retorna-o
        if (node.tagName === "BUTTON") return node;
      }
    }

    // Último recurso: examina todos os botões no menu pelo texto do rótulo
    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;

    // Alguns menus suspensos móveis precisam de um 'tick' para terminar o posicionamento/anexar manipuladores
    requestAnimationFrame(() => {
      requestAnimationFrame(() => btn.click());
    });

    return true;
  }

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

    // Tenta novamente até que o menu e o item existam
    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;

      // Substitui apenas no RTE; no modo markdown, deixe o Bonanza em paz.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Previne a inserção de BBCode do Bonanza no RTE
      e.preventDefault();
      e.stopImmediatePropagation();

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

É uma gambiarra, mas funciona. Testado apenas no iOS/macOS Safari e macOS Chrome, ainda não no Android.