Komponisten-Button-Bonanza

Ich habe einen Klon dieses TC in meinem GitHub-Repository unter GitHub - denvergeeks/DiscourseComposerButtonBonanza erstellt und dann Fehler behoben [weil der Zugriff auf den Codeberg.org-Dienst zum Forken anscheinend nicht funktionierte.]

Hier ist das Commit-Protokoll meiner Änderungen:

Wenn Sie Fehler mit DCBB hatten und/oder sogar Lösungen für diese Fehler haben, wäre es großartig, wenn Sie ein Upstream-Issue eröffnen könnten, um all dies zu melden, damit ich sie beheben kann – anstatt einfach das Repository irgendwo anders zu klonen und den Leuten zu sagen, sie sollen Ihren Klon verwenden. (Wenn Sie das Repository auf Codeberg aus irgendeinem Grund nicht forken können, wäre es auch schön, wenn Sie mir das direkt mitteilen würden, anstatt beiläufig über ein Diskussionsthema, bei dem ich nicht immer E-Mail-Benachrichtigungen erhalte.)

Oh, hey… gerade als ich auf Antworten drücken wollte, ist ein PR erschienen! Vielen Dank!

Behebt dieser PR eines der Probleme mit <div> gegenüber <span> unter dem WYSIWYG-Composer (was @shauny geplagt hat) – oder behebt er einen anderen Fehler/eine andere Fehlfunktion oder behebt er nur Deprecation Warnings (d. h. Dinge, die jeden Tag kaputtgehen werden, aber noch nicht kaputt sind)?

Ich möchte verstehen, was sich hinter den Kulissen geändert hat. Hinweise auf Themen oder die Discourse-Dokumentation wären sehr willkommen.

Ich habe mit ChatGPT eine Korrektur erstellt, die ich einfach in meine JS-Datei einfügen kann.

Dies fängt die Schaltfläche „Spoiler“ ab und löst stattdessen die Standard-Schaltfläche aus.

Ich würde dies gerne als Teil des Plugins sehen, falls es keine anderen Möglichkeiten gibt (idealerweise würde es nicht das Menü aufrufen und einen Klick auf die Schaltfläche erzwingen, sondern einfach dasselbe auslösen, was die Schaltfläche auslöst).

Hier ist der Code, den ich in die JS-Datei meines Themes eingefügt habe:

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

export default apiInitializer((api) => {
  /**
   * === RTE Spoiler Fix für Composer Button Bonanza ===
   *
   * Desktop: Funktioniert durch Klicken auf Optionen, dann auf „Spoiler verwischen“ (Blur spoiler).
   * Mobile: Das Dropdown-Markup unterscheidet sich, daher müssen wir das Element anhand des Textes und nicht anhand strenger Selektoren finden.
   *
   */

  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() {
    // Bei Discourse verwenden Dropdowns typischerweise einen dieser Container.
    // Wir wählen den letzten sichtbaren (zuletzt geöffneten).
    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;

    // Zuerst versuchen wir die Desktop-ähnliche Struktur (schneller Pfad)
    const exact =
      menuRoot.querySelector('button[title="Blur spoiler"]') ||
      menuRoot.querySelector('button[aria-label="Blur spoiler"]');
    if (exact) return exact;

    // Andernfalls nach Textinhalt irgendwo innerhalb der Dropdown-Elemente suchen
    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")) {
        // Die nächstgelegene tatsächliche Schaltfläche anklicken
        const btn = node.closest("button") || node.querySelector?.("button");
        if (btn) return btn;
        // Wenn es bereits ein button-ähnliches Element ist, gib es zurück
        if (node.tagName === "BUTTON") return node;
      }
    }

    // Letzter Ausweg: Alle Schaltflächen im Menü nach Beschriftungstext durchsuchen
    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;

    // Einige mobile Dropdowns benötigen einen Tick, um die Positionierung/Handler-Anbindung abzuschließen
    requestAnimationFrame(() => {
      requestAnimationFrame(() => btn.click());
    });

    return true;
  }

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

    // Erneut versuchen, bis das Menü und das Element vorhanden sind
    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;

      // Nur in RTE überschreiben; im Markdown-Modus Bonanza in Ruhe lassen.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Verhindern, dass Bonanza BBCode in RTE einfügt
      e.preventDefault();
      e.stopImmediatePropagation();

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

