작곡가 버튼 대박

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.

1개의 좋아요

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?

4개의 좋아요

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.

2개의 좋아요

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”.)

2개의 좋아요

안녕하세요!

제가 놓치고 있던 훌륭한 컴포넌트네요.

저는 유사한 테마 컴포넌트의 자체 버전을 작업 중이었는데, 이 컴포넌트는 [wrap] 계열이거나 허용되는 HTML / 마크다운 / 디스코urs 전용 콘텐츠 등 커스텀 랩(wrap)에 대해 사용자 정의 키보드 단축키를 추가할 수 있도록 허용합니다.

예를 들어, <kbd> </kbd>, 또는 <hr>, 또는 [wrap=announcement] [/wrap] (인라인 또는 블록 모두 가능) 등 원하는任何东西를 삽입하거나 선택된 텍스트를 감싸기 위해 Ctrl+Shift+K를 정의할 수 있습니다. 또한 기어 메뉴에 선택적인 버튼도 추가했습니다.

삽입하는 콘텐츠에서 \n을 사용하여 줄바꿈을 지원합니다.

리치 에디터와도 호환됩니다. 참고용 코드: https://github.com/Canapin/discourse-custom-shortcuts, 단 경고가 있습니다: 이 코드는 Claude가 작성한 것이며 아직 제가 코드를 검토하지 않았습니다. 또한, 여러분의 것을 사용하는 대신 제 솔루션을 계속 만들었다면, 여러분의 일부 기능을 사용하기 위해 기능을 변경하거나 추가했을 가능성이 높습니다.

제 컴포넌트의 모습은 다음과 같습니다:




다시 여러분의 컴포넌트로 돌아가면, 저는 그 다재다능함을 좋아하지만, 저에게는 두 가지 근본적인 것이 부족합니다:

  • 리치 에디터 지원
  • 커스텀 콘텐츠(커스텀 랩, 태그 또는 기타)를 추가하는 쉬운 방법

저는 제 것들을 '바이브 코딩(vibe code)'할 수 있지만, 여러분의 컴포넌트에 대한 풀 리퀘스트(예: 리치 에디터 지원)를 제안하도록 AI에 의존하는 것에는 아직 충분히 편안하지 않으며, 저는 프로그래머도 아닙니다.

리치 에디터 문제 외에도, 여러분의 컴포넌트에서 새로운 커스텀 콘텐츠를 추가하는 것의 실현 가능성에 대해 어떻게 생각하시나요(기본적으로 제가 제 컴포넌트에 대해 설명한 그 기능)? 그것이 받아들여지거나, 아니면 그 범위를 벗어난 것일까요?

1개의 좋아요

질문을 정확히 이해하지 못하겠습니다 — __Composer Button Bonanza__가 이미 이 기능을 지원하지 않나요?

컴포넌트 설정, 특히 “Buttons” 설정을 커스터마이징해 보셨나요?

  • "Buttons"은 버튼(실제로는 기능)을 정의하는 데 사용됩니다.
  • "Layout"은 UI에서 버튼이 어디에/언제 표시될지를 지정하는 데 사용됩니다(또는 단축키를 지정합니다).
  • "Translations"은 버튼 정의에 번역을 추가하는 데 사용됩니다.
2개의 좋아요

오오, 정말 그렇네요! 이 설정을 완전히 놓치고 있었어요! 더 자세히 살펴보겠습니다. 제 필요에 부합할 수도 있겠네요 :handshake:

1개의 좋아요

키보드 단축키가 작동하지 않아서 애를 먹고 있습니다.

image

도구 모드의 버튼은 작동하는데, Ctrl+Shift+k를 누르면 아무 반응이 없습니다. 혹시 원인을 아시는 분 계신가요?

1개의 좋아요

아, 죄송합니다! 레이아웃 설정 문서에 실수가 있었습니다: 단축키 지정자는 빼기(-) 기호가 아니라 더하기(+) 기호를 사용해야 합니다.

예를 들어: shift+k (shift-k가 아닙니다).

(앞으로 1~2일 안에 설정 페이지 문서를 업데이트하겠습니다.)

1개의 좋아요

완료되었습니다. 버전 2.0.1이 출시되었습니다. (이 업데이트를 통해 전반적인 문서가 추가되고, 새 버튼의 CSS 스타일링 관련 버그가 수정되었습니다.)

1개의 좋아요