Composer Button フェスティバル

このTCのクローンをGitHubリポジトリ GitHub - denvergeeks/DiscourseComposerButtonBonanza に作成し、これらのエラーに対する修正を適用しました(Codeberg.orgサービスへのアクセスがフォークに機能しないようだったため)。

こちらが私の変更のコミットログです:

DCBB でエラーが発生した場合、あるいはそのエラーの修正方法をご存知の場合は、リポジトリをどこか別の場所にクローンして、皆さんにそのクローンを使うように伝えるのではなく、私が修正できるように、上流のイシューを開いていただけると大変助かります。 (何らかの理由で Codeberg でリポジトリをフォークできない場合は、その旨も、私が常にメール更新を受け取るわけではないディスカッショントピック経由で偶発的に知らされるのではなく、直接教えていただけるとありがたいです。)

おや、ちょうど「返信」を押そうとしたら、PR が現れました!ありがとうございます!

この PR は、WYSIWYG Composer で発生している <div><span> に関する問題(@shauny を悩ませていたもの)に対処しているのでしょうか、それとも他のエラー/機能不全に対処しているのでしょうか、あるいは単に非推奨の警告(つまり、まもなく壊れるがまだ壊れていないもの)に対処しているのでしょうか?

舞台裏で何が変わったのかを理解したいです。トピックや Discourse のドキュメントへのポインターをいただけると大変ありがたいです。

ChatGPTと協力して、JSファイルにそのままドロップできる修正を作成しました。

これは、「Spoiler」ボタンをインターセプトし、代わりにデフォルトのボタンをトリガーするものです。

(メニューを呼び出してボタンをクリックさせるのではなく、ボタンがトリガーするものと同じものをトリガーできれば理想的ですが)プラグインの一部としてこれを実装できれば幸いです。

テーマのJSファイルに追加したコードは次のとおりです。

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