Es ist eine Notlösung, aber sie funktioniert. Nur unter iOS/macOS Safari und macOS Chrome getestet, noch nicht unter Android.

Ich habe gerade Version 2.0.0 von Composer Button Bonanza veröffentlicht. Die einzige Änderung besteht darin, die veraltete Verwendung von site.desktopView zu beheben. Details finden Sie im Quell-Commit.

Ich habe diese Warnung aktualisiert. Nachdem ich die Probleme untersucht habe, denke ich, dass es eigentlich umgekehrt ist: Der Rich-Text-Editor ist nicht mit dieser Theme-Komponente kompatibel, da seine ProsemirrorTextManipulation-Implementierung des TextManipulation-Interfaces unvollständig und/oder fehlerhaft ist.

Insbesondere:

  • Die Implementierung von ProsemirrorTextManipulation.applyList() verwendet den vom Aufrufer übergebenen Parameter head nicht ganz. Stattdessen betrachtet sie den Schlüssel für den vom Aufrufer übergebenen Beispieltext, um zu erraten, was der Aufrufer zu tun versucht, und sie ist fest darauf programmiert, nur die eingebauten Schaltflächen für Aufzählungslisten, nummerierte Listen und Zitate zu verstehen.
  • Die Implementierung von ProsemirrorTextManipulation.applySurround() entspricht nicht dem Verhalten der ursprünglichen Implementierung von TextareaTextManipulation.applySurround() und ist dafür verantwortlich, wahllos <div> zu verwenden, auch wenn <span> verwendet werden sollte. Die Prosemirror-Implementierung ignoriert auch das Argument opts für applySurround(). (Und unter Verwendung desselben Tricks wie bei applyList() verwendet sie fest codierte Beispieltext-Schlüssel, um die Schaltflächen für Kursiv, Fett und vorformatierter Text zu erkennen.)

@renato, sind diese Probleme auf dem Radar von jemandem? Gibt es einen Zeitplan für deren Behebung?

3 „Gefällt mir“

Können Sie mitteilen, wofür genau Sie diese benötigen?

Einige APIs, die erstellt wurden, als es nur den Textbereichs-Editor gab, sind nicht wirklich dafür gedacht, vollständige Parität mit dem Rich-Editor zu haben. Es ist nicht unsere Absicht, die ganze Leistungsfähigkeit von ProseMirror in eine Zwischenabstraktion zu bringen.

Wir können diese Stellen verbessern, falls möglich und notwendig, aber im Allgemeinen greifen wir auf komplexe Operationen zu, indem wir uns direkt über einen commands-Schlüssel auf einer registrierten Rich-Editor-Erweiterung auf ProseMirror-Abhängigkeiten stützen. Zum Beispiel:

In diesem Beispiel wendet applySurround blind den Spoiler-BBCode auf den ausgewählten Text an, während toggleSpoiler alle Funktionen von ProseMirror besitzt, um zu entscheiden, ob es sich bereits in einem Spoiler-Knoten befindet, ob es sich um einen Inline-Spoiler oder einen Block-Spoiler handelt, usw.

1 „Gefällt mir“

Wenn diese beiden Methoden mit mehr Treue zu der Schnittstelle, zu der sie gehören, implementiert würden, denke ich, dass Composer Button Bonanza im neuen RTE so ziemlich „einfach funktionieren“ würde (zumindest so gut wie im Markdown-Modus). Ich bin überrascht, dass noch keine anderen Theme-Komponenten- oder Plugin-Autoren ähnliche Probleme gemeldet haben. (Obwohl, vielleicht haben sie das; ich habe nicht versucht, nach ähnlichen Beschwerden zu suchen.)

Ich weiß nicht, was „die gesamte Leistung von ProseMirror“ beinhaltet, aber ich bezweifle, dass dies notwendig ist. Die Schnittstellen applyList() und applySurround() sind nicht so kompliziert – obwohl sie mehr beinhalten, als bisher implementiert wurde.

