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

Я отправил PR, который можно найти по адресу #1 - Update javascripts/discourse/api-initializers/api-initializer.js - centertap/DiscourseComposerButtonBonanza - Codeberg.org

[Редактирование: Сайт Codeberg.org снова стал доступен, после того как ранее, когда я пытался сделать форк и отправить PR, он был недоступен.]

Если вы столкнулись с ошибками в DCBB и/или у вас есть исправления для них, было бы здорово, если бы вы создали issue в основном репозитории, чтобы сообщить обо всём этом, чтобы я мог их исправить — вместо того чтобы просто клонировать репозиторий где-то ещё и просить людей использовать ваш клон. (Если по какой-то причине вы не можете сделать форк репозитория на Codeberg, было бы неплохо узнать об этом напрямую, а не случайно через тему обсуждения, где я не всегда получаю уведомления по электронной почте.)

О, эй… как раз когда я собирался нажать Reply, появился PR! Спасибо!

Решает ли этот PR какие-либо проблемы, возникающие с <div> против <span> в WYSIWYG-Composer (что мучило @shauny), или он касается какой-то другой ошибки/неисправности, или только предупреждений об устаревании (то есть вещей, которые сломаются в любой момент, но пока ещё не сломаны)?

Мне бы хотелось понять, что изменилось за кулисами. Любые ссылки на темы или документацию Discourse были бы очень полезны.

Я работал с ChatGPT, чтобы создать исправление, которое можно просто вставить в мой JS-файл.

Суть в том, что оно перехватывает кнопку «Спойлер» и вместо этого вызывает действие по умолчанию для стандартной кнопки.

Мне бы очень хотелось увидеть это как часть плагина, если нет других способов это сделать (в идеале, вместо вызова меню и принудительного клика по кнопке, должно просто срабатывать то же действие, что и при нажатии на кнопку).

Вот код, который я добавил в JS-файл своей темы:

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