export default apiInitializer((api) => {
  /**
   * === Composer Button Bonanza のための RTE スポイラー修正 ===
   *
   * デスクトップ: オプションをクリックし、次に「Blur spoiler」をクリックすることで機能します。
   * モバイル: ドロップダウンのマークアップが異なるため、厳密なセレクターではなくテキストで項目を見つける必要があります。
   *
   */

  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でのみオーバーライドします。マークダウンモードではBonanzaをそのままにします。
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // RTEでのBonanzaのBBCode挿入を防ぎます
      e.preventDefault();
      e.stopImmediatePropagation();

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

ハックですが、動作します。iOS/macOS SafariとmacOS Chromeでのみテスト済みで、Androidではまだテストしていません。

Composer Button Bonanza のバージョン 2.0.0 をリリースしました。変更点は site.desktopView の非推奨の使用法を修正したことだけです。詳細はソースコミットを参照してください。

その警告を更新しました。問題を調査した結果、実際にはリッチテキストエディタがこのテーマコンポーネントと互換性がないのだと考えられます。その理由は、リッチテキストエディタの TextManipulation インターフェースの ProsemirrorTextManipulation 実装が不完全であるか、または間違っているためです。

具体的には:

  • ProsemirrorTextManipulation.applyList() の実装は、呼び出し元から提供される head パラメータを完全には使用しません。代わりに、呼び出し元が何をしようとしているのかを推測するために、呼び出し元によって提供される例のテキストのキーを参照し、箇条書き、番号付きリスト、引用ブロックの組み込みボタンのみを理解するようにハードコーディングされています。
  • ProsemirrorTextManipulation.applySurround() の実装は、元の TextareaTextManipulation.applySurround() 実装の動作と一致せず、\u003cspan\u003e を使用すべき場合でも無差別に \u003cdiv\u003e を使用する原因となっています。Prosemirror の実装は、applySurround()opts 引数も無視します。(また、applyList() と同じトリックを使用して、イタリック、太字、整形済みテキストのボタンを検出するために例のテキストキーをハードコーディングしています。)

@renato、これらは誰かの認識範囲内にありますか?修正の予定はありますか?

「いいね!」 3

具体的に何のためにそれらが必要なのか共有していただけますか?

textareaエディタしかなかったときに構築された一部のAPIは、リッチエディタで完全な同等性を持つことは意図されておらず、ProseMirrorのすべての機能を中間的な抽象化に持ち込むことは当社の意図ではありません。

可能であれば、また必要であれば、それらの場所を改善することはできますが、一般的に複雑な操作が必要な場合は、登録されたリッチエディタ拡張機能のcommandsキーを介して、ProseMirrorの依存関係に直接アクセスすることがよくあります。例えば:

この例では、applySurroundは選択されたテキストにスポイラーのbbcodeを盲目的に適用しますが、toggleSpoilerには、すでにスポイラーノード内にあるかどうか、インラインスポイラーかブロックスポイラーかなどを判断するためのProseMirrorのすべての機能があります。

「いいね!」 1

もし、これら2つのメソッドが、それらが属するインターフェースに対してより忠実に実装されていれば、Composer Button Bonanza は新しいRTEで「ほぼそのまま動作する」(少なくともMarkdownモードで動作するのと同じくらい)と思うのですが。他のテーマコンポーネントやプラグインの作者が、まだ関連する問題を提起していないことに驚いています。(もっとも、提起しているかもしれませんが、同様の不満を検索してみたわけではありません。)

「ProseMirrorのすべての機能」が何を意味するのかは分かりませんが、それが必要だとは思えません。applyList()applySurround()のインターフェースはそれほど複雑ではありません — ただし、これまでに実装されているものよりも多くのものがあります。

(これまでに実装されているものは、「選択範囲にリストマークアップを適用する」や「選択範囲を囲むテキストマークアップを適用する」といった原理に基づいたものではなく、むしろ「既知の組み込みツールバーボタンからの呼び出しを特定のProseMirror関数にディスパッチする」以上のもののように見えます。)

「いいね!」 1

こんにちは!

これは素晴らしいコンポーネントですね。見落としていました。

私は、カスタムラップ([wrap] 系、または許可されている HTML/Markdown/Discourse 固有の内容)に対してカスタムキーボードショートカットを追加できる、類似のテーマコンポーネントの独自バージョンを作成していました。

例えば、Ctrl+Shift+K を定義して、選択範囲を <kbd> </kbd><hr>[wrap=announcement] [/wrap](インラインまたはブロックのどちらでも)、あるいは何でも好きなもので挿入またはラップできるようにしています。また、ギアメニューにオプションのボタンを追加することも可能です。
挿入するコンテンツ内に \n を使用することで、改行にも対応しています。

リッチエディターとも互換性があります。参考コードはこちらです:GitHub - Canapin/discourse-custom-shortcuts · GitHub
ただし、これは Claude が生成したコードであり、私がまだコードを確認していない点にご注意ください。また、もしあなたのものを使うのではなく、独自のソリューションを続けるとしたら、おそらくあなたの機能の一部を使用するために、いくつかの変更や追加を行うでしょう。

私のコンポーネントの外観は以下の通りです:




さて、あなたのコンポーネントに戻りますが、その汎用性は素晴らしいのですが、私にとっては以下の 2 つの基本的な要素が欠けています:

  • リッチエディターのサポート
  • カスタムコンテンツ(カスタムラップ、タグ、その他)を追加しやすい方法

AI に任せて自分のものをコードすることはできますが、あなたのコンポーネントに対して(例えばリッチエディターのサポートのような)プルリクエストを提案させるには、まだ不安があり、私はプログラマーでもありません。

リッチエディターの話 aside として、あなたのコンポーネントから新しいカスタムコンテンツを追加することの可行性についてどうお考えですか?(基本的には私が前述した独自コンポーネントの機能のことです)それは受け入れられるでしょうか、それともスコープ外でしょうか?

質問の意図があまり理解できませんが、Composer Button Bonanza はすでにこの機能をサポートしていないでしょうか?

コンポーネントの設定、特に「Buttons」設定のカスタマイズを試しましたか?

  • 「Buttons」はボタン(実際には機能)を定義するためのものです。
  • 「Layout」は UI でどこに、いつボタンを表示するかを指定するためのものです(ショートカットの指定も可能です)。
  • 「Translations」はボタン定義に翻訳を追加するためのものです。
「いいね!」 1

おお、その通りです!この設定をすっかり見落としていましたね。詳しく確認してみます。これで私の要望が満たされるかもしれませんね:握手

キーボードショートカットが機能しないことに困っています。

image

ツールバーのボタンは機能しますが、Ctrl+Shift+kでは何も起こりません。何かご存知でしょうか?

「いいね!」 1

おっと、申し訳ありません!レイアウト設定のドキュメントで間違えていました:ショートカットの指定にはハイフンではなく、プラス記号を使用する必要があります。

例:shift+kshift-k ではありません)。

(今後 1〜2 日以内に設定ページのドキュメントを更新します。)

「いいね!」 1