(Was bisher implementiert wurde, scheint weniger eine prinzipielle „Listenformatierung auf die Auswahl anwenden“ oder „Textformatierung um die Auswahl anwenden“ zu sein, sondern eher „einfach die Aufrufe von den bekannten, eingebauten Symbolleistenschaltflächen an spezifische prosemirror-Funktionen weiterleiten“.)

1 „Gefällt mir“

Hey!

Das ist eine fantastische Komponente, die ich übersehen habe.

Ich habe an meiner eigenen Version eines ähnlichen Theme-Components gearbeitet, die es ermöglicht, benutzerdefinierte Tastenkürzel für benutzerdefinierte Wraps hinzuzufügen (entweder vom [wrap]-Typ oder beliebiger erlaubter HTML-/Markdown-/Discourse-spezifischer Inhalt).

Zum Beispiel kann ich Strg+Umschalt+K definieren, um die Auswahl mit <kbd> </kbd>, <hr>, [wrap=announcement] [/wrap] (entweder inline oder als Block) oder irgendetwas anderem einzufügen oder zu umschließen, und ich habe auch optionale Buttons im Zahnrad-Menü hinzugefügt.
Es unterstützt Zeilenumbrüche mit \n im eingefügten Inhalt.

Es ist auch mit dem Rich-Editor kompatibel. Der Code zur Referenz: GitHub - Canapin/discourse-custom-shortcuts · GitHub, mit dem Hinweis: Er wurde von Claude generiert, und ich habe den Code noch nicht überprüft. Außerdem, wenn ich meine eigene Lösung weiterentwickeln würde, anstatt deine zu verwenden, würde ich wahrscheinlich einige deiner Funktionen ändern oder hinzufügen.

So sieht meine Komponente aus:




Um also zu deiner Komponente zurückzukehren: Ich mag ihre Vielseitigkeit, aber für mich fehlen ihr zwei grundlegende Dinge:

  • Unterstützung des Rich-Editors
  • Einfache Möglichkeit, benutzerdefinierten Inhalt hinzuzufügen (benutzerdefinierte Wraps, Tags oder andere)

Obwohl ich meine eigenen Dinge per Vibe-Coding erstellen kann, fühle ich mich nicht sicher genug, um mich darauf zu verlassen, dass KI Pull-Requests für deine Komponente vorschlägt (zum Beispiel für die Rich-Editor-Unterstützung), und ich bin kein Programmierer.

Abgesehen von der Rich-Editor-Frage: Was ist deine Einschätzung zur Machbarkeit, neuen benutzerdefinierten Inhalt über deine Komponente hinzuzufügen (im Wesentlichen das, was ich an meiner eigenen Komponente beschrieben habe)? Würde das akzeptiert werden, oder liegt es außerhalb ihres Anwendungsbereichs?

Ich verstehe deine Frage nicht ganz – unterstützt Composer Button Bonanza das nicht bereits?

Hast du versucht, die Komponenteneinstellungen anzupassen, insbesondere die Einstellung „Buttons“?

  • „Buttons“ dient zur Definition der Buttons (eigentlich Funktionen).
  • „Layout“ legt fest, wo/wann sie in der Benutzeroberfläche angezeigt werden (auch Tastenkürzel festlegen).
  • „Translations“ dient zum Hinzufügen von Übersetzungen für die Button-Definitionen.
1 „Gefällt mir“

Ooooh ja, das habe ich völlig übersehen! Ich werde mir das genauer ansehen, das könnte genau das Richtige für mich sein :handshake:

Ich habe Schwierigkeiten, die Tastenkombination zum Laufen zu bringen.

image

Der Button befindet sich in der Symbolleiste und funktioniert, aber Strg+Umschalt+k bewirkt nichts. Eine Idee?

1 „Gefällt mir“

Oh, tut mir leid! Ich habe in der Dokumentation für die Layouteinstellungen einen Fehler gemacht: Bei den Tastenkombinationen sollte ein Pluszeichen verwendet werden, kein Minus-/Bindestrich.

Zum Beispiel: shift+k (nicht shift-k).

(Ich werde die Dokumentationen auf der Einstellungsseite in den nächsten ein oder zwei Tagen aktualisieren.)

1 „Gefällt mir“