export default apiInitializer((api) => {
  /**
   * === Исправление для спойлера RTE в рамках Composer Button Bonanza ===
   *
   * Десктоп: работает путем нажатия на «Опции», а затем на «Размыть спойлер».
   * Мобильные устройства: разметка выпадающего меню отличается, поэтому мы должны искать элемент по тексту, а не по строгим селекторам.
   *
   */

  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; в режиме Markdown оставляем Bonanza как есть.
      if (!isRichTextComposerContext(bonanzaBtn)) return;

      // Предотвращаем вставку BBCode от Bonanza в режиме RTE
      e.preventDefault();
      e.stopImmediatePropagation();

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

Это довольно костыльное решение, но оно работает. Протестировано только в Safari на iOS/macOS и Chrome на macOS, на Android пока нет.

1 лайк

Я только что выпустил версию 2.0.0 плагина Composer Button Bonanza. Единственное изменение — исправление устаревшего использования site.desktopView. Подробности см. в коммите с исходным кодом.\n\n[quote=“tobiaseigen, post:21, topic:352525”]\nЯ добавил предупреждение в первый пост о том, что он несовместим с редактором rich text.\n[/quote]\n\nЯ обновил это предупреждение. После расследования проблем я считаю, что всё наоборот: редактор rich text несовместим с этим компонентом темы, поскольку его реализация ProsemirrorTextManipulation интерфейса TextManipulation неполная и/или некорректная.\n\nВ частности:\n * Реализация ProsemirrorTextManipulation.applyList() не совсем корректно использует параметр head, передаваемый вызывающей стороной. Вместо этого она смотрит на ключ для примера текста, переданного вызывающей стороной, чтобы угадать, что пытается сделать вызывающий, и она жёстко запрограммирована понимать только встроенные кнопки для маркированных списков, нумерованных списков и цитат.\n * Реализация ProsemirrorTextManipulation.applySurround() не соответствует поведению оригинальной реализации TextareaTextManipulation.applySurround() и отвечает за бездумное использование <div>, даже когда следует использовать <span>. Реализация Prosemirror также игнорирует аргумент opts функции applySurround(). (И, используя тот же трюк, что и applyList(), она жёстко кодирует ключи примеров текста для обнаружения кнопок курсива, жирного шрифта и моноширинного текста.)\n\n@renato, эти проблемы кто-то отслеживает? Есть ли график их исправления?

4 лайка

Можете уточнить, для чего именно они вам нужны?

Некоторые API, созданные во времена существования только редактора textarea, не предназначены для обеспечения полной функциональности в богатом редакторе. Мы не планируем переносить все возможности ProseMirror на уровень промежуточной абстракции.

Мы можем улучшить эти места, если это возможно и необходимо, но в целом, когда требуются сложные операции, мы обычно обращаемся к зависимостям ProseMirror напрямую через ключ commands в зарегистрированном расширении богатого редактора. Например:

В этом примере applySurround без проверки применяет bbcode-тег спойлера к любому выделенному тексту, тогда как toggleSpoiler использует все возможности ProseMirror, чтобы определить, находится ли курсор уже внутри узла спойлера, является ли это встроенным спойлером или блочным спойлером и так далее.

2 лайка

Если бы эти два метода были реализованы с большей точностью к интерфейсу, которому они принадлежат, я думаю, что Composer Button Bonanza работала бы «из коробки» в новом RTE (по крайней мере, так же хорошо, как в режиме Markdown). Меня удивляет, что ни один другой автор тем или плагинов ещё не поднял подобных вопросов. (Хотя, возможно, они это сделали; я не искал похожие жалобы.)

Я не знаю, что именно подразумевается под «всей мощью ProseMirror», но сомневаюсь, что это необходимо. Интерфейсы applyList() и applySurround() не так уж сложны, хотя в них есть больше, чем было реализовано на данный момент.

(То, что реализовано пока, выглядит не столько как последовательное «применение разметки списка к выделению» или «применение текстовой разметки вокруг выделения», сколько как «просто пересылка вызовов от известных встроенных кнопок панели инструментов к конкретным функциям ProseMirror».)

2 лайка

Привет!

Это фантастический компонент, который я упустил из виду.

Я работал над собственной версией похожего компонента темы, которая позволяет добавлять пользовательские сочетания клавиш для пользовательских обёрток (либо вида [wrap], либо любого допустимого HTML/Markdown/специфичного для Discourse содержимого).

Например, я могу определить Ctrl+Shift+K для вставки или оборачивания выделения в <kbd> </kbd>, или <hr>, или [wrap=announcement] [/wrap] (как встроенное, так и блочное) или что угодно, что нам нужно, а также добавил опциональные кнопки в меню шестерёнки.
Она поддерживает новые строки через \n в вставляемом содержимом.

Она также совместима с богатым редактором. Код для справки: GitHub - Canapin/discourse-custom-shortcuts · GitHub, с предупреждением: он написан с помощью Claude, и я ещё не проверял код. Кроме того, если бы я продолжал создавать своё решение вместо использования вашего, я бы, вероятно, изменил или добавил функции, чтобы использовать некоторые из ваших.

Вот как выглядит мой компонент:




Так что возвращаясь к вашему компоненту, мне нравится его универсальность, но для меня в нём не хватает двух фундаментальных вещей:

  • Поддержка богатого редактора
  • Простой способ добавления пользовательского содержимого (пользовательские обёртки, теги или другое)

Хотя я могу сгенерировать код для собственных вещей, я не чувствую себя достаточно уверенно, чтобы полагаться на ИИ при предложении pull request’ов (например, для поддержки богатого редактора) для вашего компонента, и я не программист.

Помимо вопроса с богатым редактором, каково ваше мнение о возможности добавления нового пользовательского содержимого в ваш компонент (по сути, того, что я описал для своего компонента)? Это было бы принято или это выходит за рамки его назначения?

1 лайк

Я не совсем понял ваш вопрос — разве Composer Button Bonanza уже не поддерживает это?

Вы пробовали настроить параметры компонента, в частности, параметр «Кнопки»?

  • «Кнопки» предназначены для определения кнопок (на самом деле, функций).
  • «Макет» используется для указания места и времени их отображения в интерфейсе (а также для назначения горячих клавиш).
  • «Переводы» служат для добавления переводов к определениям кнопок.
2 лайка

О да, я действительно пропустил эту настройку! Я внимательно изучу её, возможно, она решит мою задачу :handshake:

1 лайк

У меня не получается заставить работать сочетание клавиш.

image

Кнопка есть на панели инструментов и работает, но Ctrl+Shift+k ничего не делает. Есть какие-то идеи?

1 лайк

Ой, извините! Я допустил ошибку в документации по настройкам раскладки: в спецификаторах сочетаний клавиш должен использоваться знак «плюс», а не дефис.

Например: shift+kне shift-k).

(Я обновлю документацию на странице настроек в течение ближайших пары дней.)

1 лайк

Готово; выпущена версия 2.0.1. (Это также добавляет больше документации в целом и исправляет некоторые ошибки в CSS-стилизации новых кнопок.)

1 лайк