Бонанза кнопок композитора

I’ve submitted a PR which can be found at #1 - Update javascripts/discourse/api-initializers/api-initializer.js - centertap/DiscourseComposerButtonBonanza - Codeberg.org

[Edit: The Codeberg.org site came back online, after being offline when I had earlier tried to fork and submit a PR.]

If you experienced errors with DCBB and/or even have fixes for those errors, it would be awesome if you could open an upstream issue to report all of this, so that I can fix them — instead of just cloning the repo somewhere else altogether and telling people to use use your clone. (If you can’t fork the repo on Codeberg for some reason, it would be nice to be told about that directly, too, instead of incidentally via a discussion topic where I don’t always get email updates.)

Oh, hey… just as I go to hit Reply, a PR has appeared! Thank you!

Does this PR address any of the problems happening with <div> vs <span> under WYSIWYG-Composer (what has plagued @shauny) — or is it addressing some other error/dysfunction, or is addressing deprecation warnings only (i.e., things that will break any day now, but are not broken just yet)?

I would like to understand what changed behind the scenes. Any pointers to topics or Discourse documentation would be very appreciated.

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.

I just released version 2.0.0 of Composer Button Bonanza. The only change is to fix the deprecated use of site.desktopView. See the source commit for details.

I updated that warning. Having investigated the issues now, I think it’s actually the other way around: the richtext editor is not compatible with this theme component, because its ProsemirrorTextManipulation implementation of the TextManipulation interface is incomplete and/or incorrect.

In particular:

  • The ProsemirrorTextManipulation.applyList() implementation doesn’t quite use the head parameter supplied by the caller. Instead, it looks at the key for the example text supplied by the caller to guess at what the caller is trying to do, and it is hard-coded to only understand the built-in buttons for bullet lists, ordered lists, and blockquotes.
  • The ProsemirrorTextManipulation.applySurround() implementation does not match the behavior of the original TextareaTextManipulation.applySurround() implementation, and is responsible for indiscriminately using <div> even when it should be using <span>. The Prosemirror implementation also ignores the opts argument to applySurround(). (And, using the same trick as applyList(), it hard-codes example-text keys to detect the buttons for italics, bold, and preformatted text.)

@renato, are these issues on anyone’s radar? Is there a timeline for fixing them?

3 лайка

Can you share what exactly you need them for?

Some APIs built when there was only the textarea editor aren’t really intended to have full parity on the rich editor, it’s not our intent to bring all the power of ProseMirror to an intermediate abstraction.

We can improve those places if possible and necessary, but in general when we need complex operations we usually reach to ProseMirror dependencies directly through a commands key on a registered rich editor extension. For example:

In this example, applySurround is blindly applying the spoiler bbcode to whatever text is selected, while toggleSpoiler has all the features from ProseMirror to decide if it’s already inside a spoiler node, if it’s an inline spoiler or a block spoiler, etc.

1 лайк

If these two methods were implemented with more fidelity to the interface they belong to, I think Composer Button Bonanza would pretty much “just work” in the new RTE (at least as well as it does in Markdown mode). I’m surprised that no other theme component or plugin authors have raised any related issues yet. (Though, maybe they have; I have not tried searching for similar complaints.)

I don’t know what “all the power of ProseMirror” entails, but I doubt that is necessary. The applyList() and applySurround() interfaces are not that complicated — though there is more to them than what has been implemented so far.

(What has been implemented so far appears to be not so much a principled “apply list markup to the selection” or “apply text markup surrounding the selection”, but more “just dispatch the calls from the known, built-in toolbar buttons to specific prosemirror functions”.)

1 лайк