Bonança de Botões do Composer

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.

Acabei de lançar a versão 2.0.0 do Composer Button Bonanza. A única alteração é para corrigir o uso obsoleto de site.desktopView. Veja o commit da origem para detalhes.

Eu atualizei esse aviso. Após investigar os problemas, acho que na verdade é o contrário: o editor de texto rico não é compatível com este componente de tema, porque sua implementação ProsemirrorTextManipulation da interface TextManipulation está incompleta e/ou incorreta.

Em particular:

  • A implementação ProsemirrorTextManipulation.applyList() não utiliza exatamente o parâmetro head fornecido pelo chamador. Em vez disso, ela observa a chave para o texto de exemplo fornecido pelo chamador para adivinhar o que o chamador está tentando fazer, e é codificada para entender apenas os botões internos para listas com marcadores, listas ordenadas e citações.
  • A implementação ProsemirrorTextManipulation.applySurround() não corresponde ao comportamento da implementação original TextareaTextManipulation.applySurround(), e é responsável por usar indiscriminadamente <div> mesmo quando deveria usar <span>. A implementação Prosemirror também ignora o argumento opts para applySurround(). (E, usando o mesmo truque de applyList(), ela codifica as chaves de texto de exemplo para detectar os botões para itálico, negrito e texto pré-formatado.)

@renato, estes problemas estão no radar de alguém? Existe um cronograma para corrigi-los?

3 curtidas

Pode compartilhar para que exatamente você precisa deles?

Algumas APIs criadas quando existia apenas o editor de textarea não se destinam realmente a ter paridade total no editor rico, não é nossa intenção trazer todo o poder do ProseMirror para uma abstração intermediária.

Podemos melhorar esses lugares se for possível e necessário, mas, em geral, quando precisamos de operações complexas, geralmente recorremos às dependências do ProseMirror diretamente por meio de uma chave commands em uma extensão de editor rico registrada. Por exemplo:

Neste exemplo, applySurround aplica cegamente o bbcode de spoiler ao texto selecionado, enquanto toggleSpoiler possui todos os recursos do ProseMirror para decidir se já está dentro de um nó de spoiler, se é um spoiler inline ou um spoiler de bloco, etc.

1 curtida

Se esses dois métodos fossem implementados com mais fidelidade à interface à qual pertencem, eu acho que o Composer Button Bonanza simplesmente funcionaria no novo RTE (pelo menos tão bem quanto funciona no modo Markdown). Estou surpreso que nenhum outro componente de tema ou autor de plugin tenha levantado problemas relacionados ainda. (Embora, talvez eles o tenham feito; eu não tentei procurar por reclamações semelhantes.)

Eu não sei o que significa “todo o poder do ProseMirror”, mas duvido que isso seja necessário. As interfaces applyList() e applySurround() não são tão complicadas — embora haja mais nelas do que o que foi implementado até agora.

(O que foi implementado até agora parece ser não tanto uma “aplicação de marcação de lista à seleção” ou “aplicação de marcação de texto envolvendo a seleção” baseada em princípios, mas mais “apenas despachar as chamadas dos botões da barra de ferramentas conhecidos e embutidos para funções específicas do prosemirror”.)

1 curtida

Ei!

Essa é uma componente fantástica que eu havia passado despercebido.

Eu estava trabalhando na minha própria versão de um componente de tema semelhante, que permite adicionar atalhos de teclado personalizados para wraps personalizados (seja do tipo [wrap] ou qualquer conteúdo HTML, Markdown ou específico do Discourse permitido).

Por exemplo, posso definir Ctrl+Shift+K para inserir ou envolver a seleção com <kbd> </kbd>, ou <hr>, ou [wrap=announcement] [/wrap] (seja inline ou em bloco) ou qualquer coisa que desejemos, e também adicionei botões opcionais no menu de engrenagem.
Ele suporta quebras de linha com \n no conteúdo que inserimos.

Também é compatível com o editor rico. O código para referência: GitHub - Canapin/discourse-custom-shortcuts · GitHub, com um aviso: foi codificado com Claude e eu ainda não revisei o código. Além disso, se eu fosse continuar criando minha própria solução em vez de usar a sua, provavelmente mudaria ou adicionaria recursos para usar alguns dos seus.

Aqui está como meu componente se parece:




Então, voltando ao seu componente, gosto da sua versatilidade, mas faltam duas coisas fundamentais para mim:

  • Suporte ao editor rico
  • Maneira fácil de adicionar conteúdo personalizado (wraps personalizados, tags ou outros)

Embora eu possa codificar minhas próprias coisas com ajuda de IA, não me sinto confortável o suficiente para depender de sugestões de IA para propor pull requests (por exemplo, suporte ao editor rico) para o seu componente, e não sou programador.

Além da questão do editor rico, qual é a sua opinião sobre a viabilidade de adicionar novo conteúdo personalizado ao seu componente (basicamente o que descrevi do meu próprio componente)? Seria aceito ou está fora do escopo?

Não entendi muito bem sua pergunta — o Composer Button Bonanza já não suporta isso?

Você já tentou personalizar as configurações do componente, em especial a configuração “Buttons” (Botões)?

  • “Buttons” serve para definir os botões (na verdade, as funções).
  • “Layout” serve para especificar onde/quando eles aparecem na interface (além disso, especifique atalhos).
  • “Translations” serve para adicionar traduções às definições dos botões.
1 curtida

Ooooh, com certeza! Eu perdi completamente essa configuração! Vou dar uma olhada mais aprofundada, pode ser que isso atenda às minhas necessidades :handshake:

Tenho dificuldade para fazer o atalho de teclado funcionar.

image

O botão está na barra de ferramentas e funciona, mas Ctrl+Shift+k não faz nada. Alguma ideia?

1 curtida

Ooooh, desculpe! Cometi um erro na documentação das configurações de layout: os especificadores de atalho devem usar um sinal de mais, e não um hífen.

Por exemplo: shift+k (e não shift-k).

(Atualizarei a documentação da página de configurações em um ou dois dias.)

1 curtida